done-done-axi 0.6.4 → 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/look.js +23 -5
- package/docs/reference.md +26 -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/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`);
|
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
|
|
|
@@ -349,7 +368,7 @@ evidence: <paths of the evidence files, or "not proven: <cause>">
|
|
|
349
368
|
|---|---|
|
|
350
369
|
| Not empty | The file must contain bytes. |
|
|
351
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. |
|
|
352
|
-
| 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`. |
|
|
353
372
|
| Clean | The run was not dirty, unless `evidence.allow_dirty: true`. The run command also exited 0. |
|
|
354
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. |
|
|
355
374
|
|
|
@@ -653,6 +672,7 @@ An `agent_config` entry merges with the defaults field by field, so `claude: { m
|
|
|
653
672
|
| `instance` | none; the app under test started per run, see [Run-instance harness](#run-instance-harness) |
|
|
654
673
|
| `matrix` | none, so each journey runs once; `theme`, `width`, `form` and `skip` set the [capture matrix](#capture-matrix) |
|
|
655
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) |
|
|
656
676
|
| `inputs` | `[]`; repo-relative paths or globs the drive must leave alone, see [Input guard](#input-guard) |
|
|
657
677
|
| `base_ref` | none; a ref `verify` also drives, pairing its captures with the head's, see [Base-ref before](#base-ref-before) |
|
|
658
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
|
|