bmad-plus 0.17.1 → 0.19.0
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/CHANGELOG.md +30 -1
- package/README.md +16 -18
- package/package.json +3 -1
- package/readme-international/README.de.md +16 -19
- package/readme-international/README.es.md +16 -19
- package/readme-international/README.fr.md +16 -19
- package/src/bmad-plus/agents/agent-quality/SKILL.md +3 -2
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review-steps/step-03-triage.md +15 -0
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +25 -4
- package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +61 -1
- package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-results.schema.json +11 -0
- package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +455 -137
- package/src/bmad-plus/skills/bmad-plus-uat/template/strings.json +230 -20
- package/tools/cli/bmad-plus-cli.js +1 -0
- package/tools/cli/commands/review.js +152 -0
- package/tools/cli/commands/uat.js +50 -5
- package/tools/cli/commands/update-check.js +5 -1
- package/tools/cli/commands/update.js +9 -3
- package/tools/cli/lib/install-manifest.js +19 -1
- package/tools/cli/lib/packs.js +1 -1
- package/tools/cli/lib/review.js +522 -0
- package/tools/cli/lib/uat.js +140 -2
|
@@ -30,15 +30,13 @@
|
|
|
30
30
|
--mono-bg: #24282c; --focus: #4fb3b8;
|
|
31
31
|
}
|
|
32
32
|
* { box-sizing: border-box; }
|
|
33
|
-
/* A class that sets display wins over the hidden attribute: without this rule the steps
|
|
34
|
-
showed before any run existed, and a tester's answers were silently discarded. */
|
|
35
|
-
[hidden] { display: none !important; }
|
|
36
33
|
code.cmd { cursor: copy; }
|
|
37
34
|
code.cmd.copied::after { content: " \2713 " attr(data-copied); font-family: var(--sans); color: var(--ok); }
|
|
38
35
|
body { background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 15px; line-height: 1.5; margin: 0; padding-inline: 16px; padding-block: 0 64px; }
|
|
39
36
|
h1, h2, h3 { font-family: var(--serif); text-wrap: balance; margin: 0; }
|
|
40
37
|
h1 { font-size: 1.7rem; font-weight: 600; }
|
|
41
38
|
h2 { font-size: 1.2rem; font-weight: 600; }
|
|
39
|
+
h2:focus-visible { outline: 2px solid var(--focus); outline-offset: 4px; }
|
|
42
40
|
p { margin: 0; }
|
|
43
41
|
a { color: var(--accent); }
|
|
44
42
|
code, .ecran { font-family: var(--mono); font-size: 0.88em; background: var(--mono-bg); padding: 1px 6px; border-radius: 3px; }
|
|
@@ -46,9 +44,9 @@
|
|
|
46
44
|
.eyebrow { font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); font-weight: 600; }
|
|
47
45
|
.bar { position: sticky; top: 0; z-index: 5; background: var(--bg); border-bottom: 1px solid var(--line); padding-block: 10px; margin-inline: -16px; padding-inline: 16px; }
|
|
48
46
|
.bar-in { max-width: 860px; margin: 0 auto; display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; }
|
|
49
|
-
.count { display: flex; gap: 14px; font-variant-numeric: tabular-nums; font-size: 0.9rem; }
|
|
47
|
+
.count { display: flex; flex-wrap: wrap; gap: 14px; font-variant-numeric: tabular-nums; font-size: 0.9rem; }
|
|
50
48
|
.count b { font-weight: 600; }
|
|
51
|
-
.count .ok { color: var(--ok); } .count .ko { color: var(--ko); } .count .warn { color: var(--warn); } .count .todo { color: var(--muted); }
|
|
49
|
+
.count .ok { color: var(--ok); } .count .ko { color: var(--ko); } .count .warn { color: var(--warn); } .count .todo { color: var(--muted); } .count .pct { color: var(--ink); }
|
|
52
50
|
/* One segment per step: green seen, red not seen, orange blocked, grey skipped, empty to do. */
|
|
53
51
|
.strip { flex: 1 1 100%; display: flex; gap: 3px; min-height: 12px; }
|
|
54
52
|
.strip a { flex: 1 1 0; min-width: 10px; height: 12px; border-radius: 3px; background: var(--line); border: 1px solid transparent; }
|
|
@@ -58,8 +56,14 @@
|
|
|
58
56
|
.strip a[data-state="blocked"] { background: var(--warn); }
|
|
59
57
|
.strip a[data-state="skipped"] { background: var(--line-strong); }
|
|
60
58
|
.strip a[data-state="partial"] { background: linear-gradient(90deg, var(--ok) 50%, var(--line) 50%); }
|
|
59
|
+
[dir="rtl"] .strip a[data-state="partial"] { background: linear-gradient(270deg, var(--ok) 50%, var(--line) 50%); }
|
|
60
|
+
/* Share of lines answered — answered, not passed: a "not seen" advances the run as much as a "seen". */
|
|
61
|
+
.meter { flex: 1 1 100%; height: 5px; background: var(--line); border-radius: 3px; overflow: hidden; }
|
|
62
|
+
.meter i { display: block; height: 100%; width: 0; background: var(--accent); }
|
|
61
63
|
.state { font-size: 0.82rem; color: var(--muted); }
|
|
62
64
|
.state.saved { color: var(--ok); }
|
|
65
|
+
.state.failed { color: var(--ko); font-weight: 600; }
|
|
66
|
+
.run-note { flex: 1 1 100%; font-size: 0.82rem; color: var(--warn); }
|
|
63
67
|
.head { padding-block: 26px 18px; display: grid; gap: 10px; }
|
|
64
68
|
.head p { max-width: 65ch; }
|
|
65
69
|
.lang { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; color: var(--muted); }
|
|
@@ -75,10 +79,12 @@
|
|
|
75
79
|
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
|
76
80
|
.resume { display: grid; gap: 6px; }
|
|
77
81
|
.resume button { text-align: start; }
|
|
82
|
+
.resume .row { gap: 8px; }
|
|
78
83
|
.warn-box { border-inline-start: 3px solid var(--warn); background: var(--warn-soft); padding: 10px 12px; border-radius: 4px; }
|
|
79
84
|
.info-box { border-inline-start: 3px solid var(--accent); background: var(--accent-soft); padding: 10px 12px; border-radius: 4px; }
|
|
85
|
+
.warn-box .btn { margin-inline-start: 8px; }
|
|
80
86
|
.steps { display: grid; gap: 18px; }
|
|
81
|
-
.step { background: var(--card); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
|
|
87
|
+
.step { background: var(--card); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; scroll-margin-top: 150px; }
|
|
82
88
|
.step[data-state="passed"] { border-color: var(--ok); }
|
|
83
89
|
.step[data-state="failed"] { border-color: var(--ko); }
|
|
84
90
|
.step[data-state="blocked"] { border-color: var(--warn); }
|
|
@@ -114,8 +120,14 @@
|
|
|
114
120
|
.end { background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 16px; display: grid; gap: 12px; margin-top: 22px; }
|
|
115
121
|
.json { font-family: var(--mono); font-size: 0.8rem; min-height: 180px; white-space: pre; overflow: auto; direction: ltr; text-align: start; }
|
|
116
122
|
details > summary { cursor: pointer; font-weight: 600; }
|
|
117
|
-
@media (max-width: 480px) { .expect { grid-template-columns: 1fr; } .num { font-size: 1.25rem; } }
|
|
123
|
+
@media (max-width: 480px) { .expect { grid-template-columns: 1fr; } .num { font-size: 1.25rem; } .step { scroll-margin-top: 190px; } }
|
|
118
124
|
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
|
125
|
+
/* A class that sets display wins over the hidden attribute: without this rule the steps
|
|
126
|
+
showed before any run existed, and a tester's answers were silently discarded. It is
|
|
127
|
+
!important AND the last rule of the sheet, so it wins under a real cascade and under a
|
|
128
|
+
source-order one (jsdom resolves display by the last matching rule). The build refuses
|
|
129
|
+
a template where anything follows it. */
|
|
130
|
+
[hidden] { display: none !important; }
|
|
119
131
|
</style>
|
|
120
132
|
</head>
|
|
121
133
|
<body>
|
|
@@ -127,9 +139,12 @@
|
|
|
127
139
|
<span class="ko"><b id="c-failed">0</b> <span id="l-failed"></span></span>
|
|
128
140
|
<span class="warn"><b id="c-blocked">0</b> <span id="l-blocked"></span></span>
|
|
129
141
|
<span class="todo"><b id="c-todo">0</b> <span id="l-todo"></span></span>
|
|
142
|
+
<span class="pct"><b id="c-pct">0 %</b> <span id="l-pct"></span></span>
|
|
130
143
|
</div>
|
|
131
144
|
<nav class="strip" id="strip"></nav>
|
|
132
|
-
<
|
|
145
|
+
<div class="meter" id="meter" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><i id="meter-fill"></i></div>
|
|
146
|
+
<span class="state" id="save-state" role="status" aria-live="polite">—</span>
|
|
147
|
+
<span class="run-note" id="run-note" role="status" hidden></span>
|
|
133
148
|
</div>
|
|
134
149
|
</div>
|
|
135
150
|
|
|
@@ -141,12 +156,14 @@
|
|
|
141
156
|
<label for="tester"><span id="t-tester"></span><input type="text" id="tester" autocomplete="name"></label>
|
|
142
157
|
<div class="row">
|
|
143
158
|
<button class="btn primary" id="btn-start" type="button"></button>
|
|
144
|
-
<span class="state" id="start-state"></span>
|
|
159
|
+
<span class="state" id="start-state" role="status"></span>
|
|
145
160
|
</div>
|
|
161
|
+
<p class="warn-box" id="draft-problem" hidden><span id="t-draft-problem"></span><button class="btn" id="btn-save-raw" type="button"></button></p>
|
|
146
162
|
<div class="resume" id="resume" hidden>
|
|
147
163
|
<span class="eyebrow" id="t-resume"></span>
|
|
148
164
|
<div id="resume-list" class="resume"></div>
|
|
149
165
|
</div>
|
|
166
|
+
<p class="info-box" id="storage-note"></p>
|
|
150
167
|
</section>
|
|
151
168
|
<main class="steps" id="steps" hidden></main>
|
|
152
169
|
<section class="end" id="end" hidden>
|
|
@@ -157,7 +174,8 @@
|
|
|
157
174
|
<button class="btn primary" id="btn-finish" type="button" disabled></button>
|
|
158
175
|
<button class="btn" id="btn-save" type="button" disabled></button>
|
|
159
176
|
<button class="btn" id="btn-copy" type="button" disabled></button>
|
|
160
|
-
<
|
|
177
|
+
<button class="btn" id="btn-another" type="button" disabled></button>
|
|
178
|
+
<span class="state" id="end-state" role="status"></span>
|
|
161
179
|
</div>
|
|
162
180
|
<p class="info-box" id="mode-note"></p>
|
|
163
181
|
<details><summary>JSON</summary><textarea class="json" id="json" readonly aria-label="JSON"></textarea></details>
|
|
@@ -172,22 +190,36 @@
|
|
|
172
190
|
var SPEC = JSON.parse(document.getElementById("uat-spec").textContent);
|
|
173
191
|
var ALL = JSON.parse(document.getElementById("uat-strings").textContent);
|
|
174
192
|
var SPEC_SHA = "__SPEC_SHA__";
|
|
193
|
+
// The language the page was built for: the recipe's own, else the project's, else English.
|
|
194
|
+
var BUILT_LANG = document.documentElement.getAttribute("lang") || "";
|
|
175
195
|
var RESULTS_SCHEMA = "bmad-plus/uat-results/2";
|
|
196
|
+
var STATES = ["passed", "failed", "blocked", "skipped"];
|
|
176
197
|
var STEPS = SPEC.steps;
|
|
177
198
|
var KEY = "uat-" + SPEC.id;
|
|
199
|
+
var LARGE_BODY = 60000; // a keepalive request is refused above 64 KiB: never leave such a body pending
|
|
178
200
|
|
|
179
201
|
var run = null, runId = null, T = null, lang = null;
|
|
180
202
|
var db = null, dbDoc = null, unsub = null, downloads = null, serve = null;
|
|
181
|
-
var timer = null, lastWritten = "";
|
|
203
|
+
var timer = null, pending = null, lastWritten = "", flushSeq = 0, listSeq = 0;
|
|
204
|
+
var localOk = true, remoteState = null, remoteAt = null, unreadable = null;
|
|
205
|
+
var runNote = null, confirmingFinish = false;
|
|
182
206
|
var $ = function (id) { return document.getElementById(id); };
|
|
207
|
+
var esc = function (value) { return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); };
|
|
183
208
|
var lsGet = function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } };
|
|
184
|
-
|
|
209
|
+
// A write is a write only once it reads back: a refused write (quota, private window,
|
|
210
|
+
// blocked storage) returns false, and the page never says "saved" on its own word.
|
|
211
|
+
var lsSet = function (k, v) { try { localStorage.setItem(k, v); return localStorage.getItem(k) === v; } catch (e) { return false; } };
|
|
212
|
+
var lsKeys = function () { var keys = []; try { for (var i = 0; i < localStorage.length; i++) keys.push(localStorage.key(i)); } catch (e) { /* storage unreadable: nothing to list */ } return keys; };
|
|
213
|
+
/** When a document was last saved, as a number; a missing or unreadable date is older than anything. */
|
|
214
|
+
function at(doc) { var t = Date.parse(doc && doc.updatedAt); return isNaN(t) ? -Infinity : t; }
|
|
215
|
+
function setText(el, text) { if (el.textContent !== text) el.textContent = text; }
|
|
185
216
|
|
|
186
217
|
// ── Language: the recipe's own, the tester's browser, or their choice ───
|
|
187
218
|
function pickLanguage() {
|
|
188
219
|
var saved = lsGet(KEY + "-lang");
|
|
189
220
|
if (saved && ALL[saved]) return saved;
|
|
190
221
|
if (SPEC.language && ALL[SPEC.language]) return SPEC.language;
|
|
222
|
+
if (BUILT_LANG && ALL[BUILT_LANG]) return BUILT_LANG;
|
|
191
223
|
var tags = (navigator.languages || [navigator.language || ""]).map(String);
|
|
192
224
|
for (var i = 0; i < tags.length; i++) {
|
|
193
225
|
var tag = tags[i];
|
|
@@ -210,6 +242,20 @@
|
|
|
210
242
|
});
|
|
211
243
|
}
|
|
212
244
|
|
|
245
|
+
function modeText() { return db ? T.dbMode : serve ? T.serveMode : T.localMode; }
|
|
246
|
+
function noteText() {
|
|
247
|
+
if (!runNote) return "";
|
|
248
|
+
if (runNote.kind === "carried") return T.carried.replace("{kept}", String(runNote.kept)).replace("{left}", String(runNote.left));
|
|
249
|
+
if (runNote.kind === "otherTab") return T.otherTab;
|
|
250
|
+
if (runNote.kind === "changedAfterFinish") return T.changedAfterFinish;
|
|
251
|
+
return "";
|
|
252
|
+
}
|
|
253
|
+
function showNote(note) {
|
|
254
|
+
runNote = note;
|
|
255
|
+
$("run-note").hidden = !note;
|
|
256
|
+
setText($("run-note"), noteText());
|
|
257
|
+
}
|
|
258
|
+
|
|
213
259
|
function applyLanguage(code) {
|
|
214
260
|
lang = ALL[code] ? code : "en";
|
|
215
261
|
T = ALL[lang];
|
|
@@ -223,18 +269,25 @@
|
|
|
223
269
|
$("t-resume").textContent = T.resume;
|
|
224
270
|
$("t-finish").textContent = T.finish;
|
|
225
271
|
$("t-overall").textContent = T.overall;
|
|
226
|
-
$("btn-finish").textContent = run && run.finishedAt ? T.finished : T.finishRun;
|
|
272
|
+
$("btn-finish").textContent = run && run.finishedAt ? T.finished : confirmingFinish ? T.finishAnyway : T.finishRun;
|
|
227
273
|
$("btn-save").textContent = T.saveJson;
|
|
228
274
|
$("btn-copy").textContent = T.copyJson;
|
|
275
|
+
$("btn-another").textContent = T.another;
|
|
276
|
+
$("btn-save-raw").textContent = T.saveRaw;
|
|
277
|
+
$("t-draft-problem").textContent = T.draftUnreadable + " ";
|
|
229
278
|
$("l-passed").textContent = T.countSeen;
|
|
230
279
|
$("l-failed").textContent = T.countNotSeen;
|
|
231
280
|
$("l-blocked").textContent = T.countBlocked;
|
|
232
281
|
$("l-todo").textContent = T.todo;
|
|
233
|
-
$("
|
|
282
|
+
$("l-pct").textContent = T.countAnswered;
|
|
283
|
+
$("meter").setAttribute("aria-label", T.progressLabel);
|
|
284
|
+
$("mode-note").textContent = modeText();
|
|
285
|
+
$("storage-note").textContent = modeText();
|
|
286
|
+
setText($("run-note"), noteText());
|
|
234
287
|
renderHead();
|
|
235
288
|
renderSteps();
|
|
236
289
|
renderStrip();
|
|
237
|
-
if (run) reflect();
|
|
290
|
+
if (run) { reflect(); showSaveState(); }
|
|
238
291
|
listResumable();
|
|
239
292
|
}
|
|
240
293
|
|
|
@@ -273,10 +326,20 @@
|
|
|
273
326
|
summary: { passed: 0, failed: 0, blocked: 0, skipped: 0, unanswered: 0 },
|
|
274
327
|
};
|
|
275
328
|
}
|
|
276
|
-
|
|
329
|
+
// The combining-mark range is written as escapes: a raw range survives only as long as every
|
|
330
|
+
// hop (editor, transfer, server charset) keeps the bytes intact, and one hop did not.
|
|
331
|
+
function slug(s) {
|
|
332
|
+
var latin = String(s).normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase().slice(0, 24);
|
|
333
|
+
if (latin) return latin;
|
|
334
|
+
// A name with no Latin letter (Hebrew, Cyrillic, CJK…) still signs its run: a stable hash, not "anonymous".
|
|
335
|
+
var h = 2166136261;
|
|
336
|
+
for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }
|
|
337
|
+
return "t" + h.toString(36).slice(0, 7);
|
|
338
|
+
}
|
|
277
339
|
function newRunId(tester) {
|
|
278
340
|
var d = new Date(), p = function (n) { return String(n).padStart(2, "0"); };
|
|
279
|
-
|
|
341
|
+
// UTC, like every date the run carries: the file name and "finished on" name the same day.
|
|
342
|
+
return d.getUTCFullYear() + p(d.getUTCMonth() + 1) + p(d.getUTCDate()) + "-" + slug(tester) + "-" + (Math.random().toString(36) + "0000").slice(2, 6);
|
|
280
343
|
}
|
|
281
344
|
function stepState(step) {
|
|
282
345
|
var values = Object.keys(step.expect).map(function (k) { return step.expect[k].state; });
|
|
@@ -293,11 +356,40 @@
|
|
|
293
356
|
step.state = stepState(step);
|
|
294
357
|
Object.keys(step.expect).forEach(function (k) {
|
|
295
358
|
var v = step.expect[k].state;
|
|
296
|
-
if (v === null || v === undefined) s.unanswered++; else s[v]++;
|
|
359
|
+
if (v === null || v === undefined) s.unanswered++; else if (STATES.indexOf(v) >= 0) s[v]++; else s.unanswered++;
|
|
297
360
|
});
|
|
298
361
|
});
|
|
299
362
|
run.summary = s;
|
|
300
363
|
}
|
|
364
|
+
function answeredOf(doc) {
|
|
365
|
+
var total = 0, answered = 0;
|
|
366
|
+
Object.keys(doc.steps).forEach(function (id) {
|
|
367
|
+
Object.keys(doc.steps[id].expect).forEach(function (k) { total++; if (STATES.indexOf(doc.steps[id].expect[k].state) >= 0) answered++; });
|
|
368
|
+
});
|
|
369
|
+
return { answered: answered, total: total };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── Drafts: what this browser (or the artifact database) holds for this recipe ──
|
|
373
|
+
/** A usable document for THIS recipe, as a private copy, whatever revision it answered; anything else is null. */
|
|
374
|
+
function draftOf(raw) {
|
|
375
|
+
if (raw === null || raw === undefined) return null;
|
|
376
|
+
try {
|
|
377
|
+
// Always a copy: a database snapshot is frozen, and the page writes into what it opens.
|
|
378
|
+
var doc = JSON.parse(typeof raw === "string" ? raw : JSON.stringify(raw));
|
|
379
|
+
return validDraft(doc) ? doc : null;
|
|
380
|
+
} catch (e) { return null; }
|
|
381
|
+
}
|
|
382
|
+
/** Structural, down to every expectation: a half-shaped document must never reach open(). */
|
|
383
|
+
function validDraft(doc) {
|
|
384
|
+
if (!(doc && typeof doc === "object" && doc.schema === RESULTS_SCHEMA && doc.specId === SPEC.id && doc.steps && typeof doc.steps === "object")) return false;
|
|
385
|
+
return Object.keys(doc.steps).every(function (id) {
|
|
386
|
+
var step = doc.steps[id];
|
|
387
|
+
if (!step || typeof step !== "object" || !step.expect || typeof step.expect !== "object") return false;
|
|
388
|
+
return Object.keys(step.expect).every(function (k) { var e = step.expect[k]; return Boolean(e && typeof e === "object"); });
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
/** Only a run that answered THIS revision of the recipe is reopened as it is. */
|
|
392
|
+
function sameRevision(doc) { return doc.specSha256 === SPEC_SHA; }
|
|
301
393
|
|
|
302
394
|
// ── Steps ───────────────────────────────────────────────────────────
|
|
303
395
|
function renderSteps() {
|
|
@@ -307,25 +399,25 @@
|
|
|
307
399
|
var article = document.createElement("article");
|
|
308
400
|
article.className = "step";
|
|
309
401
|
article.id = "step-" + step.id;
|
|
310
|
-
var head = '<div class="step-head"><span class="num">' + index + '</span><h2></h2>'
|
|
402
|
+
var head = '<div class="step-head"><span class="num">' + index + '</span><h2 tabindex="-1"></h2>'
|
|
311
403
|
+ (step.duration ? '<span class="duration"></span>' : "")
|
|
312
404
|
+ '<span class="verdict" id="v-' + step.id + '"></span></div>';
|
|
313
405
|
var body = '<div class="step-body">';
|
|
314
406
|
if (step.warning) body += '<p class="warn-box">' + step.warning + "</p>";
|
|
315
|
-
else if (step.writes) body += '<p class="warn-box"><b>' + T.writesWarning + " " + SPEC.environment.name + ".</b> " + T.intended + "</p>";
|
|
407
|
+
else if (step.writes) body += '<p class="warn-box"><b>' + T.writesWarning + " " + esc(SPEC.environment.name) + ".</b> " + T.intended + "</p>";
|
|
316
408
|
if (step.optional) body += '<p class="info-box">' + T.optional + ' — <button class="btn" type="button" data-skip="' + step.id + '">' + T.skip + "</button></p>";
|
|
317
409
|
body += '<div class="section"><span class="eyebrow">' + T.where + "</span><ul>" + step.where.map(function (x) { return "<li>" + x + "</li>"; }).join("") + "</ul></div>";
|
|
318
410
|
body += '<div class="section"><span class="eyebrow">' + T.doIt + "</span><ol>" + step.do.map(function (x) { return "<li>" + x + "</li>"; }).join("") + "</ol></div>";
|
|
319
411
|
body += '<div class="section"><span class="eyebrow">' + T.expect + '</span><div class="expects">';
|
|
320
412
|
step.expect.forEach(function (e) {
|
|
321
413
|
var name = "e-" + step.id + "-" + e.id;
|
|
322
|
-
body += '<div class="expect" id="' + name + '" data-state=""><div class="text">' + e.text + "</div>"
|
|
323
|
-
+ '<div class="choice" role="radiogroup">'
|
|
414
|
+
body += '<div class="expect" id="' + name + '" data-state=""><div class="text" id="t-' + step.id + "-" + e.id + '">' + e.text + "</div>"
|
|
415
|
+
+ '<div class="choice" role="radiogroup" aria-labelledby="t-' + step.id + "-" + e.id + '">'
|
|
324
416
|
+ '<label class="pill passed"><input type="radio" name="' + name + '" id="' + name + '-passed" value="passed"><span>' + T.seen + "</span></label>"
|
|
325
417
|
+ '<label class="pill failed"><input type="radio" name="' + name + '" id="' + name + '-failed" value="failed"><span>' + T.notSeen + "</span></label>"
|
|
326
418
|
+ '<label class="pill blocked"><input type="radio" name="' + name + '" id="' + name + '-blocked" value="blocked"><span>' + T.blocked + "</span></label>"
|
|
327
419
|
+ "</div>"
|
|
328
|
-
+ '<div class="why" id="why-' + step.id + "-" + e.id + '" hidden><label for="n-' + step.id + "-" + e.id + '"><span>' + T.whyNot + '</span><textarea id="n-' + step.id + "-" + e.id + '"></textarea></label></div>'
|
|
420
|
+
+ '<div class="why" id="why-' + step.id + "-" + e.id + '" hidden><label for="n-' + step.id + "-" + e.id + '"><span id="w-' + step.id + "-" + e.id + '">' + T.whyNot + '</span><textarea id="n-' + step.id + "-" + e.id + '"></textarea></label></div>'
|
|
329
421
|
+ "</div>";
|
|
330
422
|
});
|
|
331
423
|
body += "</div></div>";
|
|
@@ -373,11 +465,15 @@
|
|
|
373
465
|
if (event && event.target instanceof HTMLInputElement && event.target.type === "radio") event.target.checked = false;
|
|
374
466
|
$("startbox").hidden = false;
|
|
375
467
|
$("start-state").textContent = T.startFirst;
|
|
376
|
-
$("startbox").scrollIntoView({ block: "center" });
|
|
468
|
+
if ($("startbox").scrollIntoView) $("startbox").scrollIntoView({ block: "center" });
|
|
377
469
|
$("tester").focus();
|
|
378
470
|
return false;
|
|
379
471
|
}
|
|
380
472
|
|
|
473
|
+
function stateLabel(state) {
|
|
474
|
+
return state === "passed" ? T.seen : state === "failed" ? T.notSeen : state === "blocked" ? T.blocked : state === "skipped" ? T.skipped : T.todo;
|
|
475
|
+
}
|
|
476
|
+
|
|
381
477
|
function reflect() {
|
|
382
478
|
recount();
|
|
383
479
|
STEPS.forEach(function (step) {
|
|
@@ -385,104 +481,173 @@
|
|
|
385
481
|
if (!observed) return;
|
|
386
482
|
var article = $("step-" + step.id);
|
|
387
483
|
article.dataset.state = observed.state || "";
|
|
388
|
-
|
|
389
|
-
$("v-" + step.id).textContent = verdict;
|
|
484
|
+
$("v-" + step.id).textContent = stateLabel(observed.state);
|
|
390
485
|
step.expect.forEach(function (e) {
|
|
391
|
-
var name = "e-" + step.id + "-" + e.id,
|
|
486
|
+
var name = "e-" + step.id + "-" + e.id, entry = observed.expect[e.id], value = entry ? entry.state : null;
|
|
392
487
|
["passed", "failed", "blocked"].forEach(function (state) { $(name + "-" + state).checked = value === state; });
|
|
393
488
|
$(name).dataset.state = value || "";
|
|
394
|
-
$("why-" + step.id + "-" + e.id).hidden = !(value === "failed" || value === "blocked");
|
|
395
489
|
var field = $("n-" + step.id + "-" + e.id);
|
|
396
|
-
|
|
490
|
+
// The question follows the answer, and a note already written stays in view whatever the answer became.
|
|
491
|
+
$("why-" + step.id + "-" + e.id).hidden = !(value === "failed" || value === "blocked" || (entry && entry.note));
|
|
492
|
+
$("w-" + step.id + "-" + e.id).textContent = value === "blocked" ? T.blockedWhy : T.whyNot;
|
|
493
|
+
// A save never moves the cursor: the field being typed in keeps what is being typed.
|
|
494
|
+
if (document.activeElement !== field) field.value = (entry && entry.note) || "";
|
|
397
495
|
});
|
|
398
496
|
var note = $("note-" + step.id);
|
|
399
497
|
if (document.activeElement !== note) note.value = observed.note || "";
|
|
400
498
|
});
|
|
401
499
|
var s = run.summary;
|
|
402
500
|
var total = s.passed + s.failed + s.blocked + s.skipped + s.unanswered;
|
|
501
|
+
var answered = total - s.unanswered;
|
|
502
|
+
var pct = total ? Math.round((answered / total) * 100) : 0;
|
|
403
503
|
$("c-passed").textContent = s.passed;
|
|
404
504
|
$("c-failed").textContent = s.failed;
|
|
405
505
|
$("c-blocked").textContent = s.blocked;
|
|
406
506
|
$("c-todo").textContent = s.unanswered;
|
|
507
|
+
// The language decides whether the sign sticks to the number (50%) or not (50 %).
|
|
508
|
+
$("c-pct").textContent = pct + (T.percentAnswered.indexOf("{n}%") >= 0 ? "%" : " %");
|
|
407
509
|
STEPS.forEach(function (step, index) {
|
|
408
510
|
var segment = $("s-" + step.id);
|
|
409
511
|
if (!segment) return;
|
|
410
512
|
var observed = run.steps[step.id];
|
|
513
|
+
if (!observed) return;
|
|
411
514
|
var states = Object.keys(observed.expect).map(function (k) { return observed.expect[k].state; });
|
|
412
|
-
var
|
|
413
|
-
var state = observed.state || (
|
|
515
|
+
var done = states.filter(function (v) { return v !== null && v !== undefined; }).length;
|
|
516
|
+
var state = observed.state || (done ? "partial" : "");
|
|
414
517
|
segment.setAttribute("data-state", state);
|
|
415
|
-
segment.title = index + " · " + step.title + " — " + (state === "
|
|
518
|
+
segment.title = index + " · " + step.title + " — " + stateLabel(state === "partial" ? null : state) + " (" + done + "/" + states.length + ")";
|
|
416
519
|
});
|
|
417
|
-
|
|
520
|
+
var counts = s.passed + " " + T.seen + " · " + s.failed + " " + T.notSeen + " · " + s.blocked + " " + T.blocked + (s.skipped ? " · " + s.skipped + " " + T.skipped : "");
|
|
521
|
+
$("strip").setAttribute("aria-label", total + " · " + counts);
|
|
522
|
+
// The meter says how much is ANSWERED, never how much passed: "not seen" advances it too.
|
|
523
|
+
var meter = $("meter");
|
|
524
|
+
meter.setAttribute("aria-valuenow", String(pct));
|
|
525
|
+
meter.setAttribute("aria-valuetext", T.percentAnswered.replace("{n}", String(pct)) + " · " + counts);
|
|
526
|
+
$("meter-fill").style.width = pct + "%";
|
|
418
527
|
// "Run by laurent · finished on 2026-09-16", as the recipes played in claude.ai always read.
|
|
419
|
-
$("bar-tester")
|
|
420
|
-
(run.finishedAt ? " · " + T.finishedOn.replace("{date}", String(run.finishedAt).slice(0, 10)) : "");
|
|
528
|
+
setText($("bar-tester"), T.runBy.replace("{tester}", run.tester) +
|
|
529
|
+
(run.finishedAt ? " · " + T.finishedOn.replace("{date}", String(run.finishedAt).slice(0, 10)) : ""));
|
|
421
530
|
var overall = $("overall");
|
|
422
531
|
if (document.activeElement !== overall) overall.value = run.overallNote || "";
|
|
423
532
|
$("json").value = JSON.stringify(run, null, 2);
|
|
424
533
|
$("btn-finish").disabled = Boolean(run.finishedAt);
|
|
425
|
-
$("btn-finish").textContent = run.finishedAt ? T.finished : T.finishRun;
|
|
534
|
+
$("btn-finish").textContent = run.finishedAt ? T.finished : confirmingFinish ? T.finishAnyway : T.finishRun;
|
|
426
535
|
}
|
|
427
536
|
|
|
428
|
-
// ── Persistence:
|
|
537
|
+
// ── Persistence: this browser always, plus the artifact database or the local server ──
|
|
538
|
+
/** Both keys must read back — the run itself, and the pointer that finds it after a reload. */
|
|
539
|
+
function saveLocal() {
|
|
540
|
+
var content = lsSet(KEY + "-" + runId, JSON.stringify(run));
|
|
541
|
+
var index = lsSet(KEY + "-last", runId);
|
|
542
|
+
localOk = content && index;
|
|
543
|
+
return localOk;
|
|
544
|
+
}
|
|
545
|
+
/** The status line reports what actually happened, never what was attempted. Written only on change: it is a live region. */
|
|
546
|
+
function showSaveState() {
|
|
547
|
+
var el = $("save-state"), text, cls = "state";
|
|
548
|
+
var remote = Boolean(dbDoc || serve);
|
|
549
|
+
if (remote && pending) { text = T.saving; }
|
|
550
|
+
else if (remote && remoteState === "ok") { text = T.savedRemote + " " + remoteAt.toLocaleTimeString(); cls = "state saved"; }
|
|
551
|
+
else if (remote && remoteState === "failed") { text = localOk ? T.remoteFailed : T.saveFailed; cls = localOk ? "state" : "state failed"; }
|
|
552
|
+
else if (localOk) { text = T.savedLocal; cls = "state saved"; }
|
|
553
|
+
else { text = T.saveFailed; cls = "state failed"; }
|
|
554
|
+
setText(el, text);
|
|
555
|
+
if (el.className !== cls) el.className = cls;
|
|
556
|
+
}
|
|
429
557
|
function persist(immediate) {
|
|
430
558
|
recount();
|
|
431
559
|
run.updatedAt = new Date().toISOString();
|
|
560
|
+
if (confirmingFinish) { confirmingFinish = false; setText($("end-state"), ""); }
|
|
561
|
+
if (run.finishedAt && (!runNote || runNote.kind !== "changedAfterFinish")) showNote({ kind: "changedAfterFinish" });
|
|
432
562
|
reflect();
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
$("save-state").textContent = T.savedRemote + " " + new Date().toLocaleTimeString();
|
|
441
|
-
$("save-state").className = "state saved";
|
|
442
|
-
}).catch(function () { $("save-state").textContent = T.savedLocal; $("save-state").className = "state"; });
|
|
443
|
-
return;
|
|
563
|
+
saveLocal();
|
|
564
|
+
if (dbDoc || serve) {
|
|
565
|
+
var body = JSON.stringify(run);
|
|
566
|
+
if (body !== lastWritten) {
|
|
567
|
+
pending = body;
|
|
568
|
+
clearTimeout(timer);
|
|
569
|
+
if (immediate || body.length > LARGE_BODY) flush(); else timer = setTimeout(flush, 500);
|
|
444
570
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
lastWritten = body;
|
|
450
|
-
$("save-state").textContent = T.savedRemote + " " + new Date().toLocaleTimeString();
|
|
451
|
-
$("save-state").className = "state saved";
|
|
452
|
-
})
|
|
453
|
-
.catch(function () { $("save-state").textContent = T.savedLocal; $("save-state").className = "state"; });
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
$("save-state").textContent = T.savedLocal;
|
|
457
|
-
};
|
|
571
|
+
}
|
|
572
|
+
showSaveState();
|
|
573
|
+
}
|
|
574
|
+
function flush(unloading) {
|
|
458
575
|
clearTimeout(timer);
|
|
459
|
-
|
|
576
|
+
timer = null;
|
|
577
|
+
var body = pending;
|
|
578
|
+
if (!body) return;
|
|
579
|
+
// Only the latest request decides the status: an older one settling late says nothing.
|
|
580
|
+
var seq = ++flushSeq;
|
|
581
|
+
var done = function () { if (seq !== flushSeq) return; if (pending === body) pending = null; lastWritten = body; remoteState = "ok"; remoteAt = new Date(); showSaveState(); };
|
|
582
|
+
var failed = function () { if (seq !== flushSeq) return; if (pending === body) pending = null; remoteState = "failed"; showSaveState(); };
|
|
583
|
+
if (dbDoc) { dbDoc.set(JSON.parse(body)).then(done, failed); return; }
|
|
584
|
+
if (serve) {
|
|
585
|
+
var init = { method: "PUT", headers: { "content-type": "application/json" }, body: body };
|
|
586
|
+
if (unloading) init.keepalive = true;
|
|
587
|
+
try {
|
|
588
|
+
fetch(serve + "/results/" + runId, init)
|
|
589
|
+
.then(function (response) { if (!response.ok) throw new Error(String(response.status)); done(); })
|
|
590
|
+
.catch(failed);
|
|
591
|
+
} catch (e) { failed(); }
|
|
592
|
+
}
|
|
460
593
|
}
|
|
461
594
|
|
|
462
|
-
|
|
595
|
+
/** A newer copy of the run arrived from elsewhere (another tab, another device): it is what both now show. */
|
|
596
|
+
function adopt(doc, kind) {
|
|
597
|
+
run = doc;
|
|
598
|
+
run.runId = runId;
|
|
599
|
+
// What this tab had not sent yet described a state that no longer exists: it is not pushed over the newer copy.
|
|
600
|
+
clearTimeout(timer); timer = null; pending = null;
|
|
601
|
+
reflect();
|
|
602
|
+
saveLocal();
|
|
603
|
+
showSaveState();
|
|
604
|
+
if (!runNote || runNote.kind !== kind) showNote({ kind: kind });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function attach(id) {
|
|
608
|
+
if (!db) return;
|
|
609
|
+
dbDoc = db.doc("recettes/" + id);
|
|
610
|
+
if (unsub) unsub();
|
|
611
|
+
unsub = dbDoc.onSnapshot(function (snapshot) {
|
|
612
|
+
if (!snapshot.exists) return;
|
|
613
|
+
var data = snapshot.data();
|
|
614
|
+
if (!data || snapshot.metadata.hasPendingWrites) return;
|
|
615
|
+
var doc = draftOf(data);
|
|
616
|
+
if (!doc || !run || at(doc) <= at(run)) return;
|
|
617
|
+
var body = JSON.stringify(doc);
|
|
618
|
+
if (body === JSON.stringify(run)) return;
|
|
619
|
+
lastWritten = body;
|
|
620
|
+
remoteState = "ok"; remoteAt = new Date();
|
|
621
|
+
adopt(doc, "otherTab");
|
|
622
|
+
}, function () {});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function open(id, doc, fromRemote) {
|
|
463
626
|
runId = id;
|
|
464
627
|
run = doc;
|
|
465
628
|
run.runId = id;
|
|
466
|
-
|
|
629
|
+
lastWritten = ""; pending = null; remoteState = null; confirmingFinish = false;
|
|
630
|
+
showNote(null);
|
|
631
|
+
setText($("end-state"), "");
|
|
467
632
|
$("startbox").hidden = true;
|
|
468
633
|
$("steps").hidden = false;
|
|
469
634
|
$("end").hidden = false;
|
|
470
635
|
$("bar").hidden = false;
|
|
471
636
|
$("btn-save").disabled = false;
|
|
472
637
|
$("btn-copy").disabled = false;
|
|
638
|
+
$("btn-another").disabled = false;
|
|
473
639
|
reflect();
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
$("mode-note").textContent = dbDoc ? T.dbMode : serve ? T.serveMode : T.localMode;
|
|
640
|
+
attach(id);
|
|
641
|
+
if (fromRemote && dbDoc) { lastWritten = JSON.stringify(run); remoteState = "ok"; remoteAt = new Date(at(run) > -Infinity ? at(run) : Date.now()); }
|
|
642
|
+
// Reopening is a write too: if this browser cannot keep the run, the tester learns it now, not at the end.
|
|
643
|
+
saveLocal();
|
|
644
|
+
showSaveState();
|
|
645
|
+
$("mode-note").textContent = modeText();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function focusFirstStep() {
|
|
649
|
+
var heading = $("steps").querySelector("h2");
|
|
650
|
+
if (heading) heading.focus();
|
|
486
651
|
}
|
|
487
652
|
|
|
488
653
|
function start() {
|
|
@@ -492,31 +657,158 @@
|
|
|
492
657
|
open(id, newRun(tester));
|
|
493
658
|
persist(true);
|
|
494
659
|
window.scrollTo({ top: 0 });
|
|
660
|
+
focusFirstStep();
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* A run that answered another revision of the recipe is never poured into this one blindly.
|
|
665
|
+
* The rule, and the only rule: an answer travels when the line it answered is identical —
|
|
666
|
+
* same step, same letter, same text. Everything else is asked again. The earlier run is
|
|
667
|
+
* left exactly as it was; the continuation is a new run that names it.
|
|
668
|
+
*/
|
|
669
|
+
function carry(id, doc) {
|
|
670
|
+
var fresh = newRun(doc.tester || "");
|
|
671
|
+
var kept = 0, total = 0;
|
|
672
|
+
STEPS.forEach(function (step) {
|
|
673
|
+
var old = doc.steps[step.id];
|
|
674
|
+
var target = fresh.steps[step.id];
|
|
675
|
+
if (old && old.title === step.title && typeof old.note === "string") target.note = old.note;
|
|
676
|
+
step.expect.forEach(function (e) {
|
|
677
|
+
total++;
|
|
678
|
+
var prior = old && old.expect && old.expect[e.id];
|
|
679
|
+
if (!prior || prior.text !== e.text) return;
|
|
680
|
+
if (STATES.indexOf(prior.state) >= 0) { target.expect[e.id].state = prior.state; kept++; }
|
|
681
|
+
target.expect[e.id].note = typeof prior.note === "string" ? prior.note : "";
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
fresh.overallNote = typeof doc.overallNote === "string" ? doc.overallNote : "";
|
|
685
|
+
fresh.carriedFrom = { runId: doc.runId || id, specSha256: doc.specSha256 || null, kept: kept, toAnswer: total - kept };
|
|
686
|
+
open(newRunId(doc.tester || ""), fresh);
|
|
687
|
+
persist(true);
|
|
688
|
+
showNote({ kind: "carried", kept: kept, left: total - kept });
|
|
689
|
+
window.scrollTo({ top: 0 });
|
|
690
|
+
focusFirstStep();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function download(name, text) {
|
|
694
|
+
var report = function (message) { setText($("end-state"), message); setText($("start-state"), message); };
|
|
695
|
+
var blob = function () {
|
|
696
|
+
try {
|
|
697
|
+
var url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
|
|
698
|
+
var link = document.createElement("a");
|
|
699
|
+
link.href = url;
|
|
700
|
+
link.download = name;
|
|
701
|
+
document.body.appendChild(link);
|
|
702
|
+
link.click();
|
|
703
|
+
link.remove();
|
|
704
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 2000);
|
|
705
|
+
report(T.savedAs.replace("{file}", name));
|
|
706
|
+
} catch (e) { report(T.downloadFailed); }
|
|
707
|
+
};
|
|
708
|
+
if (downloads) {
|
|
709
|
+
downloads.save({ filename: name, data: text })
|
|
710
|
+
.then(function () { report(T.savedAs.replace("{file}", name)); })
|
|
711
|
+
// The tester said no: nothing to add. Anything else is a failure worth a word, and worth the other way out.
|
|
712
|
+
.catch(function (error) { if (error && error.code === "declined") return; blob(); });
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
blob();
|
|
495
716
|
}
|
|
496
717
|
|
|
497
718
|
function listResumable() {
|
|
719
|
+
var seq = ++listSeq;
|
|
498
720
|
var box = $("resume-list");
|
|
499
721
|
box.innerHTML = "";
|
|
500
|
-
var
|
|
501
|
-
var
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
button.type = "button";
|
|
507
|
-
button.textContent = (doc.tester || "?") + " — " + String(doc.startedAt || "").slice(0, 10) + (doc.finishedAt ? " · " + T.finished : "");
|
|
508
|
-
button.addEventListener("click", function () { open(id, doc); });
|
|
509
|
-
box.appendChild(button);
|
|
722
|
+
var candidates = {};
|
|
723
|
+
var broken = [];
|
|
724
|
+
var button = function (label, action) { var b = document.createElement("button"); b.className = "btn"; b.type = "button"; b.textContent = label; b.addEventListener("click", action); return b; };
|
|
725
|
+
// Every copy of a run is a candidate; the newest wins, wherever it lives.
|
|
726
|
+
var offer = function (id, doc, remote) {
|
|
727
|
+
if (!candidates[id] || at(doc) > at(candidates[id].doc)) candidates[id] = { doc: doc, remote: remote };
|
|
510
728
|
};
|
|
511
|
-
var
|
|
729
|
+
var render = function () {
|
|
730
|
+
if (seq !== listSeq) return;
|
|
731
|
+
box.innerHTML = "";
|
|
732
|
+
Object.keys(candidates).sort(function (a, b) { return at(candidates[b].doc) - at(candidates[a].doc); }).forEach(function (id) {
|
|
733
|
+
var doc = candidates[id].doc, remote = candidates[id].remote;
|
|
734
|
+
var progress = answeredOf(doc);
|
|
735
|
+
var when = at(doc) > -Infinity ? new Date(at(doc)) : null;
|
|
736
|
+
var label = (doc.tester || "?") + " — " + String(doc.startedAt || "").slice(0, 10)
|
|
737
|
+
+ (when ? " " + when.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "")
|
|
738
|
+
+ " · " + progress.answered + "/" + progress.total + " " + T.countAnswered
|
|
739
|
+
+ (doc.finishedAt ? " · " + T.finished : "");
|
|
740
|
+
if (sameRevision(doc)) { box.appendChild(button(label, function () { open(id, doc, remote); })); return; }
|
|
741
|
+
// Another revision: offered, explained, exportable — never reopened as if nothing had changed.
|
|
742
|
+
var row = document.createElement("div");
|
|
743
|
+
row.className = "row";
|
|
744
|
+
var note = document.createElement("span");
|
|
745
|
+
note.className = "state";
|
|
746
|
+
note.textContent = label + " · " + T.otherRevision;
|
|
747
|
+
row.appendChild(note);
|
|
748
|
+
row.appendChild(button(T.continueHere, function () { carry(id, doc); }));
|
|
749
|
+
row.appendChild(button(T.saveThatJson, function () { download(id + ".json", JSON.stringify(doc, null, 2)); }));
|
|
750
|
+
box.appendChild(row);
|
|
751
|
+
});
|
|
752
|
+
// A stored run that cannot be read is listed too, whatever the pointer says: kept, explained, exportable.
|
|
753
|
+
broken.forEach(function (entry) {
|
|
754
|
+
var row = document.createElement("div");
|
|
755
|
+
row.className = "row";
|
|
756
|
+
var note = document.createElement("span");
|
|
757
|
+
note.className = "state failed";
|
|
758
|
+
note.textContent = T.draftUnreadable;
|
|
759
|
+
row.appendChild(note);
|
|
760
|
+
row.appendChild(button(T.saveRaw, function () { download(entry.key + ".txt", entry.raw); }));
|
|
761
|
+
box.appendChild(row);
|
|
762
|
+
});
|
|
763
|
+
$("resume").hidden = box.children.length === 0;
|
|
764
|
+
};
|
|
765
|
+
var local = function () {
|
|
766
|
+
lsKeys().forEach(function (key) {
|
|
767
|
+
if (key.indexOf(KEY + "-") !== 0 || key === KEY + "-last" || key === KEY + "-lang") return;
|
|
768
|
+
var raw = lsGet(key);
|
|
769
|
+
var doc = draftOf(raw);
|
|
770
|
+
if (doc) offer(key.slice(KEY.length + 1), doc, false);
|
|
771
|
+
else if (raw !== null && !(unreadable && unreadable.key === key)) broken.push({ key: key, raw: raw });
|
|
772
|
+
});
|
|
773
|
+
};
|
|
774
|
+
var remote = db
|
|
512
775
|
? db.collection("recettes").where("specId", "==", SPEC.id).limit(50).get()
|
|
513
|
-
.then(function (query) { query.docs.forEach(function (d) {
|
|
776
|
+
.then(function (query) { query.docs.forEach(function (d) { var doc = draftOf(d.data()); if (doc) offer(d.id, doc, true); }); }).catch(function () {})
|
|
514
777
|
: Promise.resolve();
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
778
|
+
remote.then(function () { local(); render(); });
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** The browser's own copy comes back first, before any optional capability answers — or fails to. */
|
|
782
|
+
function restoreLocal() {
|
|
783
|
+
var last = lsGet(KEY + "-last");
|
|
784
|
+
if (!last) return;
|
|
785
|
+
var raw = lsGet(KEY + "-" + last);
|
|
786
|
+
if (raw === null) return;
|
|
787
|
+
var doc = draftOf(raw);
|
|
788
|
+
if (!doc) {
|
|
789
|
+
// Not deleted, not hidden: the tester can save it and hand it over.
|
|
790
|
+
unreadable = { key: KEY + "-" + last, raw: raw };
|
|
791
|
+
$("draft-problem").hidden = false;
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (sameRevision(doc)) open(last, doc);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** The artifact database answered: the newer copy wins, and an older remote copy never overwrites this browser's. */
|
|
798
|
+
function connectDb() {
|
|
799
|
+
$("mode-note").textContent = modeText();
|
|
800
|
+
$("storage-note").textContent = modeText();
|
|
801
|
+
listResumable();
|
|
802
|
+
var id = runId || lsGet(KEY + "-last");
|
|
803
|
+
if (!id) return;
|
|
804
|
+
db.doc("recettes/" + id).get().then(function (snapshot) {
|
|
805
|
+
var remote = snapshot.exists ? draftOf(snapshot.data()) : null;
|
|
806
|
+
var local = run || draftOf(lsGet(KEY + "-" + id));
|
|
807
|
+
var remoteNewer = Boolean(remote && (!local || at(remote) > at(local)));
|
|
808
|
+
if (remoteNewer && sameRevision(remote)) open(id, remote, true);
|
|
809
|
+
else if (run) { attach(id); persist(true); }
|
|
810
|
+
else if (local && sameRevision(local)) { open(id, local); persist(true); }
|
|
811
|
+
}).catch(function () { if (run) { attach(runId); persist(true); } });
|
|
520
812
|
}
|
|
521
813
|
|
|
522
814
|
document.addEventListener("change", function (event) {
|
|
@@ -525,7 +817,9 @@
|
|
|
525
817
|
if (!(target instanceof HTMLInputElement) || target.type !== "radio") return;
|
|
526
818
|
var m = /^e-(.+)-([a-z])$/.exec(target.name);
|
|
527
819
|
if (!m || !requireRun(event)) return;
|
|
528
|
-
run.steps[m[1]].expect[m[2]]
|
|
820
|
+
var step = run.steps[m[1]], entry = step && step.expect[m[2]];
|
|
821
|
+
if (!entry) return;
|
|
822
|
+
entry.state = target.value;
|
|
529
823
|
persist();
|
|
530
824
|
});
|
|
531
825
|
document.addEventListener("input", function (event) {
|
|
@@ -536,7 +830,7 @@
|
|
|
536
830
|
var step = /^note-(.+)$/.exec(target.id);
|
|
537
831
|
if (step && run.steps[step[1]]) { run.steps[step[1]].note = target.value; persist(); return; }
|
|
538
832
|
var expect = /^n-(.+)-([a-z])$/.exec(target.id);
|
|
539
|
-
if (expect && run.steps[expect[1]]) { run.steps[expect[1]].expect[expect[2]].note = target.value; persist(); }
|
|
833
|
+
if (expect && run.steps[expect[1]] && run.steps[expect[1]].expect[expect[2]]) { run.steps[expect[1]].expect[expect[2]].note = target.value; persist(); }
|
|
540
834
|
});
|
|
541
835
|
document.addEventListener("keydown", function (event) {
|
|
542
836
|
var code = event.target && event.target.closest && event.target.closest("code.cmd");
|
|
@@ -550,77 +844,101 @@
|
|
|
550
844
|
Object.keys(run.steps[id].expect).forEach(function (k) { run.steps[id].expect[k].state = "skipped"; });
|
|
551
845
|
persist();
|
|
552
846
|
});
|
|
847
|
+
// The same run open in two tabs of this browser: whichever tab saved last is what both show.
|
|
848
|
+
// Said on screen, so a tester never wonders why a tick changed by itself.
|
|
849
|
+
window.addEventListener("storage", function (event) {
|
|
850
|
+
if (!run || !event || event.key !== KEY + "-" + runId || !event.newValue) return;
|
|
851
|
+
var doc = draftOf(event.newValue);
|
|
852
|
+
if (!doc || at(doc) <= at(run)) return;
|
|
853
|
+
adopt(doc, "otherTab");
|
|
854
|
+
});
|
|
855
|
+
window.addEventListener("pagehide", function () { if (pending) flush(true); });
|
|
856
|
+
// Browsers do not promise to show this; it is a last net under the status line, not a replacement for it.
|
|
857
|
+
window.addEventListener("beforeunload", function (event) {
|
|
858
|
+
if (run && !localOk) { event.preventDefault(); event.returnValue = ""; }
|
|
859
|
+
});
|
|
553
860
|
|
|
554
861
|
$("btn-start").addEventListener("click", start);
|
|
555
862
|
$("tester").addEventListener("keydown", function (event) { if (event.key === "Enter") start(); });
|
|
556
863
|
$("btn-finish").addEventListener("click", function () {
|
|
557
864
|
if (!requireRun()) return;
|
|
558
|
-
|
|
865
|
+
// Unanswered lines are said on the page, never in a dialog a host may swallow: the second click finishes anyway.
|
|
866
|
+
if (run.summary.unanswered > 0 && !confirmingFinish) {
|
|
867
|
+
confirmingFinish = true;
|
|
868
|
+
setText($("end-state"), T.confirmUnanswered.replace("{n}", String(run.summary.unanswered)));
|
|
869
|
+
$("btn-finish").textContent = T.finishAnyway;
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
confirmingFinish = false;
|
|
559
873
|
run.finishedAt = new Date().toISOString();
|
|
560
874
|
persist(true);
|
|
561
|
-
|
|
875
|
+
showNote(null);
|
|
876
|
+
// Finished is not accepted: the agent reads and classifies every answer before anything is decided.
|
|
877
|
+
setText($("end-state"), T.finishedNote);
|
|
562
878
|
});
|
|
563
879
|
$("btn-copy").addEventListener("click", function () {
|
|
564
880
|
if (!requireRun()) return;
|
|
565
881
|
var text = JSON.stringify(run, null, 2);
|
|
566
882
|
var fallback = function () { $("json").parentElement.open = true; $("json").focus(); $("json").select(); };
|
|
567
|
-
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { $("end-state")
|
|
883
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { setText($("end-state"), T.copied); }, fallback);
|
|
568
884
|
else fallback();
|
|
569
885
|
});
|
|
570
886
|
$("btn-save").addEventListener("click", function () {
|
|
571
887
|
// Never export a run that does not exist: that is how a tester once saved "null.json".
|
|
572
888
|
if (!requireRun()) return;
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
889
|
+
download(runId + ".json", JSON.stringify(run, null, 2));
|
|
890
|
+
});
|
|
891
|
+
$("btn-save-raw").addEventListener("click", function () {
|
|
892
|
+
if (!unreadable) return;
|
|
893
|
+
download(unreadable.key + ".txt", unreadable.raw);
|
|
894
|
+
});
|
|
895
|
+
$("btn-another").addEventListener("click", function () {
|
|
896
|
+
if (!requireRun()) return;
|
|
897
|
+
flush();
|
|
898
|
+
if (unsub) { unsub(); unsub = null; }
|
|
899
|
+
dbDoc = null; run = null; runId = null; pending = null; lastWritten = ""; remoteState = null; confirmingFinish = false;
|
|
900
|
+
showNote(null);
|
|
901
|
+
setText($("end-state"), "");
|
|
902
|
+
$("steps").hidden = true;
|
|
903
|
+
$("end").hidden = true;
|
|
904
|
+
$("bar").hidden = true;
|
|
905
|
+
$("startbox").hidden = false;
|
|
906
|
+
$("btn-save").disabled = true;
|
|
907
|
+
$("btn-copy").disabled = true;
|
|
908
|
+
$("btn-finish").disabled = true;
|
|
909
|
+
$("btn-another").disabled = true;
|
|
910
|
+
$("tester").value = "";
|
|
911
|
+
$("start-state").textContent = "";
|
|
912
|
+
listResumable();
|
|
913
|
+
$("tester").focus();
|
|
590
914
|
});
|
|
591
915
|
|
|
592
916
|
// ── Start: the page is complete without any capability ──────────────
|
|
593
917
|
lang = pickLanguage();
|
|
594
918
|
fillLanguageChoice();
|
|
595
919
|
applyLanguage(lang);
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
if (!raw) return;
|
|
601
|
-
try { open(last, JSON.parse(raw)); } catch (e) {}
|
|
920
|
+
restoreLocal();
|
|
921
|
+
var probe = function () {
|
|
922
|
+
try { return typeof fetch === "function" ? fetch("__uat/ping", { method: "GET" }) : Promise.reject(new Error("no fetch")); }
|
|
923
|
+
catch (e) { return Promise.reject(e); }
|
|
602
924
|
};
|
|
603
|
-
|
|
925
|
+
probe()
|
|
604
926
|
.then(function (response) { return response.ok ? response.json() : null; })
|
|
605
|
-
.then(function (info) {
|
|
927
|
+
.then(function (info) {
|
|
928
|
+
if (info && info.uat) {
|
|
929
|
+
serve = "__uat";
|
|
930
|
+
$("mode-note").textContent = modeText();
|
|
931
|
+
$("storage-note").textContent = modeText();
|
|
932
|
+
if (run) persist(true);
|
|
933
|
+
}
|
|
934
|
+
})
|
|
606
935
|
.catch(function () {})
|
|
607
936
|
.then(function () {
|
|
608
937
|
var use = window.claude && typeof window.claude.use === "function" ? window.claude.use.bind(window.claude) : function () { return Promise.resolve(null); };
|
|
609
|
-
return use("db").then(function (namespace) {
|
|
610
|
-
|
|
611
|
-
listResumable();
|
|
612
|
-
if (run && db && !dbDoc) { open(runId, run); persist(true); return; }
|
|
613
|
-
var last = lsGet(KEY + "-last");
|
|
614
|
-
if (last && !run && db) {
|
|
615
|
-
db.doc("recettes/" + last).get()
|
|
616
|
-
.then(function (snapshot) { if (snapshot.exists) open(last, snapshot.data()); else resumeLast(); })
|
|
617
|
-
.catch(resumeLast);
|
|
618
|
-
} else resumeLast();
|
|
619
|
-
});
|
|
620
|
-
})
|
|
621
|
-
.catch(resumeLast);
|
|
938
|
+
return use("db").then(function (namespace) { db = namespace || null; if (db) connectDb(); }).catch(function () {});
|
|
939
|
+
});
|
|
622
940
|
if (window.claude && typeof window.claude.use === "function") {
|
|
623
|
-
window.claude.use("downloads").then(function (namespace) { downloads = namespace; });
|
|
941
|
+
window.claude.use("downloads").then(function (namespace) { downloads = namespace; }).catch(function () {});
|
|
624
942
|
}
|
|
625
943
|
})();
|
|
626
944
|
</script>
|