done-done-axi 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +80 -40
- package/dist/config.js +12 -2
- package/dist/dom.js +83 -9
- package/dist/lint.js +30 -3
- package/dist/look.js +23 -5
- package/docs/examples/statechart-pilot/PaneAttach.tla +151 -0
- package/docs/examples/statechart-pilot/README.md +109 -0
- package/docs/examples/statechart-pilot/check.mjs +72 -0
- package/docs/examples/statechart-pilot/pane-attach-race.md +37 -0
- package/docs/reference.md +27 -6
- package/package.json +1 -1
- package/skills/done-done/SKILL.md +5 -1
package/dist/cli.js
CHANGED
|
@@ -128,6 +128,8 @@ const FLAGS = {
|
|
|
128
128
|
'--run <run-id>': 'the run to show (default: the newest run of this checkout)',
|
|
129
129
|
'--probe': 'ask the configured agent one JSON-schema question and require a valid answer (one call at the agent\'s model: its cheap default where it has one, else its own; set agent_config.<agent>.model to choose)',
|
|
130
130
|
'--mock <screen>=<mock>': 'judge this screen next to its approved mock (repeat per screen; the screen is judged even when not listed)',
|
|
131
|
+
'--mock <file>': 'with look: the approved design of this page (PNG or JPEG); the judge the user set compares the capture with it, as design --mock does, and a defect fails look',
|
|
132
|
+
'--act <selector>': 'with look: click the element this CSS selector names, then require the page to change what a user can read (its text, elements or address) within 5s; repeat to click several in order; look measures and captures the page they leave',
|
|
131
133
|
'--step <name>': 'the step log to show (verify: resolve, drive, check, judge, gate, receipt, report; maintain: locate, hygiene, source-wave, reconcile, triage, fix, report)',
|
|
132
134
|
'--feature <name>': 'with no journey, the author agent drives this feature of the verify skill (features/<name>.md); default: the only feature, else the agent picks the one the outcome is about',
|
|
133
135
|
'--action <approve|skip|fix>': 'at a spend gate, approve: spend and run the journey; skip: end the run with nothing spent. At the judge gate only fix or skip: fix ends it fail, then the product or the verify skill is fixed (done-done maintain checks it against the source); skip ends it fail without the fix',
|
|
@@ -220,12 +222,14 @@ const COMMANDS = {
|
|
|
220
222
|
passed, 1 a screen failed, 3 none failed but one is unsure`],
|
|
221
223
|
},
|
|
222
224
|
look: {
|
|
223
|
-
usage: [['done-done look <url> [--out <file.png>]', 'open a page in headless Chrome at a phone viewport, capture it with its page-check record, and fail on any squashed, clipped or cut-off UI']],
|
|
224
|
-
flags: ['--out <file.png>'],
|
|
225
|
-
more: [`look: one
|
|
225
|
+
usage: [['done-done look <url> [--act <selector>]... [--mock <file>] [--out <file.png>]', 'open a page in headless Chrome at a phone viewport, click each action, capture it with its page-check record, and fail on any squashed, clipped or cut-off UI, an action that changes nothing, or a mismatch with the design']],
|
|
226
|
+
flags: ['--act <selector>', '--mock <file>', '--out <file.png>'],
|
|
227
|
+
more: [`look: one step a journey calls as its command or inside its script; with no --mock, no judge and no model. It measures horizontal
|
|
226
228
|
overflow, squashed text, text clipped by its container and elements cut off by the left, right or top edge, writes the
|
|
227
|
-
screenshot and its .dom.json record, and exits 1 naming each element, kind and text, 0 when there is none.
|
|
228
|
-
|
|
229
|
+
screenshot and its .dom.json record, and exits 1 naming each element, kind and text, 0 when there is none. Each --act is
|
|
230
|
+
clicked and must change the page. With a look block in .done-done.yaml (the approved reference: tokens, max_control) it
|
|
231
|
+
also fails on a control drawn as the browser's own and on one taller than max_control. A hit always fails: never wrap it
|
|
232
|
+
in || true. Exit 3 when the only doubt is an unsure judge. Needs Chrome or Chromium (DONE_DONE_CHROME names the binary)`],
|
|
229
233
|
},
|
|
230
234
|
gate: {
|
|
231
235
|
usage: [['done-done gate [<head>] [--dir <folder>] [--pushed <time>]', 'no-model check for a hosted runner: PASS or FAIL: <reason> for a receipt committed under .done-done-receipts/']],
|
|
@@ -764,6 +768,7 @@ async function runCheck(argv, run) {
|
|
|
764
768
|
const captures = [];
|
|
765
769
|
// The run behind each cited capture, for the real-product rule and the receipt.
|
|
766
770
|
const runs = new Map();
|
|
771
|
+
const warnings = [];
|
|
767
772
|
// base_ref: a capture in <run id>/before/ of a run that drove its base ref pairs with <run id>/<the same name>.
|
|
768
773
|
const pairOf = (inRoot) => {
|
|
769
774
|
const [id, dir, ...rest] = inRoot?.split(sep) ?? [];
|
|
@@ -809,7 +814,7 @@ async function runCheck(argv, run) {
|
|
|
809
814
|
const inRoot = under(cfg.evidence.root, path);
|
|
810
815
|
const pair = pairOf(inRoot);
|
|
811
816
|
if (cfg.dom && !pair && ['png', 'jpeg', 'webp', 'gif', 'mp4', 'webm'].includes(v.type))
|
|
812
|
-
for (const why of domIssues(cfg.dom, path))
|
|
817
|
+
for (const why of domIssues(cfg.dom, path, cfg.look))
|
|
813
818
|
findings.push({ level: 'fail', why: `${name}: ${why}` });
|
|
814
819
|
// A base capture may equal its head capture byte for byte: the pair's judge, not this check, says the screen did not change.
|
|
815
820
|
if (seen.has(hash) && !record && !pair)
|
|
@@ -839,7 +844,7 @@ async function runCheck(argv, run) {
|
|
|
839
844
|
continue;
|
|
840
845
|
}
|
|
841
846
|
const rec = (atNow.length ? atNow : claims).reduce((a, b) => (b.started_at > a.started_at ? b : a));
|
|
842
|
-
runs.set(rec.run_id, { run_id: rec.run_id, journey: rec.journey, command: rec.command, ...(rec.feature && { feature: rec.feature }), ...(rec.tool && { tool: rec.tool }), ...(rec.steps && { steps: rec.steps }), ...(rec.steps_error && { steps_error: rec.steps_error }) });
|
|
847
|
+
runs.set(rec.run_id, { run_id: rec.run_id, commit: rec.commit, journey: rec.journey, command: rec.command, ...(rec.feature && { feature: rec.feature }), ...(rec.tool && { tool: rec.tool }), ...(rec.steps && { steps: rec.steps }), ...(rec.steps_error && { steps_error: rec.steps_error }) });
|
|
843
848
|
// Only a capture tied to a run record can reach the judge, so a stray cited file is never sent.
|
|
844
849
|
const c = { name, bytes, sha256: hash, media: v };
|
|
845
850
|
if (v.status === 'ok' && !record && pair)
|
|
@@ -849,7 +854,11 @@ async function runCheck(argv, run) {
|
|
|
849
854
|
if (rec.base)
|
|
850
855
|
heads.push([c, inRoot, rec]);
|
|
851
856
|
}
|
|
852
|
-
const
|
|
857
|
+
const kept = [];
|
|
858
|
+
const stale = staleness(repo, cfg, rec.commit, now.commit, rec, kept);
|
|
859
|
+
for (const w of kept)
|
|
860
|
+
if (!warnings.includes(w))
|
|
861
|
+
warnings.push(w);
|
|
853
862
|
if (stale)
|
|
854
863
|
findings.push({ level: 'fail', why: `${name}: ${stale} (run ${rec.run_id})` });
|
|
855
864
|
if (rec.dirty && !cfg.evidence.allow_dirty)
|
|
@@ -873,7 +882,6 @@ async function runCheck(argv, run) {
|
|
|
873
882
|
if (codeRuns)
|
|
874
883
|
findings.push({ level: 'fail', why: codeRuns });
|
|
875
884
|
// The user steps behind the captures: a cited real run needs a step record covering the claim. Runs from before step records warn.
|
|
876
|
-
const warnings = [];
|
|
877
885
|
if (claim && runs.size && !codeRuns) {
|
|
878
886
|
const s = stepsFault([...runs.values()], claim.outcome);
|
|
879
887
|
if (s.fault)
|
|
@@ -1079,7 +1087,7 @@ function recordReceipt(repo, cfg, r) {
|
|
|
1079
1087
|
return { code: PASS, result: { ...result, ...(r.judge && { judge: r.judge }), help: [`Verify this receipt (merge gate): done-done receipt --verify ${r.head.commit}`] } };
|
|
1080
1088
|
}
|
|
1081
1089
|
// Why evidence from `from` no longer proves `to`, or '' if it still does.
|
|
1082
|
-
function staleness(repo, cfg, from, to) {
|
|
1090
|
+
function staleness(repo, cfg, from, to, run, warnings = []) {
|
|
1083
1091
|
if (from === to)
|
|
1084
1092
|
return '';
|
|
1085
1093
|
let changed;
|
|
@@ -1092,6 +1100,19 @@ function staleness(repo, cfg, from, to) {
|
|
|
1092
1100
|
const outside = changed.filter((p) => !cfg.evidence.ignore_paths.some((g) => matchesGlob(p, g)));
|
|
1093
1101
|
if (changed.length && !outside.length)
|
|
1094
1102
|
return '';
|
|
1103
|
+
// A head change that touches no journey or feature the run covered leaves its proof valid; both commits are reported.
|
|
1104
|
+
const needs = outside.filter((p) => !p.startsWith(`${GATE_DIR}/`));
|
|
1105
|
+
if (run && (run.journey || run.feature) && needs.length) {
|
|
1106
|
+
const m = mapDiff(repo, cfg, needs, git(repo, 'ls-files', '-z').split('\0').filter(Boolean));
|
|
1107
|
+
if (!m.journeys.some((j) => j.name === run.journey) && !m.features.some((f) => f.name === run.feature)) {
|
|
1108
|
+
warnings.push(`run ${run.run_id} is for commit ${from.slice(0, 12)} and HEAD is ${to.slice(0, 12)}: its ${run.journey ? `journey ${run.journey}` : `feature ${run.feature}`} is untouched by the ${needs.length} changed file${needs.length === 1 ? '' : 's'} (done-done affected)`);
|
|
1109
|
+
return '';
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
if (run && changed.length && !needs.length) {
|
|
1113
|
+
warnings.push(`run ${run.run_id} is for commit ${from.slice(0, 12)} and HEAD is ${to.slice(0, 12)}: only gate receipts changed`);
|
|
1114
|
+
return '';
|
|
1115
|
+
}
|
|
1095
1116
|
return `evidence is for commit ${from.slice(0, 12)}, HEAD is ${to.slice(0, 12)}${outside.length ? ` (changed: ${outside.slice(0, 3).join(', ')}${outside.length > 3 ? ', ...' : ''})` : ''}`;
|
|
1096
1117
|
}
|
|
1097
1118
|
// Quote an argument only when a shell would split it.
|
|
@@ -3812,9 +3833,9 @@ async function cmdDoctor(argv) {
|
|
|
3812
3833
|
emit(result);
|
|
3813
3834
|
return missingRequired.length ? USAGE : PASS;
|
|
3814
3835
|
}
|
|
3815
|
-
// The
|
|
3836
|
+
// The look step: one page measured, with its actions clicked, against the project's look reference and the --mock design.
|
|
3816
3837
|
async function cmdLook(argv) {
|
|
3817
|
-
const { values, positionals } = parseArgs({ args: argv, options: { out: { type: 'string' } }, allowPositionals: true });
|
|
3838
|
+
const { values, positionals } = parseArgs({ args: argv, options: { out: { type: 'string' }, mock: { type: 'string' }, act: { type: 'string', multiple: true } }, allowPositionals: true });
|
|
3818
3839
|
if (positionals.length !== 1)
|
|
3819
3840
|
throw new UsageError('look needs one page URL', 'Re-run with the page to check: done-done look <url>');
|
|
3820
3841
|
// A path to a page on disk opens as its file:// URL.
|
|
@@ -3822,23 +3843,36 @@ async function cmdLook(argv) {
|
|
|
3822
3843
|
const capture = resolve(values.out ?? join(process.env.DONE_DONE_EVIDENCE_ROOT || '.', 'look.png'));
|
|
3823
3844
|
if (!capture.endsWith('.png'))
|
|
3824
3845
|
throw new UsageError(`--out ${values.out} must be a .png file`);
|
|
3846
|
+
const cfg = loadConfig(repoOrCwd());
|
|
3847
|
+
// The mock and the judge are checked before Chrome starts, so a bad one costs no capture.
|
|
3848
|
+
const mock = values.mock === undefined ? undefined : loadScreen(values.mock);
|
|
3849
|
+
const choice = mock && (await preflightJudge(cfg, false));
|
|
3825
3850
|
mkdirSync(dirname(capture), { recursive: true });
|
|
3826
|
-
const record = await look(url, capture, 390, 844);
|
|
3827
|
-
const hits = lookIssues(record);
|
|
3851
|
+
const record = await look(url, capture, 390, 844, values.act ?? []);
|
|
3852
|
+
const hits = lookIssues(record, cfg.look);
|
|
3853
|
+
const [judged] = mock && choice ? await design(cfg, choice, [{ ...loadScreen(capture), mock }], join(home(), 'agents')) : [];
|
|
3854
|
+
for (const d of judged?.defects ?? [])
|
|
3855
|
+
hits.push(`${url} differs from the approved design ${values.mock}: ${d.kind}: ${d.where}`);
|
|
3856
|
+
const code = hits.length ? FAIL : judged?.verdict === 'unsure' ? INCONCLUSIVE : PASS;
|
|
3828
3857
|
const shown = cap(hits);
|
|
3858
|
+
const again = `done-done look ${[positionals[0], ...(values.act ?? []).flatMap((a) => ['--act', a]), ...(values.mock ? ['--mock', values.mock] : []), ...(values.out ? ['--out', values.out] : [])].map(quote).join(' ')}`;
|
|
3829
3859
|
emit({
|
|
3830
3860
|
command: 'look',
|
|
3831
3861
|
// look measures the layout; it gives no verdict on the outcome, which only the judged check does.
|
|
3832
|
-
result:
|
|
3862
|
+
result: code === FAIL ? 'fail' : code === INCONCLUSIVE ? 'inconclusive' : 'clean',
|
|
3833
3863
|
url,
|
|
3834
3864
|
viewport: '390x844',
|
|
3835
3865
|
capture,
|
|
3836
3866
|
record: domRecord(capture),
|
|
3867
|
+
...(cfg.look && { reference: { ...cfg.look, ...(values.mock && { mock: values.mock }) } }),
|
|
3868
|
+
...(judged && { judge: { route: choice.route, model: choice.model, verdict: judged.verdict, ...(judged.reason && { reason: judged.reason }), ...(judged.cached && { cached: true }) } }),
|
|
3837
3869
|
hits: shown.items,
|
|
3838
3870
|
...(shown.omitted && { hits_omitted: shown.omitted }),
|
|
3839
|
-
help:
|
|
3871
|
+
help: code === FAIL ? [`Fix each element or action named above so it fits the 390 px wide screen, the design and its reference, then re-run: ${again}`]
|
|
3872
|
+
: code === INCONCLUSIVE ? [`The judge is unsure the page matches ${values.mock}: look at both yourself, then re-run: ${again}`]
|
|
3873
|
+
: ['No squashed, clipped or cut-off UI on this page; look at the capture to check the rest'],
|
|
3840
3874
|
});
|
|
3841
|
-
return
|
|
3875
|
+
return code;
|
|
3842
3876
|
}
|
|
3843
3877
|
// The folder of a branch where a receipt copied from the evidence root is committed, for `gate`.
|
|
3844
3878
|
const GATE_DIR = '.done-done-receipts';
|
|
@@ -3916,6 +3950,25 @@ function cmdLint(argv) {
|
|
|
3916
3950
|
console.log(!v.ok ? `FAIL: ${v.why}` : v.warn ? `WARN: ${v.warn}` : 'PASS');
|
|
3917
3951
|
return v.ok ? PASS : FAIL;
|
|
3918
3952
|
}
|
|
3953
|
+
// A screen or mock file for the design judge.
|
|
3954
|
+
function loadScreen(name) {
|
|
3955
|
+
let bytes;
|
|
3956
|
+
try {
|
|
3957
|
+
bytes = readFileSync(name);
|
|
3958
|
+
}
|
|
3959
|
+
catch (e) {
|
|
3960
|
+
throw new UsageError(`${name}: cannot read (${e.code ?? e.message})`, 'Pass screen and mock files that exist');
|
|
3961
|
+
}
|
|
3962
|
+
return { name, bytes, sha256: sha256(bytes), media: checkMedia(name, bytes) };
|
|
3963
|
+
}
|
|
3964
|
+
function repoOrCwd() {
|
|
3965
|
+
try {
|
|
3966
|
+
return repoRoot();
|
|
3967
|
+
}
|
|
3968
|
+
catch {
|
|
3969
|
+
return process.cwd();
|
|
3970
|
+
}
|
|
3971
|
+
}
|
|
3919
3972
|
async function cmdDesign(argv) {
|
|
3920
3973
|
const { values, positionals } = parseArgs({ args: argv, options: { mock: { type: 'string', multiple: true } }, allowPositionals: true });
|
|
3921
3974
|
const mocks = new Map();
|
|
@@ -3928,25 +3981,8 @@ async function cmdDesign(argv) {
|
|
|
3928
3981
|
const names = [...new Set([...positionals, ...[...mocks.keys()].filter((p) => !positionals.some((n) => resolve(n) === p))])];
|
|
3929
3982
|
if (!names.length)
|
|
3930
3983
|
throw new UsageError('design needs at least one screen file', 'Re-run with the screens to check: done-done design <screens...>');
|
|
3931
|
-
const
|
|
3932
|
-
|
|
3933
|
-
try {
|
|
3934
|
-
bytes = readFileSync(name);
|
|
3935
|
-
}
|
|
3936
|
-
catch (e) {
|
|
3937
|
-
throw new UsageError(`${name}: cannot read (${e.code ?? e.message})`, 'Pass screen and mock files that exist');
|
|
3938
|
-
}
|
|
3939
|
-
return { name, bytes, sha256: sha256(bytes), media: checkMedia(name, bytes) };
|
|
3940
|
-
};
|
|
3941
|
-
const screens = names.map((n) => ({ ...load(n), ...(mocks.has(resolve(n)) && { mock: load(mocks.get(resolve(n))) }) }));
|
|
3942
|
-
let repo;
|
|
3943
|
-
try {
|
|
3944
|
-
repo = repoRoot();
|
|
3945
|
-
}
|
|
3946
|
-
catch {
|
|
3947
|
-
repo = process.cwd();
|
|
3948
|
-
}
|
|
3949
|
-
const cfg = loadConfig(repo);
|
|
3984
|
+
const screens = names.map((n) => ({ ...loadScreen(n), ...(mocks.has(resolve(n)) && { mock: loadScreen(mocks.get(resolve(n))) }) }));
|
|
3985
|
+
const cfg = loadConfig(repoOrCwd());
|
|
3950
3986
|
const choice = await preflightJudge(cfg, false);
|
|
3951
3987
|
const judged = await design(cfg, choice, screens, join(home(), 'agents'));
|
|
3952
3988
|
const count = (v) => judged.filter((j) => j.verdict === v).length;
|
|
@@ -3992,6 +4028,13 @@ function defaultBranch(repo) {
|
|
|
3992
4028
|
}
|
|
3993
4029
|
}
|
|
3994
4030
|
const isAncestor = (repo, a, b) => spawnSync('git', ['merge-base', '--is-ancestor', a, b], { cwd: repo, stdio: 'ignore' }).status === 0;
|
|
4031
|
+
// The journeys and features `changed` needs (src/affected.ts), from the verify skill at the working tree.
|
|
4032
|
+
function mapDiff(repo, cfg, changed, tracked, noBase) {
|
|
4033
|
+
const featDir = join(repo, cfg.skill, 'features');
|
|
4034
|
+
const features = Object.fromEntries(skillFeatures(repo, cfg).map((n) => [n, repoText(join(featDir, `${n}.md`)) ?? '']));
|
|
4035
|
+
const coreLine = featureMap(repo, cfg).split('\n').find((l) => /^\s*[-*] /.test(l) && l.includes(MARK.core));
|
|
4036
|
+
return mapChange({ changed, tracked, journeys: cfg.journeys, skill: cfg.skill, features, coreFeature: coreLine?.match(/\]\(([^)#]+)\.md\)/)?.[1], noBase });
|
|
4037
|
+
}
|
|
3995
4038
|
// Which journeys the change needs (src/affected.ts) and whether HEAD's proof covers them. The change is read from `baseArg`,
|
|
3996
4039
|
// else from the newest receipt before HEAD (the commits since the last proof), else from the merge base with the default
|
|
3997
4040
|
// branch; with none, the mapping is uncertain and the core journey is needed.
|
|
@@ -4025,10 +4068,7 @@ function affectedOf(repo, cfg, baseArg) {
|
|
|
4025
4068
|
// Untracked files are no part of the change until added, as everywhere else in done-done.
|
|
4026
4069
|
const uncommitted = list('diff', '--name-only', '--no-renames', '-z', 'HEAD').filter(counts);
|
|
4027
4070
|
const changed = [...new Set([...(base ? list('diff', '--name-only', '--no-renames', '-z', base.commit, 'HEAD').filter(counts) : []), ...uncommitted])];
|
|
4028
|
-
const
|
|
4029
|
-
const features = Object.fromEntries(skillFeatures(repo, cfg).map((n) => [n, repoText(join(featDir, `${n}.md`)) ?? '']));
|
|
4030
|
-
const coreLine = featureMap(repo, cfg).split('\n').find((l) => /^\s*[-*] /.test(l) && l.includes(MARK.core));
|
|
4031
|
-
const m = mapChange({ changed, tracked, journeys: cfg.journeys, skill: cfg.skill, features, coreFeature: coreLine?.match(/\]\(([^)#]+)\.md\)/)?.[1], noBase: base ? undefined : 'no receipt before HEAD and no default branch to diff against' });
|
|
4071
|
+
const m = mapDiff(repo, cfg, changed, tracked, base ? undefined : 'no receipt before HEAD and no default branch to diff against');
|
|
4032
4072
|
const needed = [...m.journeys.map((j) => j.name), ...m.features.map((f) => `feature ${f.name}`)];
|
|
4033
4073
|
// Proof: a valid receipt for HEAD whose runs include every journey and feature the change needs, with nothing uncommitted.
|
|
4034
4074
|
const verified = verifyReceipt(repo, cfg, 'HEAD');
|
package/dist/config.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { basename, isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
4
|
import { parse } from 'yaml';
|
|
5
5
|
import { checkMatrix } from './matrix.js';
|
|
6
|
-
import { parseDom } from './dom.js';
|
|
6
|
+
import { parseDom, parseLook } from './dom.js';
|
|
7
7
|
export class UsageError extends Error {
|
|
8
8
|
help;
|
|
9
9
|
// The exact next command, when the error has one.
|
|
@@ -66,6 +66,7 @@ const KEYS = {
|
|
|
66
66
|
instance: { scope: 'repo', type: 'instance' },
|
|
67
67
|
matrix: { scope: 'repo', type: 'matrix' },
|
|
68
68
|
dom: { scope: 'repo', type: 'dom' },
|
|
69
|
+
look: { scope: 'repo', type: 'look' },
|
|
69
70
|
base_ref: { scope: 'repo', type: 'string' },
|
|
70
71
|
'evidence.root': { scope: 'repo', type: 'string' },
|
|
71
72
|
'evidence.allow_dirty': { scope: 'repo', type: 'boolean' },
|
|
@@ -219,6 +220,14 @@ function apply(target, text, file, scope) {
|
|
|
219
220
|
target.dom = d;
|
|
220
221
|
continue;
|
|
221
222
|
}
|
|
223
|
+
if (key === 'look') {
|
|
224
|
+
const l = parseLook(value, dirname(file));
|
|
225
|
+
if (typeof l === 'string')
|
|
226
|
+
throw new UsageError(`${file}: look ${l}`);
|
|
227
|
+
target.from[key] = scope;
|
|
228
|
+
target.look = l;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
222
231
|
if (!ok)
|
|
223
232
|
throw new UsageError(`${file}: ${key} must be ${want[spec.type] ?? `a ${spec.type}`}${key === 'matrix' ? `: ${checkMatrix(value)}` : ''}`);
|
|
224
233
|
if (key === 'agent_config')
|
|
@@ -279,6 +288,7 @@ function defaultConfig(repo) {
|
|
|
279
288
|
instance: null,
|
|
280
289
|
matrix: null,
|
|
281
290
|
dom: null,
|
|
291
|
+
look: null,
|
|
282
292
|
base_ref: '',
|
|
283
293
|
evidence: { local_root: join(home(), 'evidence'), root: '', allow_dirty: false, ignore_paths: [] },
|
|
284
294
|
secrets: { patterns: {}, allow: [] },
|
package/dist/dom.js
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
// "console_errors": ["<message>", ...], "failed_requests": [{"url": "<request url>", "status": <HTTP status, 0 when none>}],
|
|
6
6
|
// "squashed_text": [{"element": "<element>", "text": "<its text, ~40 chars>", "chars": <width>, "lines": <n>, "words": <n>}],
|
|
7
7
|
// "clipped_text": [{"element": "<element>", "text": "<its text>", "by": "<the container that cuts it>"}],
|
|
8
|
-
// "cut_off": [{"element": "<element>", "text": "<its text>", "edges": ["left" | "right" | "top", ...]}]
|
|
9
|
-
//
|
|
8
|
+
// "cut_off": [{"element": "<element>", "text": "<its text>", "edges": ["left" | "right" | "top", ...]}],
|
|
9
|
+
// "controls": [{"element": "<element>", "text": "<its label>", "native": <true when drawn as the browser's own control>, "height": <px, absent for a textarea or list box>}],
|
|
10
|
+
// "actions": [{"selector": "<what was clicked>", "text": "<its label>", "failed": "<why it did not complete, absent when it did>"}]}
|
|
11
|
+
// squashed_text, clipped_text, cut_off, controls and actions are optional, so a record written before them stays valid.
|
|
10
12
|
import { readFileSync } from 'node:fs';
|
|
11
|
-
import { basename } from 'node:path';
|
|
13
|
+
import { basename, resolve } from 'node:path';
|
|
12
14
|
// The measurement the recipe passes to its browser tool's eval, so no agent invents one: every visible element with its
|
|
13
15
|
// own non-empty text is squashed when its box is under 8 characters wide (box width / the average glyph width of its
|
|
14
16
|
// font) while its text is wider than the box, or when its text sits on more line boxes than it has words.
|
|
@@ -67,6 +69,39 @@ export const LOOK = `() => {
|
|
|
67
69
|
}
|
|
68
70
|
return { overflow: document.documentElement.scrollWidth > vw, overflowing: [...document.querySelectorAll('body *')].filter((e) => e.getBoundingClientRect().right > vw + 1).slice(0, 10).map(name), ...out };
|
|
69
71
|
}`.replace(/\s*\n\s*/g, ' ');
|
|
72
|
+
// Every visible control with its label and height, and whether it is drawn as the browser's own: its background and
|
|
73
|
+
// border are what the browser gives the same control (same tag, input type and colour scheme) on a page with no styles.
|
|
74
|
+
// A checkbox, radio, range, colour or file input is not measured, and a textarea or list box has no height to hold.
|
|
75
|
+
export const CONTROLS = `() => {
|
|
76
|
+
const name = (e) => e.tagName.toLowerCase() + (e.id ? '#' + e.id : e.classList[0] ? '.' + e.classList[0] : '');
|
|
77
|
+
const f = document.createElement('iframe'); f.style.cssText = 'position:fixed;width:0;height:0;border:0'; document.documentElement.append(f);
|
|
78
|
+
const d = f.contentDocument; d.open(); d.write('<!doctype html><body>'); d.close();
|
|
79
|
+
const keys = ['backgroundColor', 'borderTopStyle', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius']; const out = [];
|
|
80
|
+
for (const e of document.querySelectorAll('button, select, textarea, input, [role=button]')) {
|
|
81
|
+
const s = getComputedStyle(e); const r = e.getBoundingClientRect();
|
|
82
|
+
if (!e.getClientRects().length || s.visibility !== 'visible' || r.width < 1 || r.height < 1 || (e.tagName === 'INPUT' && ['checkbox', 'radio', 'range', 'color', 'file'].includes(e.type))) continue;
|
|
83
|
+
let native = false;
|
|
84
|
+
if (['BUTTON', 'SELECT', 'TEXTAREA', 'INPUT'].includes(e.tagName)) { const t = d.createElement(e.tagName); if (e.tagName === 'INPUT') t.type = e.type; t.style.colorScheme = s.colorScheme; d.body.append(t); const u = f.contentWindow.getComputedStyle(t); native = keys.every((k) => s[k] === u[k]); }
|
|
85
|
+
const tall = e.tagName === 'TEXTAREA' || (e.tagName === 'SELECT' && (e.multiple || e.size > 1));
|
|
86
|
+
out.push({ element: name(e), text: (e.getAttribute('aria-label') || e.innerText || e.value || e.placeholder || '').replace(/\\s+/g, ' ').trim().slice(0, 40), native, ...(!tall && { height: Math.round(r.height) }) });
|
|
87
|
+
}
|
|
88
|
+
f.remove(); return out;
|
|
89
|
+
}`.replace(/\s*\n\s*/g, ' ');
|
|
90
|
+
// What a user can read of the page: its address, how many elements it has and its visible text. An action completes when
|
|
91
|
+
// this changes: a card moves, a reply shows as saved, a toast says so.
|
|
92
|
+
const STATE = "(() => location.href + '\\n' + document.querySelectorAll('body *').length + '\\n' + document.body.innerText)()";
|
|
93
|
+
// Where to click the action `sel` names, with its label and the page state before it, or why it cannot be clicked.
|
|
94
|
+
export const AIM = `(sel) => {
|
|
95
|
+
const e = document.querySelector(sel); if (!e) return { text: '', failed: 'is not on the page' };
|
|
96
|
+
e.scrollIntoView({ block: 'center' }); const r = e.getBoundingClientRect(); const x = r.left + r.width / 2; const y = r.top + r.height / 2;
|
|
97
|
+
const text = (e.getAttribute('aria-label') || e.innerText || e.value || '').replace(/\\s+/g, ' ').trim().slice(0, 40);
|
|
98
|
+
if (e.disabled || e.getAttribute('aria-disabled') === 'true') return { text, failed: 'is disabled' };
|
|
99
|
+
const hit = document.elementFromPoint(x, y);
|
|
100
|
+
if (!hit || (hit !== e && !e.contains(hit))) return { text, failed: 'is covered by ' + (hit ? hit.tagName.toLowerCase() + (hit.id ? '#' + hit.id : hit.classList[0] ? '.' + hit.classList[0] : '') : 'nothing that takes a click') };
|
|
101
|
+
return { text, x, y, before: ${STATE} };
|
|
102
|
+
}`.replace(/\s*\n\s*/g, ' ');
|
|
103
|
+
// Whether the page state differs from `before` within `ms`, checked each 50 ms.
|
|
104
|
+
export const CHANGED = `async (before, ms) => { for (let t = 0; t <= ms; t += 50) { if (${STATE} !== before) return true; await new Promise((r) => setTimeout(r, 50)); } return false; }`;
|
|
70
105
|
const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
71
106
|
const strings = (a) => Array.isArray(a) && a.every((s) => typeof s === 'string');
|
|
72
107
|
// An optional record list: absent, or every item has an element and text and passes `more`.
|
|
@@ -100,8 +135,41 @@ export function parseDom(v) {
|
|
|
100
135
|
return 'fail_on_overflow must be true or false';
|
|
101
136
|
return { allow_console: v.allow_console ?? [], allow_requests: v.allow_requests ?? [], fail_on_overflow: v.fail_on_overflow ?? true };
|
|
102
137
|
}
|
|
138
|
+
// The look block read from config in `repo`, or the reason it is not one. max_control is a number of px, or the name of
|
|
139
|
+
// a custom property in the tokens file (--ctl-h: 2.75rem), read as px with a rem or em of 16 px.
|
|
140
|
+
export function parseLook(v, repo) {
|
|
141
|
+
if (v == null)
|
|
142
|
+
v = {};
|
|
143
|
+
if (!isMap(v))
|
|
144
|
+
return 'a mapping with optional tokens and max_control';
|
|
145
|
+
for (const k of Object.keys(v))
|
|
146
|
+
if (!['tokens', 'max_control'].includes(k))
|
|
147
|
+
return `unknown key ${k} (tokens, max_control)`;
|
|
148
|
+
const { tokens, max_control: max } = v;
|
|
149
|
+
if (tokens !== undefined && (typeof tokens !== 'string' || !tokens))
|
|
150
|
+
return 'tokens must be the path of the design tokens file';
|
|
151
|
+
if (typeof max === 'number')
|
|
152
|
+
return max > 0 ? { ...(tokens && { tokens }), max_control: max } : 'max_control must be more than 0 px';
|
|
153
|
+
if (max === undefined)
|
|
154
|
+
return tokens ? { tokens } : {};
|
|
155
|
+
if (typeof max !== 'string' || !/^--[\w-]+$/.test(max))
|
|
156
|
+
return 'max_control must be a number of px or the name of a token such as --ctl-h';
|
|
157
|
+
if (!tokens)
|
|
158
|
+
return `max_control ${max} names a token, so tokens must name the file that sets it`;
|
|
159
|
+
let text;
|
|
160
|
+
try {
|
|
161
|
+
text = readFileSync(resolve(repo, tokens), 'utf8');
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
return `tokens ${tokens} does not read: ${e.code ?? e.message}`;
|
|
165
|
+
}
|
|
166
|
+
const m = new RegExp(`(?<![\\w-])${max}\\s*:\\s*([\\d.]+)(px|rem|em)\\s*[;}]`).exec(text);
|
|
167
|
+
if (!m)
|
|
168
|
+
return `max_control ${max} is not set in ${tokens} as a px, rem or em size`;
|
|
169
|
+
return { tokens, max_control: Number(m[1]) * (m[2] === 'px' ? 1 : 16) };
|
|
170
|
+
}
|
|
103
171
|
// Every non-allowed issue the capture's page-check record shows; a missing or malformed record is an issue too.
|
|
104
|
-
export function domIssues(dom, capture) {
|
|
172
|
+
export function domIssues(dom, capture, ref) {
|
|
105
173
|
const path = domRecord(capture);
|
|
106
174
|
let r;
|
|
107
175
|
try {
|
|
@@ -114,22 +182,28 @@ export function domIssues(dom, capture) {
|
|
|
114
182
|
if (!isMap(r) || typeof r.url !== 'string' || typeof r.overflow !== 'boolean' || !strings(r.overflowing ?? []) || !strings(r.console_errors)
|
|
115
183
|
|| !Array.isArray(r.failed_requests) || !r.failed_requests.every((q) => isMap(q) && typeof q.url === 'string' && typeof q.status === 'number')
|
|
116
184
|
|| !list(r.squashed_text, (q) => [q.chars, q.lines, q.words].every((n) => typeof n === 'number'))
|
|
117
|
-
|| !list(r.clipped_text, (q) => typeof q.by === 'string') || !list(r.cut_off, (q) => strings(q.edges))
|
|
118
|
-
|
|
185
|
+
|| !list(r.clipped_text, (q) => typeof q.by === 'string') || !list(r.cut_off, (q) => strings(q.edges))
|
|
186
|
+
|| !list(r.controls, (q) => typeof q.native === 'boolean' && ['number', 'undefined'].includes(typeof q.height))
|
|
187
|
+
|| !(r.actions === undefined || (Array.isArray(r.actions) && r.actions.every((q) => isMap(q) && typeof q.selector === 'string' && typeof q.text === 'string' && ['string', 'undefined'].includes(typeof q.failed)))))
|
|
188
|
+
return [`page-check record ${basename(path)} is not {url, overflow, overflowing, console_errors, failed_requests: [{url, status}], squashed_text: [{element, text, chars, lines, words}], clipped_text: [{element, text, by}], cut_off: [{element, text, edges}], controls: [{element, text, native, height}], actions: [{selector, text, failed}]}`];
|
|
119
189
|
const allowed = (list, s) => list.some((re) => new RegExp(re).test(s));
|
|
120
190
|
return [
|
|
121
|
-
...lookIssues({ ...r, overflow: dom.fail_on_overflow && r.overflow }),
|
|
191
|
+
...lookIssues({ ...r, overflow: dom.fail_on_overflow && r.overflow }, ref),
|
|
122
192
|
...r.console_errors.filter((m) => !allowed(dom.allow_console, m)).map((m) => `${r.url} console error: ${m}`),
|
|
123
193
|
...r.failed_requests.filter((q) => (q.status === 0 || q.status >= 400) && !allowed(dom.allow_requests, q.url)).map((q) => `${r.url} request failed (${q.status || 'no response'}): ${q.url}`),
|
|
124
194
|
];
|
|
125
195
|
}
|
|
126
|
-
// The look defects a page-check record shows: horizontal overflow, squashed, clipped and cut-off text or elements
|
|
127
|
-
|
|
196
|
+
// The look defects a page-check record shows: horizontal overflow, squashed, clipped and cut-off text or elements, and an
|
|
197
|
+
// action that did not complete; against a reference, also a control drawn as the browser's own or taller than max_control.
|
|
198
|
+
export function lookIssues(r, ref) {
|
|
128
199
|
const quoted = (t) => (t ? ` "${t}"` : '');
|
|
129
200
|
return [
|
|
130
201
|
...(r.overflow ? [`${r.url} overflows the viewport horizontally${r.overflowing?.length ? ` (${r.overflowing.join(', ')})` : ''}`] : []),
|
|
131
202
|
...(r.squashed_text ?? []).map((q) => `${r.url} squashed text: ${q.element} "${q.text}" is ${q.chars} characters wide (lines ${q.lines}, words ${q.words})`),
|
|
132
203
|
...(r.clipped_text ?? []).map((q) => `${r.url} clipped text: ${q.element}${quoted(q.text)} is cut by ${q.by}`),
|
|
133
204
|
...(r.cut_off ?? []).map((q) => `${r.url} cut off: ${q.element}${quoted(q.text)} runs past the ${q.edges.join(' and ')} edge of the viewport`),
|
|
205
|
+
...(ref ? r.controls ?? [] : []).filter((q) => q.native).map((q) => `${r.url} unstyled control: ${q.element}${quoted(q.text)} is drawn as the browser's own control, not with the design's style`),
|
|
206
|
+
...(ref?.max_control ? r.controls ?? [] : []).filter((q) => q.height > ref.max_control + 0.5).map((q) => `${r.url} oversized control: ${q.element}${quoted(q.text)} is ${q.height} px tall, over the ${ref.max_control} px the design allows`),
|
|
207
|
+
...(r.actions ?? []).filter((q) => q.failed).map((q) => `${r.url} action did not complete: ${q.selector}${quoted(q.text)} ${q.failed}`),
|
|
134
208
|
];
|
|
135
209
|
}
|
package/dist/lint.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// `done-done lint`: the deterministic merge gate on a worker's claim. It reads the newest "## Checks" block of a PR body
|
|
2
2
|
// or report file and fails an unproven, reused or oversized claim. No judge, no model; the repo config only switches on
|
|
3
|
-
// the two optional size rules (checks.upstream_catch_up, checks.moved_from) and extra generated paths (checks.generated_paths)
|
|
3
|
+
// the two optional size rules (checks.upstream_catch_up, checks.moved_from) and extra generated paths (checks.generated_paths);
|
|
4
|
+
// files the target repo marks generated in .gitattributes are not counted either.
|
|
4
5
|
import { spawnSync } from 'node:child_process';
|
|
5
6
|
import { createHash } from 'node:crypto';
|
|
6
7
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
@@ -8,6 +9,26 @@ import { basename, dirname, isAbsolute, join, matchesGlob, resolve } from 'node:
|
|
|
8
9
|
// Third-party, legal text and committed build output are not reviewable code.
|
|
9
10
|
const GENERATED = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|go\.sum|[^/]*\.lock)$|(^|\/)vendor\/|(^|\/)dist\/|(^|\/)(LICENSE|LICENSES|NOTICE|COPYING)[^/]*$/;
|
|
10
11
|
const generatedBy = (globs = []) => (p) => GENERATED.test(p) || globs.some((g) => matchesGlob(p, g));
|
|
12
|
+
// A target repo's own .gitattributes marks generated files: linguist-generated, linguist-generated=true or -diff. The last matching line wins;
|
|
13
|
+
// linguist-generated=false and an unset attribute mean not generated. A pattern with no slash matches the basename, like git.
|
|
14
|
+
function attrGenerated(attrs) {
|
|
15
|
+
const rules = attrs.split('\n').map((l) => l.trim().split(/\s+/)).filter((w) => w[0] && !w[0].startsWith('#') && w.length > 1);
|
|
16
|
+
return (p) => {
|
|
17
|
+
let gen = false;
|
|
18
|
+
for (const [pat, ...as] of rules) {
|
|
19
|
+
const glob = pat.replace(/^\//, '');
|
|
20
|
+
if (!matchesGlob(glob.includes('/') ? p : basename(p), glob.endsWith('/') ? `${glob}**` : glob))
|
|
21
|
+
continue;
|
|
22
|
+
for (const a of as) {
|
|
23
|
+
if (/^(linguist-generated(=true)?|-diff)$/.test(a))
|
|
24
|
+
gen = true;
|
|
25
|
+
else if (/^(linguist-generated=false|-linguist-generated|!linguist-generated)$/.test(a))
|
|
26
|
+
gen = false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return gen;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
11
32
|
export const SIZE_LIMIT = 1000;
|
|
12
33
|
const FIX_TITLE = /^(fix|bugfix|hotfix)\b/i;
|
|
13
34
|
const NOT_APPLICABLE = /^n\/?a\b/i;
|
|
@@ -70,7 +91,8 @@ function movedLines(run, nwo, body, paths, isGenerated) {
|
|
|
70
91
|
const sha256 = (p) => createHash('sha256').update(readFileSync(p)).digest('hex');
|
|
71
92
|
// src: a PR URL or a report file; ev: the folder the evidence names are relative to; dir: where the hash store and log live.
|
|
72
93
|
export function lint(src, ev, dir, rules = {}, run = gh) {
|
|
73
|
-
const
|
|
94
|
+
const byConfig = generatedBy(rules.generated_paths);
|
|
95
|
+
let isGenerated = byConfig;
|
|
74
96
|
let warn = ''; // set for a PR that touches src/ with no journeys/ file; it only softens a PASS
|
|
75
97
|
const log = (v) => {
|
|
76
98
|
if (v.ok && warn)
|
|
@@ -95,6 +117,12 @@ export function lint(src, ev, dir, rules = {}, run = gh) {
|
|
|
95
117
|
const author = pr.author?.login ?? '';
|
|
96
118
|
if (/^(app\/)?(dependabot|renovate)(\[bot\])?$/.test(author))
|
|
97
119
|
return log({ ok: true, why: 'bot', head });
|
|
120
|
+
const nwo = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\//.exec(src)?.[1];
|
|
121
|
+
const attrs = nwo && [head, pr.baseRefOid].map((ref) => ref && run('api', '-H', 'Accept: application/vnd.github.raw', `repos/${nwo}/contents/.gitattributes?ref=${ref}`)).find((a) => a && a.status === 0);
|
|
122
|
+
if (attrs) {
|
|
123
|
+
const byAttr = attrGenerated(attrs.stdout);
|
|
124
|
+
isGenerated = (p) => byConfig(p) || byAttr(p);
|
|
125
|
+
}
|
|
98
126
|
const paths = (pr.files ?? []).map((f) => f.path ?? '');
|
|
99
127
|
if (paths.some((p) => p.startsWith('src/')) && !paths.some((p) => p.startsWith('journeys/')))
|
|
100
128
|
warn = 'the diff changes src/ and no journeys/ file: run the journey, commit it, then open the PR';
|
|
@@ -103,7 +131,6 @@ export function lint(src, ev, dir, rules = {}, run = gh) {
|
|
|
103
131
|
const d = run('pr', 'diff', src);
|
|
104
132
|
if (!d.status) {
|
|
105
133
|
let net = netLines(d.stdout, isGenerated);
|
|
106
|
-
const nwo = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\//.exec(src)?.[1];
|
|
107
134
|
if (nwo && rules.upstream_catch_up && total(net) >= SIZE_LIMIT)
|
|
108
135
|
net = without(net, upstreamDelta(run, nwo, head, pr.baseRefOid ?? '', isGenerated));
|
|
109
136
|
if (nwo && rules.moved_from && total(net) >= SIZE_LIMIT)
|
package/dist/look.js
CHANGED
|
@@ -3,14 +3,17 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { UsageError } from './config.js';
|
|
6
|
-
import { domRecord, LOOK, SQUASHED_TEXT } from './dom.js';
|
|
6
|
+
import { AIM, CHANGED, CONTROLS, domRecord, LOOK, SQUASHED_TEXT } from './dom.js';
|
|
7
7
|
import { findBin } from './agents.js';
|
|
8
8
|
// done-done look: opens one page in headless Chrome at a phone viewport over the DevTools protocol (Node's own
|
|
9
|
-
// WebSocket, no browser library),
|
|
9
|
+
// WebSocket, no browser library), clicks each action in `acts` in order (a CSS selector each) and waits for it to complete,
|
|
10
|
+
// then writes the screenshot and its page-check record next to it, and returns the record.
|
|
10
11
|
// Chrome runs without its sandbox so it starts in containers: point look at your own app, not untrusted sites.
|
|
11
12
|
const BROWSERS = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'];
|
|
12
13
|
const WAIT = 30_000;
|
|
13
|
-
|
|
14
|
+
// How long an action has to change what a user can read of the page.
|
|
15
|
+
const ACT = 5_000;
|
|
16
|
+
export async function look(url, capture, width, height, acts = []) {
|
|
14
17
|
const bin = process.env.DONE_DONE_CHROME ? findBin(process.env.DONE_DONE_CHROME) : BROWSERS.map(findBin).find(Boolean);
|
|
15
18
|
if (!bin)
|
|
16
19
|
throw new UsageError(`look needs Chrome or Chromium: ${process.env.DONE_DONE_CHROME ? `DONE_DONE_CHROME=${process.env.DONE_DONE_CHROME} is not an executable` : `none of ${BROWSERS.slice(0, 4).join(', ')} is on PATH`}`, 'Install Chrome or Chromium, or set DONE_DONE_CHROME to its binary');
|
|
@@ -93,8 +96,23 @@ export async function look(url, capture, width, height) {
|
|
|
93
96
|
throw new Error(`${url} did not load: ${nav.errorText}`);
|
|
94
97
|
await loaded;
|
|
95
98
|
// Web fonts change text widths, so measure once they are in and two frames have painted.
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
const settle = () => evaluate('document.fonts.ready.then(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))))');
|
|
100
|
+
await settle();
|
|
101
|
+
// Each action is a real click at its centre, so a control under a toast or overlay fails as covered.
|
|
102
|
+
const actions = [];
|
|
103
|
+
for (const selector of acts) {
|
|
104
|
+
const at = await evaluate(`(${AIM})(${JSON.stringify(selector)})`);
|
|
105
|
+
if (!at.failed) {
|
|
106
|
+
for (const type of ['mousePressed', 'mouseReleased'])
|
|
107
|
+
await send('Input.dispatchMouseEvent', { type, x: at.x, y: at.y, button: 'left', clickCount: 1 });
|
|
108
|
+
if (!(await evaluate(`(${CHANGED})(${JSON.stringify(at.before)}, ${ACT})`)))
|
|
109
|
+
at.failed = `changed nothing a user can read on the page (its text, elements or address) within ${ACT / 1000}s`;
|
|
110
|
+
}
|
|
111
|
+
actions.push({ selector, text: at.text, ...(at.failed && { failed: at.failed }) });
|
|
112
|
+
}
|
|
113
|
+
if (acts.length)
|
|
114
|
+
await evaluate('scrollTo(0, 0)').then(settle);
|
|
115
|
+
const record = { url, ...(await evaluate(`(${LOOK})()`)), console_errors: consoleErrors, failed_requests: failed, squashed_text: await evaluate(`(${SQUASHED_TEXT})()`), controls: await evaluate(`(${CONTROLS})()`), ...(acts.length && { actions }) };
|
|
98
116
|
const shot = await send('Page.captureScreenshot', { format: 'png' });
|
|
99
117
|
writeFileSync(capture, Buffer.from(shot.data, 'base64'));
|
|
100
118
|
writeFileSync(domRecord(capture), `${JSON.stringify(record, null, 2)}\n`);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
------------------------------ MODULE PaneAttach ------------------------------
|
|
2
|
+
(* Two Herdr panes, one shared tree refresh and one attach, as muxr v0.2.9 runs *)
|
|
3
|
+
(* them on @byokit/herdr 0.16.1. Pane A closes; pane B survives and is the pane *)
|
|
4
|
+
(* the user opens. Each action names the code it stands for; see README.md. *)
|
|
5
|
+
EXTENDS FiniteSets
|
|
6
|
+
|
|
7
|
+
CONSTANTS
|
|
8
|
+
Matched, \* reply-id shapes socket.js counts as a refusal of its own watch
|
|
9
|
+
DrainsQueue \* runBootstrap starts the queued follow-up (FALSE: broken variant)
|
|
10
|
+
|
|
11
|
+
Panes == {"A", "B"}
|
|
12
|
+
Callers == {"R", "U"} \* R: any other refresh caller; U: the user's attach to B
|
|
13
|
+
\* Herdr answers a watch on a closed pane with id "<id>:sub:0:probe" (muxr PR 1006).
|
|
14
|
+
RefusalShape == "subProbe"
|
|
15
|
+
|
|
16
|
+
VARIABLES
|
|
17
|
+
alive, \* Herdr: the pane exists
|
|
18
|
+
closedEvt, \* A's pane_closed event is on its way to the kit's event socket
|
|
19
|
+
booting, \* kit.js `booting`
|
|
20
|
+
queued, \* kit.js `queued`
|
|
21
|
+
phase, \* runBootstrap: "idle", "reading" (snapshot in flight), "acks"
|
|
22
|
+
waiters, \* kit.js `waiters`
|
|
23
|
+
nextWaiters, \* kit.js `nextWaiters`
|
|
24
|
+
watch, \* per-pane status watch socket
|
|
25
|
+
ready, \* that watch's `ready` promise
|
|
26
|
+
refusal, \* Herdr has refused this pane's watch (the pane is gone for good)
|
|
27
|
+
caller \* each refresh() caller
|
|
28
|
+
|
|
29
|
+
vars == <<alive, closedEvt, booting, queued, phase, waiters, nextWaiters,
|
|
30
|
+
watch, ready, refusal, caller>>
|
|
31
|
+
|
|
32
|
+
TypeOK ==
|
|
33
|
+
/\ alive \in [Panes -> BOOLEAN]
|
|
34
|
+
/\ closedEvt \in BOOLEAN /\ booting \in BOOLEAN /\ queued \in BOOLEAN
|
|
35
|
+
/\ phase \in {"idle", "reading", "acks"}
|
|
36
|
+
/\ waiters \subseteq Callers /\ nextWaiters \subseteq Callers
|
|
37
|
+
/\ watch \in [Panes -> {"none", "opening", "retry", "open", "stopped"}]
|
|
38
|
+
/\ ready \in [Panes -> {"none", "pending", "ok", "refused"}]
|
|
39
|
+
/\ refusal \in [Panes -> BOOLEAN]
|
|
40
|
+
/\ caller \in [Callers -> {"idle", "waiting", "done"}]
|
|
41
|
+
|
|
42
|
+
\* The kit has started: its first bootstrap landed and both watches acked.
|
|
43
|
+
Init ==
|
|
44
|
+
/\ alive = [p \in Panes |-> TRUE]
|
|
45
|
+
/\ closedEvt = FALSE /\ booting = FALSE /\ queued = FALSE /\ phase = "idle"
|
|
46
|
+
/\ waiters = {} /\ nextWaiters = {}
|
|
47
|
+
/\ watch = [p \in Panes |-> "open"]
|
|
48
|
+
/\ ready = [p \in Panes |-> "ok"]
|
|
49
|
+
/\ refusal = [p \in Panes |-> FALSE]
|
|
50
|
+
/\ caller = [c \in Callers |-> "idle"]
|
|
51
|
+
|
|
52
|
+
\* runBootstrap -> bootstrap sends `session.snapshot` (kit.js:523-530, 363-371).
|
|
53
|
+
StartBoot == booting' = TRUE /\ phase' = "reading"
|
|
54
|
+
|
|
55
|
+
\* kit.refresh() (kit.js:694-706). U reaches it through open -> resolvePane ->
|
|
56
|
+
\* refreshSnapshot -> client.kit.refresh() (herdrSessionSource.ts:2606, 1878-1897, 1578-1583).
|
|
57
|
+
Refresh(c) ==
|
|
58
|
+
/\ caller[c] = "idle"
|
|
59
|
+
/\ caller' = [caller EXCEPT ![c] = "waiting"]
|
|
60
|
+
/\ IF booting
|
|
61
|
+
THEN /\ queued' = TRUE /\ nextWaiters' = nextWaiters \cup {c}
|
|
62
|
+
/\ UNCHANGED <<waiters, booting, phase>>
|
|
63
|
+
ELSE /\ waiters' = waiters \cup {c} /\ StartBoot
|
|
64
|
+
/\ UNCHANGED <<queued, nextWaiters>>
|
|
65
|
+
/\ UNCHANGED <<alive, closedEvt, watch, ready, refusal>>
|
|
66
|
+
|
|
67
|
+
\* Pane A closes. Herdr closes an acked watch's socket, which re-dials after 2 s
|
|
68
|
+
\* (socket.js:115-121); a watch still opening is answered by HerdrAnswer.
|
|
69
|
+
CloseA ==
|
|
70
|
+
/\ alive["A"]
|
|
71
|
+
/\ alive' = [alive EXCEPT !["A"] = FALSE]
|
|
72
|
+
/\ closedEvt' = TRUE
|
|
73
|
+
/\ watch' = IF watch["A"] = "open" THEN [watch EXCEPT !["A"] = "retry"] ELSE watch
|
|
74
|
+
/\ UNCHANGED <<booting, queued, phase, waiters, nextWaiters, ready, refusal, caller>>
|
|
75
|
+
|
|
76
|
+
\* A structural event queues a bootstrap, or a follow-up while one runs (kit.js:582-597, 562-568).
|
|
77
|
+
ClosedEventLands ==
|
|
78
|
+
/\ closedEvt
|
|
79
|
+
/\ closedEvt' = FALSE
|
|
80
|
+
/\ IF booting
|
|
81
|
+
THEN queued' = TRUE /\ UNCHANGED <<booting, phase>>
|
|
82
|
+
ELSE StartBoot /\ UNCHANGED queued
|
|
83
|
+
/\ UNCHANGED <<alive, waiters, nextWaiters, watch, ready, refusal, caller>>
|
|
84
|
+
|
|
85
|
+
\* The snapshot lands: stop the old status watches, open one per agent pane in it
|
|
86
|
+
\* (kit.js:409-414, 440-447; openStatusWatch kit.js:496-514).
|
|
87
|
+
SnapshotLands ==
|
|
88
|
+
/\ phase = "reading"
|
|
89
|
+
/\ phase' = "acks"
|
|
90
|
+
/\ watch' = [p \in Panes |-> IF alive[p] THEN "opening"
|
|
91
|
+
ELSE IF watch[p] = "none" THEN "none" ELSE "stopped"]
|
|
92
|
+
/\ ready' = [p \in Panes |-> IF alive[p] THEN "pending" ELSE "none"]
|
|
93
|
+
/\ UNCHANGED <<alive, closedEvt, booting, queued, waiters, nextWaiters, refusal, caller>>
|
|
94
|
+
|
|
95
|
+
\* Herdr answers one `events.subscribe` (socket.js:94-112). A live pane acks. A closed
|
|
96
|
+
\* pane is refused, then Herdr closes the socket: a matched refusal settles `ready` false
|
|
97
|
+
\* and stops; an unmatched one is ignored and the close handler re-dials (socket.js:115-121).
|
|
98
|
+
HerdrAnswer(p) ==
|
|
99
|
+
/\ watch[p] = "opening"
|
|
100
|
+
/\ IF alive[p]
|
|
101
|
+
THEN /\ watch' = [watch EXCEPT ![p] = "open"]
|
|
102
|
+
/\ ready' = IF ready[p] = "pending" THEN [ready EXCEPT ![p] = "ok"] ELSE ready
|
|
103
|
+
/\ UNCHANGED refusal
|
|
104
|
+
ELSE /\ refusal' = [refusal EXCEPT ![p] = TRUE]
|
|
105
|
+
/\ IF RefusalShape \in Matched
|
|
106
|
+
THEN /\ watch' = [watch EXCEPT ![p] = "stopped"]
|
|
107
|
+
/\ ready' = IF ready[p] = "pending" THEN [ready EXCEPT ![p] = "refused"] ELSE ready
|
|
108
|
+
ELSE /\ watch' = [watch EXCEPT ![p] = "retry"] /\ UNCHANGED ready
|
|
109
|
+
/\ UNCHANGED <<alive, closedEvt, booting, queued, phase, waiters, nextWaiters, caller>>
|
|
110
|
+
|
|
111
|
+
\* later(open, backoff) dials a fresh watch with a new id (socket.js:88-92, 120-124).
|
|
112
|
+
Retry(p) ==
|
|
113
|
+
/\ watch[p] = "retry"
|
|
114
|
+
/\ watch' = [watch EXCEPT ![p] = "opening"]
|
|
115
|
+
/\ UNCHANGED <<alive, closedEvt, booting, queued, phase, waiters, nextWaiters, ready, refusal, caller>>
|
|
116
|
+
|
|
117
|
+
\* Every watch this bootstrap opened has settled (Promise.allSettled, kit.js:446-447), so
|
|
118
|
+
\* runBootstrap's statusWatchReady() returns: answer the waiters and start the queued
|
|
119
|
+
\* follow-up for nextWaiters (kit.js:531-557). An answered attach finds B in the tree.
|
|
120
|
+
AcksSettle ==
|
|
121
|
+
/\ phase = "acks"
|
|
122
|
+
/\ \A p \in Panes : ready[p] # "pending"
|
|
123
|
+
/\ caller' = [c \in Callers |-> IF c \in waiters THEN "done" ELSE caller[c]]
|
|
124
|
+
/\ IF queued /\ DrainsQueue
|
|
125
|
+
THEN /\ queued' = FALSE /\ waiters' = nextWaiters /\ nextWaiters' = {}
|
|
126
|
+
/\ StartBoot
|
|
127
|
+
ELSE /\ waiters' = {} /\ booting' = FALSE /\ phase' = "idle"
|
|
128
|
+
/\ UNCHANGED <<queued, nextWaiters>>
|
|
129
|
+
/\ UNCHANGED <<alive, closedEvt, watch, ready, refusal>>
|
|
130
|
+
|
|
131
|
+
KitStep == ClosedEventLands \/ SnapshotLands \/ AcksSettle
|
|
132
|
+
\/ \E p \in Panes : HerdrAnswer(p) \/ Retry(p)
|
|
133
|
+
|
|
134
|
+
Next == KitStep \/ CloseA \/ \E c \in Callers : Refresh(c)
|
|
135
|
+
|
|
136
|
+
\* Herdr answers every request and the kit's timers fire: weak fairness on the kit and
|
|
137
|
+
\* Herdr's replies. Pane closes and user actions get none; they may never happen.
|
|
138
|
+
Spec == Init /\ [][Next]_vars
|
|
139
|
+
/\ WF_vars(ClosedEventLands) /\ WF_vars(SnapshotLands) /\ WF_vars(AcksSettle)
|
|
140
|
+
/\ \A p \in Panes : WF_vars(HerdrAnswer(p)) /\ WF_vars(Retry(p))
|
|
141
|
+
|
|
142
|
+
\* Property 1: after a final refusal, its watch settles (stops for good).
|
|
143
|
+
WatchSettles == \A p \in Panes : refusal[p] ~> (watch[p] = "stopped")
|
|
144
|
+
|
|
145
|
+
\* Property 2: an attach to the surviving pane B eventually answers. This model has no
|
|
146
|
+
\* failure path (no outage, no snapshot error), so an answer here is a success.
|
|
147
|
+
AttachAnswers == (caller["U"] = "waiting") ~> (caller["U"] = "done")
|
|
148
|
+
|
|
149
|
+
\* Witness: violated exactly when some run opens B, so the properties are not vacuous.
|
|
150
|
+
NeverAttached == caller["U"] # "done"
|
|
151
|
+
=============================================================================
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Pane-attach race: a statechart pilot
|
|
2
|
+
|
|
3
|
+
A small statechart and an executable model of one real bug: in muxr v0.2.9, a pane that closes before its status watch opens leaves the shared Herdr tree read waiting forever, so opening a pane that is still alive hangs. A pinned model checker finds that path on the old code, and the path is written below as the steps a user takes.
|
|
4
|
+
|
|
5
|
+
**This is supporting evidence only. It is not proof that muxr works or fails.** Real-product replay: **not checked**. No Herdr lifecycle command was run and muxr was not touched. The journey entry that a real replay would follow is [pane-attach-race.md](pane-attach-race.md). It is written in the format of muxr's project skill and is waiting for an isolated Herdr lab.
|
|
6
|
+
|
|
7
|
+
Files: [PaneAttach.tla](PaneAttach.tla) (model and properties), [check.mjs](check.mjs) (repeatable check), [pane-attach-race.md](pane-attach-race.md) (project-skill journey entry).
|
|
8
|
+
|
|
9
|
+
## The code it models
|
|
10
|
+
|
|
11
|
+
| Model action | Real code (muxr tag `v0.2.9`, `@byokit/herdr` 0.16.1 as published on npm) |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `Refresh(U)`: the user opens pane B | `open` → `resolvePane` → `refreshSnapshot` → `client.kit.refresh()` (`apps/host/src/agent/infrastructure/herdrSessionSource.ts:2606, 1878-1897, 1578-1583`) |
|
|
14
|
+
| `Refresh(c)` joins a running read or starts one | `HerdrKit.refresh()` (`dist/kit.js:694-706`) |
|
|
15
|
+
| `StartBoot` / `SnapshotLands` | `runBootstrap` → `bootstrap` sends `session.snapshot`, stops the old status watches and opens one per agent pane (`dist/kit.js:523-530, 363-371, 409-414, 440-447`; `openStatusWatch` `dist/kit.js:496-514`) |
|
|
16
|
+
| `CloseA`, then `ClosedEventLands` | a structural event queues a bootstrap, or a follow-up while one runs (`dist/kit.js:582-597, 562-568`); Herdr closing an acked watch's socket re-dials it (`dist/socket.js:115-121`) |
|
|
17
|
+
| `HerdrAnswer(p)` | the watch's frame handler. Only an error whose id is `''` or exactly the watch's id counts as a refusal (`dist/socket.js:94-112`). Herdr refuses a watch on a closed pane with id `<id>:sub:0:probe` (the record in muxr PR 1006) |
|
|
18
|
+
| `Retry(p)` | `later(open, backoff)` dials a new watch with a new id (`dist/socket.js:88-92, 120-124`) |
|
|
19
|
+
| `AcksSettle` | `Promise.allSettled` over the watches' `ready` promises; `runBootstrap` then answers its waiters and starts the queued follow-up (`dist/kit.js:446-447, 531-557`) |
|
|
20
|
+
|
|
21
|
+
Source identity: muxr `v0.2.9` is commit `3b8321f897f984ee1bf186177bf89ebfd812de2d`, whose `apps/host/package.json` pins `@byokit/herdr` `0.16.1`. The kit tarball `herdr-0.16.1.tgz` has sha256 `407fc6fce982fd61e5f1a77f60b0516c51ef6bf6963998f8ac80330d6bc2f2c9` (gitHead `39be7f88b7b13b7a9f32fbc1a1d74567016198b0`). Its `dist/socket.js` has sha256 `525d439da77312880dc170f9bf8f186bb0a295fb3d7210372ab19eda44700a90` and its `dist/kit.js` has sha256 `e4e6eb848e3043e97f4c95c485390a963879df67feb7f57a3f188828960f8597`.
|
|
22
|
+
|
|
23
|
+
## Statechart
|
|
24
|
+
|
|
25
|
+
One tree read in the kit:
|
|
26
|
+
|
|
27
|
+
```mermaid
|
|
28
|
+
stateDiagram-v2
|
|
29
|
+
[*] --> Idle
|
|
30
|
+
Idle --> Reading: refresh() or a pane event
|
|
31
|
+
Reading --> Reading: refresh() while reading, queued as a follow-up
|
|
32
|
+
Reading --> AwaitingWatches: snapshot lands, one watch opened per agent pane
|
|
33
|
+
AwaitingWatches --> Idle: every watch settled, waiters answered
|
|
34
|
+
AwaitingWatches --> Reading: every watch settled, queued follow-up starts
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
One pane's status watch:
|
|
38
|
+
|
|
39
|
+
```mermaid
|
|
40
|
+
stateDiagram-v2
|
|
41
|
+
[*] --> Opening
|
|
42
|
+
Opening --> Open: Herdr accepts (pane alive), ready = true
|
|
43
|
+
Opening --> Stopped: refusal with an empty id or the watch's own id, ready = false
|
|
44
|
+
Opening --> Redial: refusal with id ...sub:0:probe, ignored in 0.16.1
|
|
45
|
+
Open --> Redial: Herdr closes the socket (pane closed)
|
|
46
|
+
Redial --> Opening: re-dial with a new id
|
|
47
|
+
Open --> Stopped: the next snapshot stops old watches
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The bad loop is `Opening → Redial → Opening`, entered while the tree read waits in `AwaitingWatches`. The watch's `ready` never settles, so the tree read never leaves `AwaitingWatches`. Every `refresh()` waits behind it, and so does every pane open.
|
|
51
|
+
|
|
52
|
+
## Properties
|
|
53
|
+
|
|
54
|
+
1. **After a final refusal, its watch settles** (`WatchSettles`): once Herdr refuses a pane's watch, which happens only after the pane has closed for good, that watch eventually stops.
|
|
55
|
+
2. **Opening a surviving pane eventually answers** (`AttachAnswers`): once the user opens pane B, the open eventually finishes. This model has no outage or snapshot error, so the only answer is success. Explicit failure paths are outside its scope.
|
|
56
|
+
|
|
57
|
+
`NeverAttached` is a witness. It is violated exactly when some run opens B, which shows the properties are not passing trivially.
|
|
58
|
+
|
|
59
|
+
## Results
|
|
60
|
+
|
|
61
|
+
Run on 22 October 2026 with `node docs/examples/statechart-pilot/check.mjs <tla2tools.jar> <out-dir>`, which exited 0:
|
|
62
|
+
|
|
63
|
+
| Run | Kit behaviour | Checks | Expected | Got | Distinct states |
|
|
64
|
+
|---|---|---|---|---|---|
|
|
65
|
+
| `old-watch` | 0.16.1 matcher (`''` or `<id>`) | `WatchSettles` | violated | violated | 460 |
|
|
66
|
+
| `old-attach` | 0.16.1 matcher | `AttachAnswers` | violated | violated | 460 |
|
|
67
|
+
| `old-witness` | 0.16.1 matcher | `NeverAttached` | violated (B can open) | violated | 97 |
|
|
68
|
+
| `fixed` | also matches `<id>:` ids (the kit fix named in PR 1006) | both | hold | hold | 399 |
|
|
69
|
+
| `fixed-witness` | corrected matcher | `NeverAttached` | violated (B can open) | violated | 93 |
|
|
70
|
+
| `broken-attach` | corrected matcher, but `runBootstrap` never starts the queued follow-up | `AttachAnswers` | violated | violated | 435 |
|
|
71
|
+
| `broken-watch` | the same broken variant | `WatchSettles` | holds | holds | 435 |
|
|
72
|
+
|
|
73
|
+
The broken variant is caught by the attach property, which was not written for it. The watch property still holds for that variant, so the two properties check different things.
|
|
74
|
+
|
|
75
|
+
### The counterexample, as user steps (`old-attach`)
|
|
76
|
+
|
|
77
|
+
1. Umer opens agent B on the phone. The host asks the kit to re-read the Herdr tree.
|
|
78
|
+
2. Something else re-reads the tree at the same moment, such as another open. It queues behind the first read.
|
|
79
|
+
3. The tree comes back listing agents A and B. The kit opens a status watch for each and waits for both.
|
|
80
|
+
4. Agent A's pane closes before Herdr has accepted A's watch.
|
|
81
|
+
5. Herdr accepts B's watch.
|
|
82
|
+
6. Herdr refuses A's watch with id `<id>:sub:0:probe` and closes the socket. The kit does not see that id as a refusal, logs nothing, and dials A's watch again.
|
|
83
|
+
7. Each new dial gets the same refusal, forever.
|
|
84
|
+
|
|
85
|
+
What Umer sees: B stays on "connecting" even though B is alive. Every later open queues behind the same tree read. Only stopping the kit (a host restart) rejects the waiting opens. `old-watch` reaches the same loop one read later: the first tree read lands, and the queued follow-up read is the one that hangs.
|
|
86
|
+
|
|
87
|
+
## Model report
|
|
88
|
+
|
|
89
|
+
- Checker: TLC2 Version 2.19 of 08 August 2024 (rev: 5a47802), from [tla2tools.jar v1.7.4](https://github.com/tlaplus/tlaplus/releases/tag/v1.7.4). Its sha256 is `936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88` and its sha1 `bee4a54f3ee3d4afc347c3240ec2d9e93b075104` matches the release notes. `check.mjs` refuses any other jar. It ran on OpenJDK 21.0.2 with `-workers 1 -deadlock`: v1.7.4's liveness check is only sound with one worker (tlaplus issue 971), and finished runs are allowed to stop.
|
|
90
|
+
- Model `PaneAttach.tla` sha256: `47caab3c3d5eb0fe34841ae841e93cafb51873b67e861fdd1d1e63aee3e7e739`.
|
|
91
|
+
- Properties (sha256 of each definition's text): `WatchSettles` `7b79e33aa012dad92414f12b13df535f87fb3ee4facee03b6b07d1695cf373a0`, `AttachAnswers` `40e9a75d27e47f467113ddc84428528769d192a80c73fa112891eb5e8b0da678`, `NeverAttached` `ab58eddaa46974dcbc9273bc1698e06255a1e04c74f44c5f22e501e1b71f122c`.
|
|
92
|
+
- Source commit: muxr `3b8321f897f984ee1bf186177bf89ebfd812de2d` (`v0.2.9`) with `@byokit/herdr` 0.16.1 (identity above).
|
|
93
|
+
- Bounds: two panes (A may close, B never closes), one other refresh caller and one open of B, each made once. Every open and dial of a watch is modelled, with no retry cap, because the watch loop is finite in state. A full search finds every reachable state; no depth limit is used.
|
|
94
|
+
- Assumptions:
|
|
95
|
+
- Herdr answers every request and the kit's timers fire. This is weak fairness on the kit and Herdr steps. The refusal is a delivered answer, not a lost packet, so a network that never answers is not mistaken for this bug.
|
|
96
|
+
- Closing a pane and the user's actions get no fairness; they may never happen.
|
|
97
|
+
- A closed pane never comes back.
|
|
98
|
+
- A snapshot shows the panes alive when it lands. A pane closing between Herdr's read and the reply landing is the same race as closing just after it lands.
|
|
99
|
+
- The `<id>:sub:0:probe` refusal shape comes from the record in muxr PR 1006, not from a Herdr run here.
|
|
100
|
+
- Not modelled: Herdr outages, snapshot errors, the event socket reconnecting, `kit.stop()`, and the 20-second refresh bound and kit restart that PR 1006 adds to the host. Whether that host recovery repairs the property is a separate check and is not claimed here.
|
|
101
|
+
|
|
102
|
+
## Rerun it
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
curl -fLo tla2tools.jar https://github.com/tlaplus/tlaplus/releases/download/v1.7.4/tla2tools.jar
|
|
106
|
+
node docs/examples/statechart-pilot/check.mjs tla2tools.jar <out-dir>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Each run's TLC transcript, counterexamples included, and `model-report.json` with every hash land in `<out-dir>`. The command exits 1 when any run differs from the expected result above. When the kit or `herdrSessionSource.ts` changes, re-read the cited lines, update the model to match the code, and rerun. If the code no longer matches a row in the code-map table, the model is stale and its results do not apply.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Model check for the pane-attach race pilot: runs the pinned TLC on PaneAttach.tla for the old kit, the corrected
|
|
2
|
+
// kit, a deliberately broken variant and two reachability witnesses, and fails unless each gives its expected result.
|
|
3
|
+
// Usage: node docs/examples/statechart-pilot/check.mjs <tla2tools.jar> <out-dir>. Needs java on PATH; writes one TLC
|
|
4
|
+
// transcript per run and model-report.json into <out-dir>. This is supporting evidence, never real-product proof.
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
// tla2tools.jar from https://github.com/tlaplus/tlaplus/releases/tag/v1.7.4 (sha1 bee4a54f… as its release notes list).
|
|
13
|
+
const TLC_SHA256 = '936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88';
|
|
14
|
+
const OLD = '{"exact", "empty"}'; // socket.js:95: frame.id === '' || frame.id === id
|
|
15
|
+
const FIXED = '{"exact", "empty", "subProbe"}'; // the kit fix PR 1006 names: match "<id>:" ids too
|
|
16
|
+
const RUNS = [
|
|
17
|
+
{ name: 'old-watch', matched: OLD, drains: 'TRUE', check: 'PROPERTY WatchSettles', expect: 'violated' },
|
|
18
|
+
{ name: 'old-attach', matched: OLD, drains: 'TRUE', check: 'PROPERTY AttachAnswers', expect: 'violated' },
|
|
19
|
+
{ name: 'old-witness', matched: OLD, drains: 'TRUE', check: 'INVARIANT NeverAttached', expect: 'violated' },
|
|
20
|
+
{ name: 'fixed', matched: FIXED, drains: 'TRUE', check: 'PROPERTY WatchSettles AttachAnswers', expect: 'holds' },
|
|
21
|
+
{ name: 'fixed-witness', matched: FIXED, drains: 'TRUE', check: 'INVARIANT NeverAttached', expect: 'violated' },
|
|
22
|
+
// Broken variant: the corrected kit, but runBootstrap never starts the queued follow-up (kit.js:550-555).
|
|
23
|
+
{ name: 'broken-attach', matched: FIXED, drains: 'FALSE', check: 'PROPERTY AttachAnswers', expect: 'violated' },
|
|
24
|
+
{ name: 'broken-watch', matched: FIXED, drains: 'FALSE', check: 'PROPERTY WatchSettles', expect: 'holds' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const [jar, outArg] = process.argv.slice(2);
|
|
28
|
+
if (!jar || !outArg) {
|
|
29
|
+
console.error('usage: node docs/examples/statechart-pilot/check.mjs <tla2tools.jar> <out-dir>');
|
|
30
|
+
process.exit(2);
|
|
31
|
+
}
|
|
32
|
+
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
|
33
|
+
const jarHash = sha256(readFileSync(jar));
|
|
34
|
+
if (jarHash !== TLC_SHA256) {
|
|
35
|
+
console.error(`check: ${jar} has sha256 ${jarHash}, not the pinned TLC v1.7.4 ${TLC_SHA256}`);
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const model = readFileSync(join(here, 'PaneAttach.tla'), 'utf8');
|
|
40
|
+
const definition = (name) => model.match(new RegExp(`^${name} ==.*(?:\\n[ \\t].*)*`, 'm'))[0];
|
|
41
|
+
const out = resolve(outArg);
|
|
42
|
+
mkdirSync(out, { recursive: true });
|
|
43
|
+
const work = mkdtempSync(join(tmpdir(), 'dd-paneattach-'));
|
|
44
|
+
const report = {
|
|
45
|
+
tool: '', java: spawnSync('java', ['-version'], { encoding: 'utf8' }).stderr.split('\n')[0], tla2tools_sha256: jarHash,
|
|
46
|
+
model_sha256: sha256(model),
|
|
47
|
+
properties: Object.fromEntries(['WatchSettles', 'AttachAnswers', 'NeverAttached'].map((p) => [p, sha256(definition(p))])),
|
|
48
|
+
runs: [],
|
|
49
|
+
};
|
|
50
|
+
let failed = false;
|
|
51
|
+
try {
|
|
52
|
+
copyFileSync(join(here, 'PaneAttach.tla'), join(work, 'PaneAttach.tla'));
|
|
53
|
+
for (const run of RUNS) {
|
|
54
|
+
const cfg = `SPECIFICATION Spec\nCONSTANTS\n Matched = ${run.matched}\n DrainsQueue = ${run.drains}\nINVARIANT TypeOK\n${run.check}\n`;
|
|
55
|
+
writeFileSync(join(work, `${run.name}.cfg`), cfg);
|
|
56
|
+
// One worker: TLC 1.7.4 liveness checking is only sound single-threaded (tlaplus issue 971).
|
|
57
|
+
const r = spawnSync('java', ['-cp', resolve(jar), 'tlc2.TLC', '-workers', '1', '-deadlock', '-cleanup',
|
|
58
|
+
'-metadir', join(work, `${run.name}.states`), '-config', `${run.name}.cfg`, 'PaneAttach.tla'], { cwd: work, encoding: 'utf8' });
|
|
59
|
+
const text = r.stdout + r.stderr;
|
|
60
|
+
writeFileSync(join(out, `${run.name}.txt`), `${cfg}\n${text}`);
|
|
61
|
+
report.tool ||= text.match(/TLC2 Version [^\n]*/)?.[0] ?? '';
|
|
62
|
+
const got = /No error has been found/.test(text) ? 'holds' : /(Temporal properties were violated|Invariant \w+ is violated)/.test(text) ? 'violated' : 'error';
|
|
63
|
+
const states = text.match(/(\d+) distinct states found/)?.[1];
|
|
64
|
+
report.runs.push({ ...run, cfg_sha256: sha256(cfg), got, distinct_states: Number(states), exit: r.status });
|
|
65
|
+
console.log(`${got === run.expect ? 'ok ' : 'FAIL'} ${run.name}: expected ${run.expect}, got ${got} (${states} distinct states)`);
|
|
66
|
+
if (got !== run.expect) failed = true;
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
rmSync(work, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
writeFileSync(join(out, 'model-report.json'), `${JSON.stringify(report, null, 2)}\n`);
|
|
72
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# A pane that closes before its watch opens never stops other panes opening
|
|
2
|
+
|
|
3
|
+
A journey entry in the format of muxr's project skill (`.agents/skills/done-done-verify-pockit/features/`). It turns the model checker's counterexample in [README.md](README.md) into a journey on the real product. The entry lives here until muxr's verification owner replays it in an isolated Herdr lab and moves it into muxr's skill.
|
|
4
|
+
|
|
5
|
+
**Real-product replay: not checked.** The model results are supporting evidence only. They never count as proof that this journey passes or fails.
|
|
6
|
+
|
|
7
|
+
## Sub-features
|
|
8
|
+
|
|
9
|
+
- While a tree read waits on the status watches, an agent pane closes before Herdr accepts its watch. Opening a different, live agent still finishes: the terminal opens, or the phone shows an explicit error. It never stays on "connecting".
|
|
10
|
+
- Later opens after that moment answer at normal speed, without a host restart.
|
|
11
|
+
|
|
12
|
+
## How to get to it (user POV)
|
|
13
|
+
|
|
14
|
+
- On a busy computer, agents come and go. Open any live agent on the phone right as another agent finishes. The terminal opens.
|
|
15
|
+
|
|
16
|
+
## Driving it with the private stack
|
|
17
|
+
|
|
18
|
+
Needs a separately authorized, isolated Herdr lab session. Never drive the captain's live Herdr.
|
|
19
|
+
|
|
20
|
+
1. Start a lab host against a named lab Herdr session with two agent panes, A and B, and pair a lab phone client (Umer).
|
|
21
|
+
2. Hold A's `pane.agent_status_changed` subscribe at a socket proxy so the kit's tree read waits for A's watch, then close pane A. This is step 4 of the counterexample.
|
|
22
|
+
3. Release the subscribe. Herdr answers `{"id":"<id>:sub:0:probe","error":{"code":"pane_not_found"}}` and closes the socket.
|
|
23
|
+
4. Open B from the phone, type a short line, and read it back in the terminal.
|
|
24
|
+
5. Open B a second time.
|
|
25
|
+
|
|
26
|
+
Pass: steps 4 and 5 each open the terminal and show the typed line, or show an explicit failure on the phone, within 60 seconds. Fail: either open is still on "connecting" after 60 seconds.
|
|
27
|
+
|
|
28
|
+
PR 1006's `scripts/diagnostics/application/checkHerdrVanishedWatch.mjs` already holds a subscribe at a proxy, according to that PR's record. It checks the host client's tree read, not the phone, so it covers steps 1-3 only.
|
|
29
|
+
|
|
30
|
+
## Evidence
|
|
31
|
+
|
|
32
|
+
- A phone screenshot after step 4 and after step 5.
|
|
33
|
+
- The host journal for the run, plus a proxy log showing A's refusal frame and its id.
|
|
34
|
+
- `steps.json` naming the muxr version, the starting state (two agents, A held), the steps above and what Umer sees at the end.
|
|
35
|
+
- Kept separately as supporting evidence: the model report from `node docs/examples/statechart-pilot/check.mjs` in done-done, at the muxr and kit commits that were driven.
|
|
36
|
+
|
|
37
|
+
On muxr v0.2.9 with `@byokit/herdr` 0.16.1, the model predicts step 4 hangs. With the kit's refusal-id fix, the model predicts both opens answer. Neither prediction has been checked on the real product.
|
package/docs/reference.md
CHANGED
|
@@ -177,18 +177,23 @@ done-done never drives the browser: the recipe or journey writes one page-check
|
|
|
177
177
|
{"url": "http://127.0.0.1:4100/", "overflow": true, "overflowing": ["div#wide"], "console_errors": ["boom"], "failed_requests": [{"url": "http://127.0.0.1:4100/x.js", "status": 404}],
|
|
178
178
|
"squashed_text": [{"element": "h3.card-title", "text": "Approved", "chars": 1.6, "lines": 8, "words": 1}],
|
|
179
179
|
"clipped_text": [{"element": "h3.card-title", "text": "Answered: the release notes are approved", "by": "its own box"}],
|
|
180
|
-
"cut_off": [{"element": "div.clock", "text": "12:30", "edges": ["top"]}]
|
|
180
|
+
"cut_off": [{"element": "div.clock", "text": "12:30", "edges": ["top"]}],
|
|
181
|
+
"controls": [{"element": "button#request", "text": "Request changes", "native": true, "height": 21}],
|
|
182
|
+
"actions": [{"selector": "#request", "text": "Request changes", "failed": "changed nothing a user can read on the page (its text, elements or address) within 5s"}]}
|
|
181
183
|
```
|
|
182
184
|
|
|
185
|
+
`controls` and `actions` are optional too; [`done-done look`](#look-check) writes `controls` on every page and `actions` when it clicks one. With a [`look` block](#look-check), the check fails on each control in `controls` drawn as the browser's own, and on one taller than `max_control`; it fails on each `actions` item with a `failed` reason with or without one. A recipe that drives its own browser runs done-done's `CONTROLS` measurement (exported from `dom.js` next to `LOOK`) for `controls`, and `AIM` and `CHANGED` around each click for `actions`.
|
|
186
|
+
|
|
183
187
|
With your browser tool, for example chrome-devtools-axi: `overflow` and `overflowing` from `eval` (document `scrollWidth` beyond `clientWidth`, and the elements whose right edge passes it), `console_errors` from `console --type error`, `failed_requests` (status 400 or more, or 0 for no response) from `network`, `squashed_text` from `eval` of done-done's measurement function, run in each frame of the page (an embedded frame too). It lists every visible HTML element with its own text (SVG text has no layout box and is skipped; widths are untransformed layout pixels, so a scaled ancestor does not shrink them) whose box is under 8 characters wide (its width over the average glyph width of its font) while its text is wider than the box, or whose text sits on more lines (distinct line boxes) than it has words: a title wrapped one letter per line in a narrow column. `squashed_text`, `clipped_text` and `cut_off` are optional, so a record written without them stays valid; [`done-done look`](#look-check) writes all of them, and the check fails on each `clipped_text` and `cut_off` item as it does on `squashed_text`. The agent drive prompt carries the recipe, and a verify skill should carry it too. Verify's check and `done-done check` fail naming the capture and each issue not on an allow list, and fail when a capture has no record or a record is not this shape. No `dom` block keeps today's behaviour.
|
|
184
188
|
|
|
185
189
|
### Look check
|
|
186
190
|
|
|
187
|
-
`done-done look` is the look step a journey calls on a web page. It opens the page in headless Chrome at a 390x844 phone viewport (a mobile device at 1x scale), measures the look defects in the DOM, writes the screenshot with its page-check record next to it, and exits 1 on any hit, naming the element, the kind and its text. It is a measurement step, not a verdict: with no hit it ends `result: clean` (exit 0), never `pass`, and its capture is judged when a `check` or `verify` cites it.
|
|
191
|
+
`done-done look` is the look step a journey calls on a web page. It opens the page in headless Chrome at a 390x844 phone viewport (a mobile device at 1x scale), clicks each `--act` and checks it completes, measures the look defects in the DOM, writes the screenshot with its page-check record next to it, and exits 1 on any hit, naming the element, the kind and its text. It is a measurement step, not a verdict: with no hit it ends `result: clean` (exit 0), never `pass`, and its capture is judged when a `check` or `verify` cites it. Without `--mock` no model runs here, so the same page gives the same answer. A worker's claim that a phone UI works needs this 390 px capture of the real page, plus the page's scroll-container count, as proof; a mock or another width is not proof.
|
|
188
192
|
|
|
189
193
|
```sh
|
|
190
194
|
done-done look http://127.0.0.1:3000/inbox --out "$DONE_DONE_EVIDENCE_ROOT/inbox.png"
|
|
191
195
|
done-done look ./public/inbox.html # a path on disk opens as its file:// URL
|
|
196
|
+
done-done look "$DONE_DONE_BASE_URL/#decide" --act '#item-1 .approve' --mock design/decide.png --out "$DONE_DONE_EVIDENCE_ROOT/decide.png"
|
|
192
197
|
```
|
|
193
198
|
|
|
194
199
|
A journey in `.done-done.yaml` that calls it (one command per screen, `&&` between them, so the first hit stops the journey):
|
|
@@ -211,17 +216,31 @@ journeys:
|
|
|
211
216
|
| squashed text | done-done's [squashed-text measurement](#dom-assertions): a text block under 8 characters wide while its text does not fit, or on more lines than it has words |
|
|
212
217
|
| clipped text | part of an element's text shows and part runs past a container that hides its overflow (`overflow: hidden` or `clip`), with no `text-overflow: ellipsis` (sideways) or line clamp (downwards): a card title cut to one line |
|
|
213
218
|
| cut off | part of an element shows and part lies past the left, right or top edge of the viewport: a status-bar clock clipped at the top. The page is at its top, so content below the bottom edge is fine |
|
|
219
|
+
| action did not complete | an `--act <selector>` names nothing, a disabled control, or one covered where its centre is (a toast over a button), or its click changes nothing a user can read within 5 seconds: the page's visible text, its element count and its address all stay the same. A card that moves, a reply that shows as saved or a toast that says so is a change; a button that is only there is not |
|
|
220
|
+
| unstyled control | with a `look` block: a button, select, textarea or input drawn as the browser's own, its background and border the ones the browser gives the same control (same tag, input type and colour scheme) on a page with no styles. Checkbox, radio, range, colour and file inputs are not measured |
|
|
221
|
+
| oversized control | with `look.max_control`: a button, select, input or `role=button` taller than that many px. A textarea or list box is not measured |
|
|
222
|
+
| differs from the approved design | with `--mock <file>`: the [design check](#design-check)'s judge (the one the user set) names a defect on the capture next to that approved mock, the same request as `design --mock`, cached the same way |
|
|
214
223
|
|
|
215
224
|
Something entirely outside its container or the viewport is hidden on purpose (a closed drawer, a visually hidden label) and is not a hit; a cut element's children are not listed again. Content in a scrolling container (`overflow: auto` or `scroll`, such as a carousel) scrolls into view and is not clipped.
|
|
216
|
-
- **
|
|
225
|
+
- **The approved reference.** A `look` block in `.done-done.yaml` is the project's approved design reference; with none, look behaves as before:
|
|
226
|
+
|
|
227
|
+
```yaml
|
|
228
|
+
look:
|
|
229
|
+
tokens: web/tokens.css # the approved design tokens file, relative to the repo
|
|
230
|
+
max_control: --ctl-h # the tallest a control may be: px (44), or a token in tokens set in px, rem or em (a rem or em is 16 px)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
The block alone turns on the unstyled-control check; `max_control` adds the size check. A token that is not set in the file, a tokens file that does not read or an unknown key is a config error (exit 2). The per-page reference is its mock: pass `--mock <file>` with the approved design of that screen. The `--act` clicks are real mouse clicks at each control's centre, in order, and the page they leave is the one measured and captured, so a journey drives the page to the state to check with them.
|
|
234
|
+
- **What it writes.** `--out <file.png>` (default `look.png` in `$DONE_DONE_EVIDENCE_ROOT` inside a journey, else in the current folder) gets the viewport screenshot, exactly the viewport size in pixels, and `<file>.dom.json` next to it the page-check record of [DOM assertions](#dom-assertions), with the page's console errors and failed requests too. look itself fails only on the kinds above; with a `dom` block, verify's check also reads the record and applies its allow lists to console errors and failed requests.
|
|
217
235
|
- **Chrome.** look starts its own headless Chrome or Chromium (`google-chrome`, `google-chrome-stable`, `chromium` or `chromium-browser` on PATH, or `DONE_DONE_CHROME` naming the binary) with a throwaway profile, and removes both when it ends. It runs Chrome without its sandbox so it starts in containers, so point it at your own app, not untrusted sites. No Chrome is a usage error (exit 2) that says how to install or point to one; a page that does not load (its network error, or no load event within 30 seconds) is exit 2 with the cause.
|
|
218
|
-
- **Limits.** Only the main frame is measured, not an embedded frame; a transform or `clip-path` that hides part of a box is not seen; nothing is
|
|
236
|
+
- **Limits.** Only the main frame is measured, not an embedded frame; a transform or `clip-path` that hides part of a box is not seen; nothing is scrolled first, and only `--act` clicks; drive any other state (typing, a URL per state) before you call look. Look at the capture too: a defect the DOM cannot see (a stretched image, a colour) is the [design check](#design-check)'s.
|
|
219
237
|
|
|
220
238
|
| Result | Exit |
|
|
221
239
|
|---|---|
|
|
222
240
|
| no hit | 0 |
|
|
223
241
|
| a hit of any kind | 1 |
|
|
224
|
-
| bad flags, no Chrome,
|
|
242
|
+
| bad flags, a bad `look` block, no Chrome, the page did not load, or `--mock` with no judge set | 2 |
|
|
243
|
+
| no hit, but the `--mock` judge is unsure | 3 |
|
|
225
244
|
|
|
226
245
|
### Lint check
|
|
227
246
|
|
|
@@ -238,6 +257,7 @@ journeys:
|
|
|
238
257
|
- `checks.upstream_catch_up: true`: in a fork, lines the parent repo already shipped are not authored here, so lint subtracts the changes between the PR's base and the merge base with the parent's default branch. A PR that merges in no new upstream commit loses nothing.
|
|
239
258
|
- `checks.moved_from: true`: a line in the PR body `moved-from: <owner>/<repo>@<ref> <dst-prefix>=<src-prefix>` (more `dst=src` pairs may follow) cancels added lines that match, byte for byte after trimming, the file at the same path under `<src-prefix>` in that repo at `<ref>`. The source repo must have the PR's owner; authored lines still count.
|
|
240
259
|
- `checks.generated_paths: ["gen/**", "*.snap"]`: globs for the repo's own generated files, counted as generated beside the built-in ones above, so they stay out of the size count. Default none, so results do not change. Like the two rules above it applies to a PR URL, never a report file.
|
|
260
|
+
- `.gitattributes` in the target repo: a file it marks `linguist-generated`, `linguist-generated=true` or `-diff` is counted as generated too, with no config. `linguist-generated=false` and an unset attribute are not; the last matching line wins. `lint` reads the file at the PR head (else its base) and, like the rules above, only for a PR URL.
|
|
241
261
|
|
|
242
262
|
### Base-ref before
|
|
243
263
|
|
|
@@ -348,7 +368,7 @@ evidence: <paths of the evidence files, or "not proven: <cause>">
|
|
|
348
368
|
|---|---|
|
|
349
369
|
| Not empty | The file must contain bytes. |
|
|
350
370
|
| Valid media | The magic bytes match the extension. PNG and JPEG are complete (end markers present) and at least 64×64. PNG is not a blank frame. WebP and GIF are not truncated. MP4/MOV and WebM/MKV decode at least two video packets (`ffprobe`). Text and log files hold text; JSON parses. |
|
|
351
|
-
| Fresh | The file is in a run record whose commit is HEAD.
|
|
371
|
+
| Fresh | The file is in a run record whose commit is HEAD. Two exceptions: if the only commits since the run touch `ignore_paths`, the evidence still counts; and if the changed files touch no journey or feature the run covered (the same mapping as `done-done affected`, model-free), the proof holds and the result warns with both commits. A change that does touch the run's journey is stale. A receipt keeps each run's `commit` beside its own `head`. An empty commit does not count as touching only `ignore_paths`. |
|
|
352
372
|
| Clean | The run was not dirty, unless `evidence.allow_dirty: true`. The run command also exited 0. |
|
|
353
373
|
| Not reused | Fails when the files are byte-identical within one claim. Also fails when every file was already used to prove a different claim in this repo. A claim is named by `--claim-id <id>`, by default the current branch (with `--from <PR URL>`, the PR's branch; on a detached HEAD, the commit), so rewording the outcome is not reuse. One branch is one claim: several claims on one branch each need their own `--claim-id`, or they share one reuse scope. The ledger is `<root>/.done-done/ledger` in the evidence root, so each repo keeps its own (pin `app`, as `init` does, so two repos with the same directory name do not share one); `DONE_DONE_HOME` moves it only with the default evidence root. |
|
|
354
374
|
|
|
@@ -652,6 +672,7 @@ An `agent_config` entry merges with the defaults field by field, so `claude: { m
|
|
|
652
672
|
| `instance` | none; the app under test started per run, see [Run-instance harness](#run-instance-harness) |
|
|
653
673
|
| `matrix` | none, so each journey runs once; `theme`, `width`, `form` and `skip` set the [capture matrix](#capture-matrix) |
|
|
654
674
|
| `dom` | none; page-check records per capture, see [DOM assertions](#dom-assertions) |
|
|
675
|
+
| `look` | none; the look step's approved design reference (`tokens`, `max_control`), see [Look check](#look-check) |
|
|
655
676
|
| `inputs` | `[]`; repo-relative paths or globs the drive must leave alone, see [Input guard](#input-guard) |
|
|
656
677
|
| `base_ref` | none; a ref `verify` also drives, pairing its captures with the head's, see [Base-ref before](#base-ref-before) |
|
|
657
678
|
| `evidence.root` | `<local_root>/<app>`, outside the repo. A relative path is under the repo; an absolute path is used as-is |
|
package/package.json
CHANGED
|
@@ -50,7 +50,11 @@ its findings and captures, and end it with `done-done respond --action fix` only
|
|
|
50
50
|
A journey that `spends` runs only after the user approves spending: `done-done run <journey>` refuses it, so ask the user and use `verify`.
|
|
51
51
|
|
|
52
52
|
For a web page, `done-done look <url>` is the look step a journey calls: it captures the page at a 390x844 phone viewport
|
|
53
|
-
with its page-check record and exits 1 on squashed, clipped or cut-off UI, naming each element.
|
|
53
|
+
with its page-check record and exits 1 on squashed, clipped or cut-off UI, naming each element. `--act <selector>` clicks a
|
|
54
|
+
control and fails when the page does not change (a card moves, a reply saves, a toast shows): a button that is only there
|
|
55
|
+
is not proof it works. A `look` block in `.done-done.yaml` (`tokens`, `max_control`) is the approved reference: look
|
|
56
|
+
then fails on a control drawn as the browser's own or taller than `max_control`, and `--mock <file>` has the judge compare
|
|
57
|
+
the page with its approved mock. A hit fails the journey;
|
|
54
58
|
never wrap it in `|| true` or log the exit and go on. A claim that a phone UI works needs a 390 px capture of the real
|
|
55
59
|
page and the page's scroll-container count as proof: a mock, a different width or a claim with no count is not proof. A journey in `.done-done.yaml` that calls it:
|
|
56
60
|
|