agent-dealer 1.3.1 → 1.3.2
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/bundle/server/dist/adapters/git-worktree.js +38 -1
- package/bundle/server/dist/adapters/github.js +50 -0
- package/bundle/server/dist/adapters/github.test.js +89 -1
- package/bundle/server/dist/coordinator/auto-merge.js +46 -9
- package/bundle/server/dist/coordinator/commands.js +89 -9
- package/bundle/server/dist/coordinator/commands.test.js +228 -2
- package/bundle/server/dist/coordinator/developer-effect.js +36 -0
- package/bundle/server/dist/coordinator/operator-verification.integration.test.js +102 -1
- package/bundle/server/dist/coordinator/product-scope-contract.test.js +249 -0
- package/bundle/server/dist/coordinator/prompts.js +57 -2
- package/bundle/server/dist/coordinator/prompts.test.js +236 -2
- package/bundle/server/dist/coordinator/reviewer-browser-probe.js +89 -10
- package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +100 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +30 -1
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +54 -0
- package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +594 -0
- package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +496 -0
- package/bundle/server/dist/coordinator/source-reload.js +44 -9
- package/bundle/server/dist/coordinator/visual-qa-collection.test.js +619 -0
- package/bundle/server/dist/coordinator/visual-qa.js +630 -0
- package/bundle/server/dist/coordinator/visual-qa.test.js +493 -0
- package/bundle/server/dist/paths.js +24 -0
- package/bundle/server/dist/repository/issues.js +8 -1
- package/bundle/server/dist/repository/issues.test.js +34 -0
- package/bundle/server/dist/routes/issues-execution-contract.test.js +293 -0
- package/bundle/server/dist/routes/issues-source-attachments.test.js +277 -0
- package/bundle/server/dist/routes/issues.js +23 -8
- package/bundle/server/dist/routes/issues.test.js +80 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-DQ5ivcyg.js +64 -0
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-Bz_c-UbO.js +0 -64
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// packages/server/src/coordinator/visual-qa.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-381: runtime-aware visual-QA policy, structured receipt parsing,
|
|
4
|
+
// SHA-bound validation, and reviewer-section rendering. Pure tests only —
|
|
5
|
+
// artifact persistence and worktree collection live in
|
|
6
|
+
// visual-qa-collection.test.ts (they need a database + git worktree).
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { VISUAL_QA_DIR_NAME, capableDeveloperVisualQaPromptSection, developerVisualQaPolicy, parseVisualQaReceipt, validateVisualQaReceipt, visualQaReviewerSection, } from "./visual-qa.js";
|
|
13
|
+
const HEAD_A = "a".repeat(40);
|
|
14
|
+
const HEAD_B = "b".repeat(40);
|
|
15
|
+
function verifiedConclusion(head = HEAD_A) {
|
|
16
|
+
return [
|
|
17
|
+
`Implemented the widget and its tests.`,
|
|
18
|
+
``,
|
|
19
|
+
`Visual QA: verified`,
|
|
20
|
+
`head: ${head}`,
|
|
21
|
+
`app: real (no mocks)`,
|
|
22
|
+
`scenario: /widgets/new — fill the form and submit`,
|
|
23
|
+
`viewports: 1440x900, 390x800`,
|
|
24
|
+
`commands: npm run dev:api & npm run dev:web -- --port 4321; capture via chromium headless screenshot`,
|
|
25
|
+
`screenshots: ${VISUAL_QA_DIR_NAME}/desktop-1440x900.png, ${VISUAL_QA_DIR_NAME}/mobile-390x800.png`,
|
|
26
|
+
``,
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
29
|
+
function writeWorktree(files) {
|
|
30
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-visualqa-"));
|
|
31
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
32
|
+
const dest = path.join(dir, rel);
|
|
33
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
34
|
+
fs.writeFileSync(dest, content);
|
|
35
|
+
}
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
function parsedVerified(head = HEAD_A) {
|
|
39
|
+
const parsed = parseVisualQaReceipt(verifiedConclusion(head));
|
|
40
|
+
assert.equal(parsed.found, true);
|
|
41
|
+
assert.ok(parsed.found && parsed.receipt);
|
|
42
|
+
return parsed.receipt;
|
|
43
|
+
}
|
|
44
|
+
// Policy: only Muse keeps the preflight; every other runtime attempts in-session.
|
|
45
|
+
test("NOT-381: policy keeps Muse on the preflight and gives capable runtimes the in-session attempt", () => {
|
|
46
|
+
assert.equal(developerVisualQaPolicy("muse_code"), "muse");
|
|
47
|
+
assert.equal(developerVisualQaPolicy("claude_code"), "capable");
|
|
48
|
+
assert.equal(developerVisualQaPolicy("codex_local"), "capable");
|
|
49
|
+
assert.equal(developerVisualQaPolicy("cursor_local"), "capable");
|
|
50
|
+
});
|
|
51
|
+
test("NOT-381: unknown runtimes default to capable — the probe degrades to unavailable, never a missed verification", () => {
|
|
52
|
+
assert.equal(developerVisualQaPolicy(null), "capable");
|
|
53
|
+
assert.equal(developerVisualQaPolicy(undefined), "capable");
|
|
54
|
+
assert.equal(developerVisualQaPolicy("some_future_runtime"), "capable");
|
|
55
|
+
});
|
|
56
|
+
test("NOT-381: capable section requires one bounded probe, the real app, defaults, and a structured receipt", () => {
|
|
57
|
+
const text = capableDeveloperVisualQaPromptSection().join("\n");
|
|
58
|
+
assert.match(text, /## Visual QA/);
|
|
59
|
+
assert.match(text, /ONE bounded capability probe/);
|
|
60
|
+
assert.match(text, /loopback/);
|
|
61
|
+
assert.match(text, /do not install browsers or packages/);
|
|
62
|
+
assert.match(text, /do not retry the failed capability/);
|
|
63
|
+
assert.match(text, /do not weaken any sandbox flag/);
|
|
64
|
+
assert.match(text, /REAL backend and frontend/);
|
|
65
|
+
assert.match(text, /Use mocks only when an acceptance criterion explicitly permits them/);
|
|
66
|
+
assert.match(text, /1440x900/);
|
|
67
|
+
assert.match(text, /390x800/);
|
|
68
|
+
assert.match(text, /\.agent-dealer-visual-qa\//);
|
|
69
|
+
assert.match(text, /never commit it/);
|
|
70
|
+
assert.match(text, /Visual QA: verified/);
|
|
71
|
+
assert.match(text, /head:/);
|
|
72
|
+
assert.match(text, /screenshots:/);
|
|
73
|
+
assert.match(text, /Visual QA: unavailable/);
|
|
74
|
+
assert.match(text, /capability:/);
|
|
75
|
+
assert.match(text, /Visual QA: not_required/);
|
|
76
|
+
assert.doesNotMatch(text, /--yolo/);
|
|
77
|
+
});
|
|
78
|
+
// Parsing.
|
|
79
|
+
test("NOT-381: parses a full verified block", () => {
|
|
80
|
+
const receipt = parsedVerified();
|
|
81
|
+
assert.equal(receipt.status, "verified");
|
|
82
|
+
assert.equal(receipt.headSha, HEAD_A);
|
|
83
|
+
assert.equal(receipt.realApp, "real (no mocks)");
|
|
84
|
+
assert.match(receipt.scenario, /\/widgets\/new/);
|
|
85
|
+
assert.deepEqual(receipt.viewports, ["1440x900", "390x800"]);
|
|
86
|
+
assert.match(receipt.commands, /chromium/);
|
|
87
|
+
assert.deepEqual(receipt.screenshots, [
|
|
88
|
+
`${VISUAL_QA_DIR_NAME}/desktop-1440x900.png`,
|
|
89
|
+
`${VISUAL_QA_DIR_NAME}/mobile-390x800.png`,
|
|
90
|
+
]);
|
|
91
|
+
assert.equal(receipt.capability, null);
|
|
92
|
+
});
|
|
93
|
+
test("NOT-381: no Visual QA line means no receipt, not an error", () => {
|
|
94
|
+
assert.deepEqual(parseVisualQaReceipt("Implemented the widget.\nNo screenshots.\n"), { found: false });
|
|
95
|
+
});
|
|
96
|
+
test("NOT-381: unknown status fails loudly", () => {
|
|
97
|
+
const parsed = parseVisualQaReceipt("Done.\n\nVisual QA: done\n");
|
|
98
|
+
assert.equal(parsed.found, true);
|
|
99
|
+
assert.ok(parsed.found && !parsed.receipt);
|
|
100
|
+
assert.match(parsed.error, /unknown Visual QA status "done"/);
|
|
101
|
+
});
|
|
102
|
+
test("NOT-381: last Visual QA block wins when the worker corrects itself", () => {
|
|
103
|
+
const parsed = parseVisualQaReceipt(`Visual QA: unavailable\ncapability: browser launch: no chromium\n\nActually fixed it:\n\n${verifiedConclusion()}`);
|
|
104
|
+
assert.equal(parsed.found, true);
|
|
105
|
+
assert.ok(parsed.found && parsed.receipt);
|
|
106
|
+
assert.equal(parsed.receipt.status, "verified");
|
|
107
|
+
});
|
|
108
|
+
test("NOT-381: parses unavailable with a concrete capability, inline or field form", () => {
|
|
109
|
+
const field = parseVisualQaReceipt(`Visual QA: unavailable\nhead: ${HEAD_A}\ncapability: loopback listen: EPERM\n`);
|
|
110
|
+
assert.equal(field.found, true);
|
|
111
|
+
assert.ok(field.found && field.receipt);
|
|
112
|
+
assert.equal(field.receipt.status, "unavailable");
|
|
113
|
+
assert.equal(field.receipt.capability, "loopback listen: EPERM");
|
|
114
|
+
assert.equal(field.receipt.headSha, HEAD_A);
|
|
115
|
+
const inline = parseVisualQaReceipt(`Visual QA: unavailable (browser launch: chromium binary not found)\n`);
|
|
116
|
+
assert.ok(inline.found && inline.receipt);
|
|
117
|
+
assert.equal(inline.receipt.status, "unavailable");
|
|
118
|
+
assert.equal(inline.receipt.capability, "browser launch: chromium binary not found");
|
|
119
|
+
});
|
|
120
|
+
test("NOT-381: legacy Muse `not run` line normalizes to unavailable, never to a pass", () => {
|
|
121
|
+
const parsed = parseVisualQaReceipt(`Visual QA: not run (no headless shell installed, and Google Chrome.app headless aborts in-session)\n`);
|
|
122
|
+
assert.ok(parsed.found && parsed.receipt);
|
|
123
|
+
assert.equal(parsed.receipt.status, "unavailable");
|
|
124
|
+
assert.match(parsed.receipt.capability, /no headless shell installed/);
|
|
125
|
+
});
|
|
126
|
+
test("NOT-381: bare `not run` without a reason parses with no capability (validation rejects it)", () => {
|
|
127
|
+
const parsed = parseVisualQaReceipt(`Visual QA: not run\n`);
|
|
128
|
+
assert.ok(parsed.found && parsed.receipt);
|
|
129
|
+
assert.equal(parsed.receipt.status, "unavailable");
|
|
130
|
+
assert.equal(parsed.receipt.capability, null);
|
|
131
|
+
const validation = validateVisualQaReceipt(parsed.receipt, { expectedHeadSha: HEAD_A, worktreePath: "/none" });
|
|
132
|
+
assert.equal(validation.ok, false);
|
|
133
|
+
assert.match(validation.reason, /names no failed capability/);
|
|
134
|
+
});
|
|
135
|
+
test("NOT-381: not_required accepts underscore, hyphen, and space spellings", () => {
|
|
136
|
+
for (const token of ["not_required", "not-required", "not required"]) {
|
|
137
|
+
const parsed = parseVisualQaReceipt(`No UI changed.\n\nVisual QA: ${token}\n`);
|
|
138
|
+
assert.ok(parsed.found && parsed.receipt, token);
|
|
139
|
+
assert.equal(parsed.receipt.status, "not_required");
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
test("NOT-381: legacy Muse usable-shell verified line parses with no fields (validation rejects it)", () => {
|
|
143
|
+
const parsed = parseVisualQaReceipt(`Visual QA: verified (shot.png, exit 0)\n`);
|
|
144
|
+
assert.ok(parsed.found && parsed.receipt);
|
|
145
|
+
assert.equal(parsed.receipt.status, "verified");
|
|
146
|
+
assert.equal(parsed.receipt.headSha, null);
|
|
147
|
+
const validation = validateVisualQaReceipt(parsed.receipt, { expectedHeadSha: HEAD_A, worktreePath: "/none" });
|
|
148
|
+
assert.equal(validation.ok, false);
|
|
149
|
+
assert.match(validation.reason, /names no HEAD SHA/);
|
|
150
|
+
});
|
|
151
|
+
test("NOT-381: fields stop at a markdown heading or fence; singular aliases work", () => {
|
|
152
|
+
const parsed = parseVisualQaReceipt([
|
|
153
|
+
`Visual QA: verified`,
|
|
154
|
+
`sha: ${HEAD_A}`,
|
|
155
|
+
`app: real`,
|
|
156
|
+
`path: /login`,
|
|
157
|
+
`viewport: 1440x900`,
|
|
158
|
+
`command: npm run dev; screenshot`,
|
|
159
|
+
`screenshot: ${VISUAL_QA_DIR_NAME}/a.png`,
|
|
160
|
+
``,
|
|
161
|
+
`## Follow-ups`,
|
|
162
|
+
`viewport: 1x1`,
|
|
163
|
+
"```",
|
|
164
|
+
`head: ${HEAD_B}`,
|
|
165
|
+
].join("\n"));
|
|
166
|
+
assert.ok(parsed.found && parsed.receipt);
|
|
167
|
+
const r = parsed.receipt;
|
|
168
|
+
assert.equal(r.headSha, HEAD_A);
|
|
169
|
+
assert.equal(r.scenario, "/login");
|
|
170
|
+
assert.deepEqual(r.viewports, ["1440x900"]);
|
|
171
|
+
assert.deepEqual(r.screenshots, [`${VISUAL_QA_DIR_NAME}/a.png`]);
|
|
172
|
+
});
|
|
173
|
+
// Validation: verified.
|
|
174
|
+
test("NOT-381: verified receipt is accepted when SHA binds and screenshots are regular files in the artifact dir", () => {
|
|
175
|
+
const dir = writeWorktree({
|
|
176
|
+
[`${VISUAL_QA_DIR_NAME}/desktop-1440x900.png`]: "png-bytes",
|
|
177
|
+
[`${VISUAL_QA_DIR_NAME}/mobile-390x800.png`]: "png-bytes",
|
|
178
|
+
});
|
|
179
|
+
try {
|
|
180
|
+
assert.deepEqual(validateVisualQaReceipt(parsedVerified(), { expectedHeadSha: HEAD_A, worktreePath: dir }), {
|
|
181
|
+
ok: true,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
test("NOT-381: SHA mismatch fails loudly", () => {
|
|
189
|
+
const dir = writeWorktree({ [`${VISUAL_QA_DIR_NAME}/desktop-1440x900.png`]: "x" });
|
|
190
|
+
try {
|
|
191
|
+
const v = validateVisualQaReceipt(parsedVerified(HEAD_B), { expectedHeadSha: HEAD_A, worktreePath: dir });
|
|
192
|
+
assert.equal(v.ok, false);
|
|
193
|
+
assert.match(v.reason, /bound to bbbbbbbb but the developer handoff is aaaaaaaa/);
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
test("NOT-381: missing or invalid HEAD fails loudly", () => {
|
|
200
|
+
const dir = writeWorktree({});
|
|
201
|
+
try {
|
|
202
|
+
const missing = { ...parsedVerified(), headRaw: null, headSha: null };
|
|
203
|
+
assert.match(validateVisualQaReceipt(missing, { expectedHeadSha: HEAD_A, worktreePath: dir }).reason, /names no HEAD SHA/);
|
|
204
|
+
const invalid = { ...parsedVerified(), headRaw: "zzz", headSha: null };
|
|
205
|
+
assert.match(validateVisualQaReceipt(invalid, { expectedHeadSha: HEAD_A, worktreePath: dir }).reason, /invalid HEAD SHA/);
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
test("NOT-381: each missing verified field fails loudly", () => {
|
|
212
|
+
const dir = writeWorktree({});
|
|
213
|
+
try {
|
|
214
|
+
const base = parsedVerified();
|
|
215
|
+
const cases = [
|
|
216
|
+
["realApp", /real-app\/mocks/],
|
|
217
|
+
["scenario", /scenario\/path/],
|
|
218
|
+
["commands", /commands summary/],
|
|
219
|
+
];
|
|
220
|
+
for (const [key, re] of cases) {
|
|
221
|
+
const v = validateVisualQaReceipt({ ...base, [key]: null }, { expectedHeadSha: HEAD_A, worktreePath: dir });
|
|
222
|
+
assert.equal(v.ok, false, String(key));
|
|
223
|
+
assert.match(v.reason, re);
|
|
224
|
+
}
|
|
225
|
+
const noViewports = validateVisualQaReceipt({ ...base, viewports: [], viewportsRaw: null }, { expectedHeadSha: HEAD_A, worktreePath: dir });
|
|
226
|
+
assert.match(noViewports.reason, /no viewport sizes/);
|
|
227
|
+
const noScreenshots = validateVisualQaReceipt({ ...base, screenshots: [] }, { expectedHeadSha: HEAD_A, worktreePath: dir });
|
|
228
|
+
assert.match(noScreenshots.reason, /no screenshot files/);
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
test("NOT-381: missing screenshot file fails loudly", () => {
|
|
235
|
+
const dir = writeWorktree({ [`${VISUAL_QA_DIR_NAME}/desktop-1440x900.png`]: "x" });
|
|
236
|
+
try {
|
|
237
|
+
const v = validateVisualQaReceipt(parsedVerified(), { expectedHeadSha: HEAD_A, worktreePath: dir });
|
|
238
|
+
assert.equal(v.ok, false);
|
|
239
|
+
assert.match(v.reason, /screenshot is missing/);
|
|
240
|
+
assert.match(v.reason, /mobile-390x800\.png/);
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
test("NOT-381: screenshot symlink fails loudly", () => {
|
|
247
|
+
const dir = writeWorktree({
|
|
248
|
+
[`${VISUAL_QA_DIR_NAME}/desktop-1440x900.png`]: "x",
|
|
249
|
+
"real.png": "x",
|
|
250
|
+
});
|
|
251
|
+
fs.symlinkSync(path.join(dir, "real.png"), path.join(dir, VISUAL_QA_DIR_NAME, "mobile-390x800.png"));
|
|
252
|
+
try {
|
|
253
|
+
const v = validateVisualQaReceipt(parsedVerified(), { expectedHeadSha: HEAD_A, worktreePath: dir });
|
|
254
|
+
assert.equal(v.ok, false);
|
|
255
|
+
assert.match(v.reason, /is a symlink, refusing/);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
test("NOT-381: symlinked artifact directory fails loudly (realpath escapes the worktree)", () => {
|
|
262
|
+
const dir = writeWorktree({ "keep.txt": "x" });
|
|
263
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-visualqa-out-"));
|
|
264
|
+
try {
|
|
265
|
+
fs.writeFileSync(path.join(outside, "desktop-1440x900.png"), "x");
|
|
266
|
+
fs.writeFileSync(path.join(outside, "mobile-390x800.png"), "x");
|
|
267
|
+
fs.symlinkSync(outside, path.join(dir, VISUAL_QA_DIR_NAME));
|
|
268
|
+
const v = validateVisualQaReceipt(parsedVerified(), { expectedHeadSha: HEAD_A, worktreePath: dir });
|
|
269
|
+
assert.equal(v.ok, false);
|
|
270
|
+
assert.match(v.reason, /visual-artifact directory is a symlink, refusing/);
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
274
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
test("NOT-381: symlinked subdirectory fails loudly (intermediate symlink escapes the worktree)", () => {
|
|
278
|
+
const dir = writeWorktree({ [`${VISUAL_QA_DIR_NAME}/desktop-1440x900.png`]: "x" });
|
|
279
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-visualqa-out-"));
|
|
280
|
+
try {
|
|
281
|
+
fs.writeFileSync(path.join(outside, "mobile-390x800.png"), "x");
|
|
282
|
+
fs.symlinkSync(outside, path.join(dir, VISUAL_QA_DIR_NAME, "sub"));
|
|
283
|
+
const base = parsedVerified();
|
|
284
|
+
const v = validateVisualQaReceipt({
|
|
285
|
+
...base,
|
|
286
|
+
screenshots: [`${VISUAL_QA_DIR_NAME}/desktop-1440x900.png`, `${VISUAL_QA_DIR_NAME}/sub/mobile-390x800.png`],
|
|
287
|
+
}, { expectedHeadSha: HEAD_A, worktreePath: dir });
|
|
288
|
+
assert.equal(v.ok, false);
|
|
289
|
+
assert.match(v.reason, /traverses a symlink, refusing/);
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
293
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
test("NOT-381: screenshot path traversal and absolute paths fail loudly", () => {
|
|
297
|
+
const dir = writeWorktree({
|
|
298
|
+
"evil.png": "x",
|
|
299
|
+
[`${VISUAL_QA_DIR_NAME}/desktop-1440x900.png`]: "x",
|
|
300
|
+
});
|
|
301
|
+
try {
|
|
302
|
+
const base = parsedVerified();
|
|
303
|
+
for (const evil of ["../evil.png", `${VISUAL_QA_DIR_NAME}/../../evil.png`, "/tmp/evil.png"]) {
|
|
304
|
+
const v = validateVisualQaReceipt({ ...base, screenshots: [`${VISUAL_QA_DIR_NAME}/desktop-1440x900.png`, evil] }, { expectedHeadSha: HEAD_A, worktreePath: dir });
|
|
305
|
+
assert.equal(v.ok, false, evil);
|
|
306
|
+
assert.match(v.reason, /absolute screenshot path|escapes the visual-artifact directory/);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
test("NOT-381: non-regular, empty, and wrong-extension screenshots fail loudly", () => {
|
|
314
|
+
const dir = writeWorktree({ [`${VISUAL_QA_DIR_NAME}/desktop-1440x900.png`]: "x" });
|
|
315
|
+
fs.mkdirSync(path.join(dir, VISUAL_QA_DIR_NAME, "mobile-390x800.png"));
|
|
316
|
+
try {
|
|
317
|
+
const base = parsedVerified();
|
|
318
|
+
const vDir = validateVisualQaReceipt(base, { expectedHeadSha: HEAD_A, worktreePath: dir });
|
|
319
|
+
assert.equal(vDir.ok, false);
|
|
320
|
+
assert.match(vDir.reason, /not a regular file/);
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
324
|
+
}
|
|
325
|
+
const empty = writeWorktree({
|
|
326
|
+
[`${VISUAL_QA_DIR_NAME}/desktop-1440x900.png`]: "x",
|
|
327
|
+
[`${VISUAL_QA_DIR_NAME}/mobile-390x800.png`]: "",
|
|
328
|
+
});
|
|
329
|
+
try {
|
|
330
|
+
const v = validateVisualQaReceipt(parsedVerified(), { expectedHeadSha: HEAD_A, worktreePath: empty });
|
|
331
|
+
assert.equal(v.ok, false);
|
|
332
|
+
assert.match(v.reason, /is empty/);
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
fs.rmSync(empty, { recursive: true, force: true });
|
|
336
|
+
}
|
|
337
|
+
const wrongExt = writeWorktree({
|
|
338
|
+
[`${VISUAL_QA_DIR_NAME}/desktop-1440x900.png`]: "x",
|
|
339
|
+
[`${VISUAL_QA_DIR_NAME}/notes.txt`]: "x",
|
|
340
|
+
});
|
|
341
|
+
try {
|
|
342
|
+
const v = validateVisualQaReceipt({ ...parsedVerified(), screenshots: [`${VISUAL_QA_DIR_NAME}/desktop-1440x900.png`, `${VISUAL_QA_DIR_NAME}/notes.txt`] }, { expectedHeadSha: HEAD_A, worktreePath: wrongExt });
|
|
343
|
+
assert.equal(v.ok, false);
|
|
344
|
+
assert.match(v.reason, /unsupported extension/);
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
fs.rmSync(wrongExt, { recursive: true, force: true });
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
// Validation: unavailable / not_required.
|
|
351
|
+
test("NOT-381: unavailable requires one concrete failed capability", () => {
|
|
352
|
+
const ok = validateVisualQaReceipt({ ...parsedVerified(), status: "unavailable", capability: "browser launch: no chromium" }, { expectedHeadSha: HEAD_A, worktreePath: "/none" });
|
|
353
|
+
assert.deepEqual(ok, { ok: true });
|
|
354
|
+
// No screenshots needed, no SHA binding enforced — the capability is the record.
|
|
355
|
+
const minimal = validateVisualQaReceipt({
|
|
356
|
+
status: "unavailable",
|
|
357
|
+
headSha: null,
|
|
358
|
+
headRaw: null,
|
|
359
|
+
realApp: null,
|
|
360
|
+
scenario: null,
|
|
361
|
+
viewports: [],
|
|
362
|
+
viewportsRaw: null,
|
|
363
|
+
commands: null,
|
|
364
|
+
screenshots: [],
|
|
365
|
+
capability: "loopback listen: EPERM",
|
|
366
|
+
note: null,
|
|
367
|
+
}, { expectedHeadSha: HEAD_A, worktreePath: "/none" });
|
|
368
|
+
assert.deepEqual(minimal, { ok: true });
|
|
369
|
+
});
|
|
370
|
+
test("NOT-381: not_required validates with no screenshots and no other fields", () => {
|
|
371
|
+
const v = validateVisualQaReceipt({
|
|
372
|
+
status: "not_required",
|
|
373
|
+
headSha: null,
|
|
374
|
+
headRaw: null,
|
|
375
|
+
realApp: null,
|
|
376
|
+
scenario: null,
|
|
377
|
+
viewports: [],
|
|
378
|
+
viewportsRaw: null,
|
|
379
|
+
commands: null,
|
|
380
|
+
screenshots: [],
|
|
381
|
+
capability: null,
|
|
382
|
+
note: null,
|
|
383
|
+
}, { expectedHeadSha: HEAD_A, worktreePath: "/none" });
|
|
384
|
+
assert.deepEqual(v, { ok: true });
|
|
385
|
+
});
|
|
386
|
+
// Reviewer section.
|
|
387
|
+
function receiptRecord(contentOver = {}) {
|
|
388
|
+
return {
|
|
389
|
+
kind: "receipt",
|
|
390
|
+
createdAt: "2026-10-08T00:00:00.000Z",
|
|
391
|
+
content: {
|
|
392
|
+
status: "verified",
|
|
393
|
+
headSha: HEAD_A,
|
|
394
|
+
realApp: "real (no mocks)",
|
|
395
|
+
scenario: "/widgets/new — fill the form and submit",
|
|
396
|
+
viewports: ["1440x900", "390x800"],
|
|
397
|
+
commands: "npm run dev; capture",
|
|
398
|
+
screenshots: [
|
|
399
|
+
{ fileName: "0-desktop.png", blobPath: "/blobs/0-desktop.png", sizeBytes: 1234, sha256: "ab".repeat(32) },
|
|
400
|
+
],
|
|
401
|
+
capability: null,
|
|
402
|
+
note: null,
|
|
403
|
+
recordedAt: "2026-10-08T00:00:00.000Z",
|
|
404
|
+
...contentOver,
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
test("NOT-381: no record renders nothing", () => {
|
|
409
|
+
assert.deepEqual(visualQaReviewerSection(null, HEAD_A), []);
|
|
410
|
+
});
|
|
411
|
+
test("NOT-381: verified receipt bound to the pinned head renders the full evidence block", () => {
|
|
412
|
+
const text = visualQaReviewerSection(receiptRecord(), HEAD_A).join("\n");
|
|
413
|
+
assert.match(text, /## Visual QA \(developer receipt — SHA-bound to this head\)/);
|
|
414
|
+
assert.match(text, new RegExp(HEAD_A));
|
|
415
|
+
assert.match(text, /real \(no mocks\)/);
|
|
416
|
+
assert.match(text, /\/widgets\/new/);
|
|
417
|
+
assert.match(text, /1440x900, 390x800/);
|
|
418
|
+
assert.match(text, /0-desktop\.png/);
|
|
419
|
+
assert.match(text, /not as your own verification/);
|
|
420
|
+
});
|
|
421
|
+
test("NOT-381: verified receipt bound to another head is stale, never verification", () => {
|
|
422
|
+
const text = visualQaReviewerSection(receiptRecord(), HEAD_B).join("\n");
|
|
423
|
+
assert.match(text, /## Visual QA \(stale receipt — not this head\)/);
|
|
424
|
+
assert.match(text, /do not treat this as verification of the pinned head/);
|
|
425
|
+
assert.doesNotMatch(text, /SHA-bound to this head/);
|
|
426
|
+
});
|
|
427
|
+
test("NOT-381: unavailable receipt names the failed capability and is never a pass", () => {
|
|
428
|
+
const record = receiptRecord({
|
|
429
|
+
status: "unavailable",
|
|
430
|
+
headSha: HEAD_A,
|
|
431
|
+
realApp: null,
|
|
432
|
+
scenario: null,
|
|
433
|
+
viewports: [],
|
|
434
|
+
commands: null,
|
|
435
|
+
screenshots: [],
|
|
436
|
+
capability: "browser launch: chromium binary not found",
|
|
437
|
+
note: null,
|
|
438
|
+
});
|
|
439
|
+
const text = visualQaReviewerSection(record, HEAD_A).join("\n");
|
|
440
|
+
assert.match(text, /## Visual QA \(UNAVAILABLE — not verified\)/);
|
|
441
|
+
assert.match(text, /browser launch: chromium binary not found/);
|
|
442
|
+
assert.match(text, /never a pass/);
|
|
443
|
+
assert.doesNotMatch(text, /verified at/);
|
|
444
|
+
});
|
|
445
|
+
test("NOT-381: not_required renders the no-surface claim with the reviewer override", () => {
|
|
446
|
+
const record = receiptRecord({
|
|
447
|
+
status: "not_required",
|
|
448
|
+
headSha: null,
|
|
449
|
+
realApp: null,
|
|
450
|
+
scenario: null,
|
|
451
|
+
viewports: [],
|
|
452
|
+
commands: null,
|
|
453
|
+
screenshots: [],
|
|
454
|
+
capability: null,
|
|
455
|
+
note: null,
|
|
456
|
+
});
|
|
457
|
+
const text = visualQaReviewerSection(record, HEAD_A).join("\n");
|
|
458
|
+
assert.match(text, /## Visual QA \(not required/);
|
|
459
|
+
assert.match(text, /blocking finding/);
|
|
460
|
+
});
|
|
461
|
+
test("NOT-381: rejection bound to the pinned head fails loudly in the reviewer input", () => {
|
|
462
|
+
const record = {
|
|
463
|
+
kind: "rejected",
|
|
464
|
+
createdAt: "2026-10-08T00:00:00.000Z",
|
|
465
|
+
content: {
|
|
466
|
+
status: "verified",
|
|
467
|
+
headSha: HEAD_B,
|
|
468
|
+
expectedHeadSha: HEAD_A,
|
|
469
|
+
reason: `verified Visual QA receipt is bound to ${HEAD_B.slice(0, 8)} but the developer handoff is ${HEAD_A.slice(0, 8)}`,
|
|
470
|
+
recordedAt: "2026-10-08T00:00:00.000Z",
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
const text = visualQaReviewerSection(record, HEAD_A).join("\n");
|
|
474
|
+
assert.match(text, /## Visual QA \(INVALID receipt/);
|
|
475
|
+
assert.match(text, /bound to bbbbbbbb but the developer handoff is aaaaaaaa/);
|
|
476
|
+
assert.match(text, /never verification/);
|
|
477
|
+
});
|
|
478
|
+
test("NOT-381: rejection for another head is superseded, not shown as this head's verdict", () => {
|
|
479
|
+
const record = {
|
|
480
|
+
kind: "rejected",
|
|
481
|
+
createdAt: "2026-10-08T00:00:00.000Z",
|
|
482
|
+
content: {
|
|
483
|
+
status: "verified",
|
|
484
|
+
headSha: HEAD_B,
|
|
485
|
+
expectedHeadSha: HEAD_B,
|
|
486
|
+
reason: "screenshot is missing",
|
|
487
|
+
recordedAt: "2026-10-08T00:00:00.000Z",
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
const text = visualQaReviewerSection(record, HEAD_A).join("\n");
|
|
491
|
+
assert.match(text, /superseded rejection — not this head/);
|
|
492
|
+
assert.doesNotMatch(text, /INVALID receipt/);
|
|
493
|
+
});
|
|
@@ -47,6 +47,30 @@ export function getSourceAttachmentsDir() {
|
|
|
47
47
|
fs.mkdirSync(dir, { recursive: true });
|
|
48
48
|
return dir;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* NOT-384: Dealer-owned bytes for CI visual evidence (downloaded `ui-screenshots` /
|
|
52
|
+
* `ui-baseline` / `ui-diff` artifacts). Content lives under
|
|
53
|
+
* `<dir>/<issueId>/<headSha>/`; `visual_evidence` issue-artifact rows point
|
|
54
|
+
* here via `blobPath`. Retained with the issue history like source-attachment
|
|
55
|
+
* blobs — reviewer-worktree copies are removed with normal worktree cleanup.
|
|
56
|
+
*/
|
|
57
|
+
export function getVisualEvidenceDir() {
|
|
58
|
+
const dir = path.join(getDataDir(), "visual-evidence");
|
|
59
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
60
|
+
return dir;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* NOT-381: Dealer-owned blobs for collected developer visual-QA screenshots.
|
|
64
|
+
* Content is addressed under `<dir>/<issueId>/`; `visual_qa_receipt` artifact
|
|
65
|
+
* rows point here. Retained with the issue history like source attachments —
|
|
66
|
+
* the worktree's transient `.agent-dealer-visual-qa/` directory is removed on
|
|
67
|
+
* collection, but these blobs survive until the issue row itself is gone.
|
|
68
|
+
*/
|
|
69
|
+
export function getVisualQaDir() {
|
|
70
|
+
const dir = path.join(getDataDir(), "visual-qa");
|
|
71
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
72
|
+
return dir;
|
|
73
|
+
}
|
|
50
74
|
/**
|
|
51
75
|
* Root for Dealer-managed repository clones and session worktrees (NOT-149).
|
|
52
76
|
* Defaults under AGENT_DEALER_HOME; override with AGENT_DEALER_EXECUTION_ROOT for a
|
|
@@ -137,6 +137,7 @@ export function listIssues(status) {
|
|
|
137
137
|
/** NOT-228: page-size bounds for the Issues list (matches the report default/max). */
|
|
138
138
|
export const ISSUES_LIST_DEFAULT_LIMIT = 25;
|
|
139
139
|
export const ISSUES_LIST_MAX_LIMIT = 100;
|
|
140
|
+
export const DEFAULT_ISSUES_LIST_DIRECTION = "desc";
|
|
140
141
|
/** Escape the LIKE metacharacters in a user query so `%`/`_` match literally. */
|
|
141
142
|
function escapeLikePattern(raw) {
|
|
142
143
|
return raw.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
@@ -146,6 +147,9 @@ function escapeLikePattern(raw) {
|
|
|
146
147
|
* Ordering is `updated_at DESC, rowid DESC` so pages are repeatable when many
|
|
147
148
|
* rows share a timestamp; the filter applies before pagination so `total` and
|
|
148
149
|
* `totalPages` describe the full matching cohort.
|
|
150
|
+
* NOT-385: `direction: "asc"` reverses both to `updated_at ASC, rowid ASC` —
|
|
151
|
+
* the tie-breaker always follows the timestamp direction so equal-timestamp
|
|
152
|
+
* rows paginate without duplicates or skips in either direction.
|
|
149
153
|
*/
|
|
150
154
|
export function queryIssues(query = {}) {
|
|
151
155
|
const db = getDb();
|
|
@@ -183,8 +187,11 @@ export function queryIssues(query = {}) {
|
|
|
183
187
|
.get(...params);
|
|
184
188
|
const totalPages = total === 0 ? 0 : Math.ceil(total / limit);
|
|
185
189
|
const offset = (page - 1) * limit;
|
|
190
|
+
// NOT-385: the direction is a ternary over literals, never interpolated —
|
|
191
|
+
// anything but an explicit "asc" orders newest-first.
|
|
192
|
+
const dir = query.direction === "asc" ? "ASC" : "DESC";
|
|
186
193
|
const rows = db
|
|
187
|
-
.prepare(`SELECT * FROM issues ${where} ORDER BY updated_at
|
|
194
|
+
.prepare(`SELECT * FROM issues ${where} ORDER BY updated_at ${dir}, rowid ${dir} LIMIT ? OFFSET ?`)
|
|
188
195
|
.all(...params, limit, offset);
|
|
189
196
|
return { rows: rows.map(rowToIssue), page, limit, total, totalPages };
|
|
190
197
|
}
|
|
@@ -139,6 +139,40 @@ test("NOT-228: page/limit bounds clamp instead of erroring", () => {
|
|
|
139
139
|
assert.equal(empty.total, 0);
|
|
140
140
|
assert.equal(empty.totalPages, 0);
|
|
141
141
|
});
|
|
142
|
+
test("NOT-385: the default direction is newest-first by updated_at", () => {
|
|
143
|
+
const oldId = createIssue(makeInput("NOT385 default old")).id;
|
|
144
|
+
const newId = createIssue(makeInput("NOT385 default new")).id;
|
|
145
|
+
getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run("2026-08-01T00:00:00.000Z", oldId);
|
|
146
|
+
getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run("2026-09-01T00:00:00.000Z", newId);
|
|
147
|
+
for (const query of [{ search: "NOT385 default" }, { search: "NOT385 default", direction: "desc" }]) {
|
|
148
|
+
const res = queryIssues(query);
|
|
149
|
+
assert.deepEqual(res.rows.map((i) => i.id), [newId, oldId], `newest updated_at first (${JSON.stringify(query)})`);
|
|
150
|
+
}
|
|
151
|
+
// Anything but an explicit "asc" falls back to newest-first.
|
|
152
|
+
const fallback = queryIssues({ search: "NOT385 default", direction: "sideways" });
|
|
153
|
+
assert.deepEqual(fallback.rows.map((i) => i.id), [newId, oldId]);
|
|
154
|
+
});
|
|
155
|
+
test("NOT-385: ascending reverses to oldest-first with an ascending rowid tiebreaker", () => {
|
|
156
|
+
const stamp = "2026-09-01T00:00:00.000Z";
|
|
157
|
+
const ids = ["NOT385 tie one", "NOT385 tie two", "NOT385 tie three"].map((t) => createIssue(makeInput(t)).id);
|
|
158
|
+
for (const id of ids) {
|
|
159
|
+
getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run(stamp, id);
|
|
160
|
+
}
|
|
161
|
+
const first = queryIssues({ search: "NOT385 tie", direction: "asc", limit: 2, page: 1 });
|
|
162
|
+
const second = queryIssues({ search: "NOT385 tie", direction: "asc", limit: 2, page: 2 });
|
|
163
|
+
assert.equal(first.total, 3);
|
|
164
|
+
assert.equal(first.totalPages, 2);
|
|
165
|
+
// Oldest rowid first: creation order was one, two, three.
|
|
166
|
+
assert.deepEqual(first.rows.map((i) => i.id), [ids[0], ids[1]]);
|
|
167
|
+
assert.deepEqual(second.rows.map((i) => i.id), [ids[2]]);
|
|
168
|
+
const again = queryIssues({ search: "NOT385 tie", direction: "asc", limit: 2, page: 1 });
|
|
169
|
+
assert.deepEqual(again.rows.map((i) => i.id), first.rows.map((i) => i.id), "repeatable");
|
|
170
|
+
assert.deepEqual(new Set([...first.rows, ...second.rows].map((i) => i.id)).size, 3, "pages neither duplicate nor skip rows");
|
|
171
|
+
// The reverse is the exact mirror of the default across the same cohort.
|
|
172
|
+
const descAll = queryIssues({ search: "NOT385 tie", limit: 10 });
|
|
173
|
+
const ascAll = queryIssues({ search: "NOT385 tie", direction: "asc", limit: 10 });
|
|
174
|
+
assert.deepEqual(ascAll.rows.map((i) => i.id), [...descAll.rows.map((i) => i.id)].reverse());
|
|
175
|
+
});
|
|
142
176
|
test("NOT-228: needsAttention returns only issues with an open human action", () => {
|
|
143
177
|
const flagged = createIssue(makeInput("NOT228 flagged attention"));
|
|
144
178
|
const quiet = createIssue(makeInput("NOT228 quiet no-action"));
|