Files
callstack__agent-device/scripts/maestro-conformance/build-manifest.mjs
Michał Pierzchała 5df5ec469d feat(maestro): add positional selectors (#1911)
* feat(maestro): add positional selectors

* perf(maestro): resolve selectors once per snapshot
2026-08-20 18:21:57 +02:00

127 lines
6.2 KiB
JavaScript

#!/usr/bin/env node
// Rebuilds corpus/manifest.json from the flows on disk. `regenerate.mjs` runs
// this first, so adding a corpus flow is just: drop the .yaml in, add a note
// below if it needs one, and regenerate.
//
// The manifest is the corpus's provenance record: vendored upstream flows carry
// their upstream repo path and sha256 so a silently-edited flow is detectable;
// authored flows say why they exist.
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const CORPUS_DIR = path.join(HERE, 'corpus');
const UPSTREAM_REPO_DIR = 'maestro-test/src/test/resources';
const NOTES = {
'bug-classes/percent-decimal-swipe': 'Bug class 1: decimal percentage rejection at parse.',
'bug-classes/target-swipe-missing-direction':
'Bug class 2: target swipe requires an explicit direction.',
'bug-classes/retry-over-cap':
'Bug class 3: retry maxRetries parses verbatim; the clamp is a layer-2 vector.',
'bug-classes/settle-after-tap':
'Bug class 4: plain tap; settle ordering is a layer-3 differential.',
'authored/runscript': 'Coverage: runScript file command (no self-contained upstream flow).',
'authored/runflow-main': 'Coverage: runFlow file include + provenance.',
'authored/runflow-child': 'Include target for runflow-main (not parsed as a top-level flow).',
'authored/doubletap':
'Coverage: doubleTapOn (upstream 101 uses the unsupported retryTapIfNoChange option).',
'authored/label-metadata':
'Coverage: outer command labels stay metadata across selector, point, assertion, wait, scroll, swipe, and input commands.',
'authored/scroll-until-visible':
'Coverage: scrollUntilVisible (upstream 079 uses unsupported speed/visibilityPercentage).',
'authored/extended-wait':
'Coverage: extendedWaitUntil (upstream 042 interpolates ${TIMEOUT} from a flow env block).',
'authored/repeat':
'Coverage: repeat.times (upstream 053 also uses the unsupported evalScript command).',
'authored/presskey':
'Coverage: pressKey supported keys (upstream 034 exercises many unsupported keycodes).',
'authored/numeric-variable-tap':
'Coverage: tapOn repeat/delay/index numeric option fields accept ${VAR} tokens (upstream rejects repeat/delay as integer-typed; agent-device is lenient).',
'authored/numeric-variable-doubletap':
'Coverage: doubleTapOn delay accepts a ${VAR} token (upstream rejects as integer-typed; agent-device is lenient).',
'authored/numeric-variable-swipe':
'Coverage: swipe duration accepts a ${VAR} token (upstream rejects as integer-typed; agent-device is lenient).',
'authored/numeric-variable-erase':
'Coverage: eraseText charactersToErase accepts a ${VAR} token (upstream rejects as integer-typed; agent-device is lenient).',
'authored/numeric-variable-tap-index':
'Coverage: tapOn index accepts a ${VAR} token and projects identically through the canonical model.',
'authored/tree-relations':
'Coverage: recursive selector index, childOf, containsChild, and containsDescendants across tapOn and assertVisible.',
'authored/positional-relations':
'Coverage: above, below, leftOf, and rightOf recursively across target, assertion, wait, scroll, and swipe commands.',
'authored/numeric-variable-wait':
'Coverage: waitForAnimationToEnd timeout accepts a ${VAR} token and projects identically through the canonical model.',
'invalid/bad-swipe-direction': 'Lenient-guard: unknown SwipeDirection enum value.',
'invalid/unknown-command': 'Lenient-guard: unknown command name (tapOn typo).',
'invalid/malformed-selector': 'Lenient-guard: selector given as a sequence.',
'invalid/duplicate-keys': 'Lenient-guard: duplicate YAML mapping keys.',
'invalid/unknown-selector-field': 'Lenient-guard: unknown field inside a selector map.',
'invalid/commands-not-a-list': 'Lenient-guard: command document is not a sequence.',
};
const sha256 = (file) => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const yamlIn = (dir) =>
fs.existsSync(path.join(CORPUS_DIR, dir))
? fs
.readdirSync(path.join(CORPUS_DIR, dir))
.filter((n) => n.endsWith('.yaml'))
.sort()
: [];
function entriesFor(dir, kind) {
return yamlIn(dir).map((name) => {
const stem = name.slice(0, -'.yaml'.length);
const id = `${dir}/${stem}`;
const entry = { id, file: `${dir}/${name}` };
if (kind === 'upstream') {
entry.origin = {
kind: 'upstream',
repoPath: `${UPSTREAM_REPO_DIR}/${name}`,
sha256: sha256(path.join(CORPUS_DIR, dir, name)),
};
} else {
entry.origin = { kind: 'authored', note: NOTES[id] ?? '' };
}
if (dir === 'bug-classes') entry.bugClass = stem;
if (id === 'authored/runflow-child') entry.includeTargetOnly = true;
return entry;
});
}
function buildManifest(pin) {
return {
description:
'Corpus of Maestro flows driven through the upstream parser to generate layer-1 fixtures. Upstream flows are vendored verbatim from the pinned commit; sha256 records provenance. Authored flows fill coverage gaps, encode the four #1217 bug classes, and (invalid/) give the never-accept-what-upstream-rejects guard teeth. Generated by build-manifest.mjs — do not hand-edit.',
upstreamCommit: pin.commit,
flows: [
...entriesFor('upstream', 'upstream'),
...entriesFor('bug-classes', 'authored'),
...entriesFor('invalid', 'authored'),
...entriesFor('authored', 'authored'),
],
};
}
export function writeManifest(pin) {
const manifest = buildManifest(pin);
fs.writeFileSync(
path.join(CORPUS_DIR, 'manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`,
);
const missing = manifest.flows.filter((f) => f.origin.kind === 'authored' && !f.origin.note);
for (const flow of missing) {
console.warn(`warning: ${flow.id} has no note — add one to NOTES in build-manifest.mjs`);
}
return manifest;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const pin = JSON.parse(fs.readFileSync(path.join(HERE, 'pinned-upstream.json'), 'utf8'));
const manifest = writeManifest(pin);
console.log(`wrote corpus/manifest.json (${manifest.flows.length} flows)`);
}