Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,7 @@ export const cloudCommand = defineCommand({
flowMetadata,
flowOverrides,
flowsToRun: testFileNames,
includedFiles,
referencedFiles,
sequence,
} = executionPlan;
Expand All @@ -635,10 +636,27 @@ export const cloudCommand = defineCommand({
out(`[DEBUG] Test file names: ${testFileNames.join(', ')}`);
}

const commonRoot = computeCommonRoot(testFileNames, referencedFiles);
const commonRoot = computeCommonRoot(
testFileNames,
referencedFiles,
includedFiles,
);

if (debug) {
out(`[DEBUG] Common root directory: ${commonRoot}`);

// `includedPaths` files sitting beside the flows tree rather than
// inside it raise the common root, so every server-side flow key gains
// a leading segment. Harmless but visible in the console, so say it.
const rootWithoutIncludes = computeCommonRoot(
testFileNames,
referencedFiles,
);
if (includedFiles.length > 0 && rootWithoutIncludes !== commonRoot) {
out(
`[DEBUG] \`includedPaths\` raised the common root from ${rootWithoutIncludes} to ${commonRoot} — flow paths gain a leading segment`,
);
}
}

const testMetadataMap = buildTestMetadataMap(flowMetadata, commonRoot);
Expand Down
1 change: 1 addition & 0 deletions src/mcp/tools/run-cloud-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export function registerRunCloudTest(server: McpServer): void {
const commonRoot = computeCommonRoot(
executionPlan.flowsToRun,
executionPlan.referencedFiles,
executionPlan.includedFiles,
);
const testMetadataMap = buildTestMetadataMap(
executionPlan.flowMetadata,
Expand Down
141 changes: 140 additions & 1 deletion src/services/execution-plan.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ export interface IExecutionPlan {
flowMetadata: Record<string, Record<string, unknown>>;
flowOverrides: Record<string, Record<string, unknown>>;
flowsToRun: string[];
/**
* Extra files pulled in by `config.yaml`'s `includedPaths`. Kept separate
* from `referencedFiles` (which is derived from flow commands) so the zip
* manifest and the common-root calculation can tell the two apart.
*/
includedFiles: string[];
referencedFiles: string[];
sequence?: IFlowSequence | null;
totalFlowFiles: number;
Expand Down Expand Up @@ -178,6 +184,7 @@ async function planSingleFile(
normalizedInput: string,
warn: (message: string) => void,
resolvedConfigFile?: string,
debug = false,
): Promise<IExecutionPlan> {
const inputBasename = path.basename(normalizedInput);
if (
Expand Down Expand Up @@ -218,11 +225,24 @@ async function planSingleFile(
}
}

// A single-file input has no workspace directory, so `includedPaths` (which
// only reaches here via --config) anchors on the flow file's own directory —
// the same place Maestro resolves an assertScreenshot baseline from.
const includedFiles = workspaceConfig
? resolveIncludedPaths(
workspaceConfig,
path.dirname(normalizedInput),
warn,
debug,
)
: [];

const checkedDependancies = await checkDependencies(normalizedInput);
return {
flowMetadata,
flowOverrides,
flowsToRun: [normalizedInput],
includedFiles,
referencedFiles: [...new Set(checkedDependancies)],
totalFlowFiles: 1,
workspaceConfig,
Expand Down Expand Up @@ -288,6 +308,119 @@ async function applyFlowGlobs(
return unfilteredFlowFiles.filter((file) => !isExcludedConfig(file));
}

/**
* The whole archive is buffered in memory by `compressFilesFromRelativePath`,
* so an unbounded `**` glob is a real footgun. These are warn thresholds, not
* hard limits — a legitimately large baseline set should still upload.
*/
const INCLUDED_PATHS_FILE_WARN_THRESHOLD = 200;
const INCLUDED_PATHS_BYTES_WARN_THRESHOLD = 50 * 1024 * 1024;

/**
* Resolve `config.yaml`'s `includedPaths` globs into absolute file paths.
*
* This is the general-purpose escape hatch for shipping files the flow
* commands don't reference: `assertScreenshot` baselines above all, but also
* fixtures, test data and certificates. Only `addMedia` / `runFlow` /
* `runScript` arguments are discovered by walking the flows, so without this
* key such files are silently absent from the uploaded zip.
*
* Glob semantics deliberately mirror `flows:` (`applyFlowGlobs`): patterns
* resolve against the workspace root, never the config file's directory, so
* `--config ci/workspace.yaml` behaves identically to an auto-detected config.
*
* @param workspaceConfig - Validated workspace config
* @param normalizedInput - Normalized path to the workspace directory
* @param warn - Sink for non-fatal problems
* @param debug - Whether to emit debug logging
* @returns Absolute paths of every matched file, deduped and sorted
* @throws Error if a pattern escapes the workspace root
*/
function resolveIncludedPaths(
workspaceConfig: IWorkspaceConfig,
normalizedInput: string,
warn: (message: string) => void,
debug = false,
): string[] {
const patterns = workspaceConfig.includedPaths;
if (!patterns || patterns.length === 0) return [];

const workspaceRoot = path.resolve(normalizedInput);
const resolved = new Set<string>();
const unmatched: string[] = [];

for (const pattern of patterns) {
// fs.globSync lands in Node 22; the CLI's `engines.node` already requires
// it. No `nodir` option — directories are stripped by the stat check below.
const matches = fs.globSync(pattern, { cwd: normalizedInput });
let matchedFile = false;

for (const match of matches) {
const absolute = path.resolve(normalizedInput, match);

// Containment guard: `flows:` has none because its matches are only ever
// parsed as YAML, but this key ships arbitrary bytes to a remote runner,
// so a `../../../` climb must not silently leave the workspace.
const relative = path.relative(workspaceRoot, absolute);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(
`\`includedPaths\` pattern "${pattern}" resolves outside the workspace: ${absolute}\n\n` +
`Included paths must stay within ${workspaceRoot}.`,
);
}

try {
if (!fs.statSync(absolute).isFile()) continue;
} catch {
continue;
}

matchedFile = true;
resolved.add(absolute);
}

if (!matchedFile) unmatched.push(pattern);
}

if (unmatched.length > 0) {
warn(
`Warning: \`includedPaths\` pattern(s) in config matched no files:\n` +
`${unmatched.map((pattern) => ` ${pattern}`).join('\n')}\n\n` +
`Patterns are resolved relative to ${workspaceRoot}.`,
);
}

const files = [...resolved].sort((a, b) => a.localeCompare(b));

let totalBytes = 0;
for (const file of files) {
try {
totalBytes += fs.statSync(file).size;
} catch {
// Raced away between glob and stat; the zip step reports it properly.
}
}

if (
files.length > INCLUDED_PATHS_FILE_WARN_THRESHOLD ||
totalBytes > INCLUDED_PATHS_BYTES_WARN_THRESHOLD
) {
warn(
`Warning: \`includedPaths\` matched ${files.length} file(s) totalling ` +
`${Math.round(totalBytes / (1024 * 1024))} MB. The flow archive is built in ` +
`memory, so consider narrowing the patterns.`,
);
}

if (debug) {
console.log(
`[DEBUG] includedPaths matched ${files.length} file(s):\n${files.join('\n')}`,
);
}

return files;
}

/**
* Resolve sequential execution order from workspace config
* @param workspaceConfig - Workspace configuration with executionOrder
Expand Down Expand Up @@ -382,7 +515,7 @@ export async function plan(options: PlanOptions): Promise<IExecutionPlan> {
}

if (fs.lstatSync(normalizedInput).isFile()) {
return planSingleFile(normalizedInput, warn, resolvedConfigFile);
return planSingleFile(normalizedInput, warn, resolvedConfigFile, debug);
}

let unfilteredFlowFiles = await readDirectory(normalizedInput, isFlowFile);
Expand Down Expand Up @@ -522,6 +655,12 @@ export async function plan(options: PlanOptions): Promise<IExecutionPlan> {
flowMetadata,
flowOverrides,
flowsToRun: normalFlows,
includedFiles: resolveIncludedPaths(
workspaceConfig,
normalizedInput,
warn,
debug,
),
referencedFiles: [...new Set(allFiles)],
sequence: {
continueOnFailure: workspaceConfig.executionOrder?.continueOnFailure,
Expand Down
76 changes: 71 additions & 5 deletions src/services/execution-plan.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,53 @@ import {
WORKSPACE_CONFIG_KEYS,
} from './workspace-config.schema.js';

const commandsThatRequireFiles = new Set(['addMedia', 'runFlow', 'runScript']);
const commandsThatRequireFiles = new Set([
'addMedia',
'assertScreenshot',
'runFlow',
'runScript',
]);

/**
* Commands whose file references are best-effort rather than mandatory.
*
* `assertScreenshot` baselines are legitimately absent on a first run, and
* Maestro's own "Screenshot file not found — searched in: …" error is more
* useful than ours, so a missing baseline must not abort the upload the way a
* missing `addMedia` file does.
*/
const commandsWithOptionalFiles = new Set(['assertScreenshot']);

/**
* Extensions Maestro's `normalizeScreenshotPath` recognises; anything else
* gets `.png` appended, so `assertScreenshot: home` means `home.png`.
*/
const SCREENSHOT_EXTENSIONS = new Set([
'.bmp',
'.gif',
'.heic',
'.heif',
'.jpeg',
'.jpg',
'.png',
'.tiff',
'.wbmp',
]);

/**
* Mirror Maestro's `Orchestra.normalizeScreenshotPath`: a screenshot path with
* no image extension gets `.png`. Without this, `assertScreenshot: home` looks
* like a missing file here while resolving fine on the device.
*
* @param relativePath - The path as written in the flow
* @returns The path with an image extension guaranteed
*/
function normalizeScreenshotPath(relativePath: string): string {
const extension = path.extname(relativePath).toLowerCase();
return SCREENSHOT_EXTENSIONS.has(extension)
? relativePath
: `${relativePath}.png`;
}

export function getFlowsToRunInSequence(
paths: { [key: string]: string },
Expand Down Expand Up @@ -189,18 +235,34 @@ export const checkIfFilesExistInWorkspace = (
const errors: string[] = [];
const files: string[] = [];
const directory = path.dirname(absoluteFilePath);
const isScreenshot = commandName === 'assertScreenshot';
const isOptional = commandsWithOptionalFiles.has(commandName);

const buildError = (error: string) =>
`Flow file "${absoluteFilePath}" has a command "${commandName}" that references a ${error} ${JSON.stringify(
command,
)}`;

const processFilePath = (relativePath: string) => {
// A JS/variable-interpolated path (`screenshots/${DCD_DEVICE}/home`) can't
// be resolved without running the flow. Skip it rather than guessing — the
// config.yaml `includedPaths` key is how those files get bundled.
if (relativePath.includes('${')) return;

const resolvedRelativePath = isScreenshot
? normalizeScreenshotPath(relativePath)
: relativePath;
const absoluteFilePath = path.normalize(
path.resolve(directory, relativePath),
path.resolve(directory, resolvedRelativePath),
);
const error = checkFile(absoluteFilePath);
if (error) errors.push(buildError(error));
if (error) {
// Optional references drop out entirely when missing: pushing them onto
// `files` would put a non-existent path into the zip manifest.
if (isOptional) return;
errors.push(buildError(error));
}

files.push(absoluteFilePath);
};

Expand All @@ -216,9 +278,13 @@ export const checkIfFilesExistInWorkspace = (
}
}

// object command
// object command. `file` is addMedia/runFlow/runScript; `path` is
// assertScreenshot's own key for the same thing.
const x = command as Record<string, string>; // prevent annoying ts error
if (typeof command === 'object' && x?.file) processFilePath(x.file);
if (typeof command === 'object' && !Array.isArray(command)) {
if (x?.file) processFilePath(x.file);
if (isScreenshot && typeof x?.path === 'string') processFilePath(x.path);
}

return { errors, files };
};
Expand Down
17 changes: 14 additions & 3 deletions src/services/flow-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,25 @@ import { toPortableRelativePath } from '../utils/paths.js';
* file path. Segment comparison (not `startsWith`) so sibling dirs like
* `flows`/`flows-extra` can't merge, and the file segment itself is never
* consumed. Returns '' when the paths share no root at all (or none are given).
*
* `includedFiles` (config.yaml `includedPaths`) must be folded in for the same
* reason referenced files are: the zip strips this root as an anchored prefix,
* so a file outside it would get a non-relative entry name. Folding them in
* can raise the root — flows in `flows/` beside baselines in `screenshots/`
* shifts it from `<root>/flows` to `<root>`, so flow keys gain a `flows/`
* segment. That shift is what preserves the flow→baseline relative offset
* Maestro resolves against, and is already how `addMedia` behaves.
*/
export function computeCommonRoot(
testFileNames: string[],
referencedFiles: string[],
includedFiles: string[] = [],
): string {
const pathsShortestToLongest = [...testFileNames, ...referencedFiles].sort(
(a, b) => a.split(path.sep).length - b.split(path.sep).length,
);
const pathsShortestToLongest = [
...testFileNames,
...referencedFiles,
...includedFiles,
].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
if (pathsShortestToLongest.length === 0) return '';

const splitPaths = pathsShortestToLongest.map((p) => p.split(path.sep));
Expand Down
Loading
Loading