@worca/app 1.3.0 → 1.4.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/README.md +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- package/ui/server.mjs +1827 -73
|
@@ -0,0 +1,1154 @@
|
|
|
1
|
+
// ui/public/script-bench-view.mjs
|
|
2
|
+
// The bench under the editor (script-wizard plan S12; scripts-workbench design
|
|
3
|
+
// §5.3): the bar, the setup that builds ONE bench request, the cases strip and
|
|
4
|
+
// everything the run produced. The bench engine and its `scriptbench-*` frames
|
|
5
|
+
// live in the server (Task 3/5); this module owns the pixels and the request shape.
|
|
6
|
+
//
|
|
7
|
+
// It never has its own idea of how a script runs: every field here maps to a key
|
|
8
|
+
// of the §4.2 request, and the result panes only show what the runner returned.
|
|
9
|
+
import {
|
|
10
|
+
h, renderParamsForm, collectParams, renderPortEditor, collectPorts, applyPortEdit, paramEditorHook,
|
|
11
|
+
} from './script-forms.mjs';
|
|
12
|
+
import { openArtifactPicker, readInputFile } from './artifact-picker.mjs';
|
|
13
|
+
import { escapeHtml } from './code-editor.mjs';
|
|
14
|
+
import {
|
|
15
|
+
CASE_ID_RE, EXPECT_VERDICTS, MAX_CASES, MAX_CASE_NAME, evaluateExpect,
|
|
16
|
+
} from '../../src/shared/graph/script-cases.mjs';
|
|
17
|
+
|
|
18
|
+
export const BENCH_STATUSES = ['clean', 'blocking', 'error', 'stopped', 'timeout'];
|
|
19
|
+
export const SCRATCH_LABEL = 'Scratch folder';
|
|
20
|
+
/** The three verdicts a case may expect (spec §3.2). `stopped` and `timeout` are
|
|
21
|
+
* outcomes, not expectations — `Use result` leaves the row alone for them. */
|
|
22
|
+
export { EXPECT_VERDICTS, MAX_CASES, MAX_CASE_NAME };
|
|
23
|
+
|
|
24
|
+
const metaOf = (data) => ((data && data.meta) ? data.meta : (data || {}));
|
|
25
|
+
const isUserLayer = (meta) => meta.origin === 'user';
|
|
26
|
+
const seconds = (ms) => `${(Number(ms || 0) / 1000).toFixed(1)} s`;
|
|
27
|
+
|
|
28
|
+
/** Shipped cases first, then the user's overlay (W18). `writableIndex` names the
|
|
29
|
+
* array this page may rewrite: its own file on the user layer, the overlay
|
|
30
|
+
* everywhere else — so a plugin's proof is never edited by its user. */
|
|
31
|
+
export function caseListFor(data) {
|
|
32
|
+
const meta = metaOf(data);
|
|
33
|
+
const own = Array.isArray(data && data.cases) ? data.cases : [];
|
|
34
|
+
const overlay = Array.isArray(data && data.userCases) ? data.userCases : [];
|
|
35
|
+
const user = isUserLayer(meta);
|
|
36
|
+
return {
|
|
37
|
+
writableIndex: user ? 'cases' : 'userCases',
|
|
38
|
+
cases: [...own.map((c) => ({ ...c, writable: user })), ...overlay.map((c) => ({ ...c, writable: true }))],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const miniBtn = (doc, cls, label, kind = 'btn-ghost') => {
|
|
43
|
+
const b = h(doc, 'button', `btn ${kind} btn-mini ${cls}`, label);
|
|
44
|
+
b.type = 'button';
|
|
45
|
+
return b;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The case list. A WRITABLE case carries Rename and Delete; a shipped one (a
|
|
49
|
+
* built-in's or a plugin's own file) carries the lock and neither — the user's
|
|
50
|
+
* own cases for it live in the W18 overlay instead (caseListFor). */
|
|
51
|
+
function caseRows(doc, data, caseState) {
|
|
52
|
+
const list = h(doc, 'div', 'bench-case-list');
|
|
53
|
+
const { cases } = caseListFor(data);
|
|
54
|
+
const states = caseState.get(metaOf(data).key) || new Map();
|
|
55
|
+
for (const c of cases) {
|
|
56
|
+
const row = h(doc, 'div', 'bench-case-row');
|
|
57
|
+
row.dataset.caseId = c.id;
|
|
58
|
+
const pick = h(doc, 'button', 'bench-case');
|
|
59
|
+
pick.type = 'button';
|
|
60
|
+
const dot = h(doc, 'i', 'script-dot');
|
|
61
|
+
dot.dataset.state = states.get(c.id) || 'none';
|
|
62
|
+
pick.append(dot, h(doc, 'span', 'bench-case-name', c.name || c.id));
|
|
63
|
+
row.appendChild(pick);
|
|
64
|
+
if (c.writable) row.append(miniBtn(doc, 'bench-case-rename', 'Rename'), miniBtn(doc, 'bench-case-delete', 'Delete'));
|
|
65
|
+
else row.appendChild(h(doc, 'span', 'chip bench-lock', 'shipped'));
|
|
66
|
+
list.appendChild(row);
|
|
67
|
+
}
|
|
68
|
+
return list;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The cases strip (S12): the rows, the inline name, Save as case / + Case / Run all.
|
|
72
|
+
* `unsaved`: the script has no key on disk yet, so nothing can be written for it. */
|
|
73
|
+
function casesStrip(doc, data, caseState, { unsaved = false } = {}) {
|
|
74
|
+
const col = h(doc, 'div', 'bench-col bench-cases');
|
|
75
|
+
col.appendChild(h(doc, 'div', 'bench-col-head', 'Cases'));
|
|
76
|
+
col.appendChild(caseRows(doc, data, caseState));
|
|
77
|
+
// The name row is INLINE (never a browser prompt): Save as case reads it, and
|
|
78
|
+
// selecting a case fills it.
|
|
79
|
+
const name = doc.createElement('input');
|
|
80
|
+
name.type = 'text'; name.className = 'input bench-case-name'; name.dataset.field = 'bench:caseName';
|
|
81
|
+
name.placeholder = 'Case name'; name.maxLength = MAX_CASE_NAME;
|
|
82
|
+
name.setAttribute('aria-label', 'Case name');
|
|
83
|
+
name.disabled = unsaved;
|
|
84
|
+
col.appendChild(name);
|
|
85
|
+
// `.bench-case-actions` is the slot Update is added to and removed from, so the
|
|
86
|
+
// other buttons never move as the selection changes.
|
|
87
|
+
const actions = h(doc, 'div', 'bench-case-actions');
|
|
88
|
+
actions.append(miniBtn(doc, 'bench-save-case', 'Save as case'), miniBtn(doc, 'bench-add-case', '+ Case'));
|
|
89
|
+
col.appendChild(actions);
|
|
90
|
+
const all = miniBtn(doc, 'bench-run-all', 'Run all');
|
|
91
|
+
all.disabled = unsaved || caseListFor(data).cases.length === 0;
|
|
92
|
+
col.appendChild(all);
|
|
93
|
+
if (unsaved) {
|
|
94
|
+
for (const b of col.querySelectorAll('.bench-save-case,.bench-add-case,.bench-run-all')) { b.disabled = true; b.title = 'Save the script first'; }
|
|
95
|
+
}
|
|
96
|
+
return col;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function inputRows(doc, ports) {
|
|
100
|
+
const box = h(doc, 'div', 'bench-inputs');
|
|
101
|
+
box.appendChild(h(doc, 'div', 'bench-zone', 'Inputs'));
|
|
102
|
+
for (const p of ports) {
|
|
103
|
+
const row = h(doc, 'div', 'bench-port');
|
|
104
|
+
row.dataset.port = p.id;
|
|
105
|
+
row.dataset.type = p.type;
|
|
106
|
+
const bind = h(doc, 'label', 'bench-bind');
|
|
107
|
+
const box2 = doc.createElement('input');
|
|
108
|
+
box2.type = 'checkbox'; box2.dataset.field = `in:${p.id}:bound`;
|
|
109
|
+
bind.append(box2, h(doc, 'span', 'bench-port-id mono', p.id), h(doc, 'span', 'chip bench-port-type', p.type));
|
|
110
|
+
row.appendChild(bind);
|
|
111
|
+
if (p.type === 'void') {
|
|
112
|
+
row.appendChild(h(doc, 'span', 'bench-void-fired', 'fired'));
|
|
113
|
+
} else {
|
|
114
|
+
const seg = h(doc, 'div', 'seg bench-src');
|
|
115
|
+
for (const [src, label] of [['text', 'Text'], ['file', 'File…'], ['run', 'Run…']]) {
|
|
116
|
+
const b = h(doc, 'button', src === 'text' ? 'on' : '', label);
|
|
117
|
+
b.type = 'button'; b.dataset.inSrc = src;
|
|
118
|
+
b.setAttribute('aria-pressed', src === 'text' ? 'true' : 'false');
|
|
119
|
+
seg.appendChild(b);
|
|
120
|
+
}
|
|
121
|
+
const file = doc.createElement('input');
|
|
122
|
+
file.type = 'file'; file.className = 'bench-file'; file.hidden = true;
|
|
123
|
+
const ta = doc.createElement('textarea');
|
|
124
|
+
ta.className = 'bench-text mono'; ta.dataset.field = `in:${p.id}:text`; ta.rows = 6; ta.spellcheck = false;
|
|
125
|
+
row.append(seg, h(doc, 'span', 'bench-src-label', ''), file, ta);
|
|
126
|
+
}
|
|
127
|
+
box.appendChild(row);
|
|
128
|
+
}
|
|
129
|
+
return box;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The chips + summary field under the verdict select. Rebuilt on every verdict
|
|
133
|
+
* change, so `—` really means "no expectation": there is nothing else to read. */
|
|
134
|
+
function expectDetail(doc, outputs, { fired = [], summaryIncludes = '' } = {}) {
|
|
135
|
+
const box = h(doc, 'div', 'bench-expect-detail');
|
|
136
|
+
const picked = new Set(fired);
|
|
137
|
+
const chips = h(doc, 'div', 'bench-expect-chips');
|
|
138
|
+
for (const p of outputs) {
|
|
139
|
+
const chip = h(doc, 'label', 'bench-expect-chip');
|
|
140
|
+
const cb = doc.createElement('input');
|
|
141
|
+
cb.type = 'checkbox'; cb.dataset.field = `expect:fired:${p.id}`; cb.checked = picked.has(p.id);
|
|
142
|
+
chip.append(cb, doc.createTextNode(p.id));
|
|
143
|
+
chips.appendChild(chip);
|
|
144
|
+
}
|
|
145
|
+
box.appendChild(chips);
|
|
146
|
+
const wrap = h(doc, 'div', 'ins-f bench-f');
|
|
147
|
+
wrap.appendChild(h(doc, 'label', 'ins-label', 'Summary contains'));
|
|
148
|
+
const input = doc.createElement('input');
|
|
149
|
+
input.type = 'text'; input.className = 'ins-number'; input.dataset.field = 'expect:summaryIncludes';
|
|
150
|
+
input.value = summaryIncludes || '';
|
|
151
|
+
wrap.appendChild(input);
|
|
152
|
+
box.appendChild(wrap);
|
|
153
|
+
return box;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The Expect row (spec §5.3): a verdict picker, and — only once a verdict is
|
|
157
|
+
* chosen — one checkbox per DECLARED output port plus a summary substring.
|
|
158
|
+
* `Use result` copies the verdict and the fired ports from the result on screen
|
|
159
|
+
* (the summary substring is the author's sentence). Labels only. */
|
|
160
|
+
function expectRow(doc, declared) {
|
|
161
|
+
const row = h(doc, 'div', 'bench-expect-row');
|
|
162
|
+
row.appendChild(h(doc, 'div', 'bench-zone', 'Expect'));
|
|
163
|
+
const head = h(doc, 'div', 'bench-expect-head');
|
|
164
|
+
const sel = h(doc, 'select', 'ins-select');
|
|
165
|
+
sel.dataset.field = 'expect:verdict';
|
|
166
|
+
for (const [value, label] of [['', '—'], ...EXPECT_VERDICTS.map((v) => [v, v])]) {
|
|
167
|
+
const o = doc.createElement('option');
|
|
168
|
+
o.value = value; o.textContent = label;
|
|
169
|
+
sel.appendChild(o);
|
|
170
|
+
}
|
|
171
|
+
const use = miniBtn(doc, 'bench-expect-from-result', 'Use result');
|
|
172
|
+
use.disabled = true; // nothing has run yet
|
|
173
|
+
head.append(sel, use);
|
|
174
|
+
row.appendChild(head);
|
|
175
|
+
// The detail is owned by the RENDER, not the controller: a verdict change must
|
|
176
|
+
// repaint the chips on a bare `renderBench` tree too. `declared()` re-reads the
|
|
177
|
+
// port editor every time, so a config-ported script's chips follow its ports.
|
|
178
|
+
row._fill = (expect) => {
|
|
179
|
+
const outs = declared();
|
|
180
|
+
sel.value = expect && EXPECT_VERDICTS.includes(expect.verdict) ? expect.verdict : '';
|
|
181
|
+
const old = row.querySelector('.bench-expect-detail');
|
|
182
|
+
if (old) old.remove();
|
|
183
|
+
if (!sel.value) return;
|
|
184
|
+
row.appendChild(expectDetail(doc, outs, {
|
|
185
|
+
fired: (expect && expect.fired) || [],
|
|
186
|
+
summaryIncludes: (expect && expect.summaryIncludes) || '',
|
|
187
|
+
}));
|
|
188
|
+
};
|
|
189
|
+
sel.addEventListener('change', () => {
|
|
190
|
+
const outs = declared();
|
|
191
|
+
const fired = outs.map((p) => p.id).filter((id) => {
|
|
192
|
+
const cb = row.querySelector(`[data-field="expect:fired:${id}"]`);
|
|
193
|
+
return Boolean(cb && cb.checked);
|
|
194
|
+
});
|
|
195
|
+
const sum = String((row.querySelector('[data-field="expect:summaryIncludes"]') || {}).value || '');
|
|
196
|
+
row._fill(sel.value ? { verdict: sel.value, fired, summaryIncludes: sum } : null);
|
|
197
|
+
});
|
|
198
|
+
return row;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The bar under the editor: the folder, then Test / Stop (S12). W1: a scratch folder,
|
|
202
|
+
* or a REGISTERED project's real checkout — never a path the user types. */
|
|
203
|
+
function benchBar(doc, projects) {
|
|
204
|
+
const bar = h(doc, 'div', 'bench-bar');
|
|
205
|
+
const wrap = h(doc, 'div', 'ins-f bench-f');
|
|
206
|
+
wrap.appendChild(h(doc, 'label', 'ins-label', 'Folder'));
|
|
207
|
+
const sel = h(doc, 'select', 'ins-select');
|
|
208
|
+
sel.dataset.field = 'bench:cwd';
|
|
209
|
+
const scratch = doc.createElement('option');
|
|
210
|
+
scratch.value = ''; scratch.textContent = SCRATCH_LABEL;
|
|
211
|
+
sel.appendChild(scratch);
|
|
212
|
+
for (const p of projects || []) {
|
|
213
|
+
const o = doc.createElement('option');
|
|
214
|
+
o.value = `project:${p.key}`; o.textContent = `${p.name} — ${p.path}`;
|
|
215
|
+
o.disabled = p.exists === false;
|
|
216
|
+
sel.appendChild(o);
|
|
217
|
+
}
|
|
218
|
+
wrap.appendChild(sel);
|
|
219
|
+
bar.append(wrap, h(doc, 'span', 'sp'));
|
|
220
|
+
const stop = miniBtn(doc, 'bench-stop', 'Stop');
|
|
221
|
+
stop.disabled = true;
|
|
222
|
+
bar.append(miniBtn(doc, 'bench-run', 'Test', 'btn-primary'), stop);
|
|
223
|
+
return bar;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The setup: the params form (in `.bench-params`, so setMeta can swap it), the
|
|
227
|
+
* per-run port editor of a config script, the input rows, the Expect row. */
|
|
228
|
+
function setupPanel(doc, data, { highlight, editors, getMeta }) {
|
|
229
|
+
const meta = metaOf(data);
|
|
230
|
+
const col = h(doc, 'div', 'bench-col bench-setup');
|
|
231
|
+
col.appendChild(h(doc, 'div', 'bench-col-head', 'Test inputs'));
|
|
232
|
+
// The SAME params form the composer's inspector shows (§5.3, C3), including the
|
|
233
|
+
// shared code editor for a command/code param.
|
|
234
|
+
const pbox = h(doc, 'div', 'bench-params');
|
|
235
|
+
pbox.appendChild(renderParamsForm(meta, {}, { doc, editorFor: paramEditorHook({ doc, highlight, editors }) }));
|
|
236
|
+
col.appendChild(pbox);
|
|
237
|
+
const configPorts = meta.ports === 'config';
|
|
238
|
+
const raw = configPorts ? (meta.defaultPorts || { inputs: [], outputs: [] }) : { inputs: meta.inputs || [], outputs: meta.outputs || [] };
|
|
239
|
+
if (configPorts) col.appendChild(renderPortEditor(raw, { doc, hasVerdict: Boolean(meta.verdict) }));
|
|
240
|
+
col.appendChild(inputRows(doc, raw.inputs || []));
|
|
241
|
+
// `declared()` reads the LIVE declaration: the page's setMeta swaps data.meta under this tree (S12).
|
|
242
|
+
col.appendChild(expectRow(doc, () => (configPorts ? (collectPorts(col).outputs || []) : (((getMeta && getMeta()) || meta).outputs || []))));
|
|
243
|
+
col.appendChild(h(doc, 'div', 'form-msg bench-msg'));
|
|
244
|
+
return col;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function resultColumn(doc) {
|
|
248
|
+
const col = h(doc, 'div', 'bench-col bench-result');
|
|
249
|
+
col.appendChild(h(doc, 'div', 'bench-col-head', 'Result'));
|
|
250
|
+
const body = h(doc, 'div', 'bench-result-body');
|
|
251
|
+
const line = h(doc, 'div', 'bench-status');
|
|
252
|
+
const dot = h(doc, 'i', 'bench-dot');
|
|
253
|
+
dot.dataset.status = 'idle';
|
|
254
|
+
line.append(dot, h(doc, 'span', 'bench-status-text', 'idle'));
|
|
255
|
+
body.appendChild(line);
|
|
256
|
+
const tabs = h(doc, 'div', 'seg bench-tabs');
|
|
257
|
+
const b = h(doc, 'button', 'on', 'Log');
|
|
258
|
+
b.type = 'button'; b.dataset.rtab = 'log'; b.setAttribute('aria-pressed', 'true');
|
|
259
|
+
tabs.appendChild(b);
|
|
260
|
+
body.appendChild(tabs);
|
|
261
|
+
const pane = h(doc, 'div', 'bench-pane');
|
|
262
|
+
pane.dataset.rpane = 'log';
|
|
263
|
+
pane.appendChild(h(doc, 'pre', 'bench-log'));
|
|
264
|
+
body.appendChild(pane);
|
|
265
|
+
col.appendChild(body);
|
|
266
|
+
return col;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The whole bench, stacked under the editor (S12). Detached; the controller mounts and drives it.
|
|
270
|
+
* `unsaved`: a NEW script's draft — Test runs it, the case actions wait for a Save. */
|
|
271
|
+
export function renderBench(data, { doc = globalThis.document, projects = [], caseState = new Map(),
|
|
272
|
+
highlight = async (t) => escapeHtml(t), unsaved = false } = {}) { // the default ESCAPES (C11): it feeds two innerHTML sinks
|
|
273
|
+
const root = h(doc, 'div', 'bench');
|
|
274
|
+
root.dataset.scriptKey = metaOf(data).key || '';
|
|
275
|
+
if (unsaved) root.dataset.unsaved = 'true';
|
|
276
|
+
// The param editors this tree mounted, for the controller to destroy (the
|
|
277
|
+
// pane._search idiom; a live debounce on a detached node is a leak).
|
|
278
|
+
const editors = [];
|
|
279
|
+
root._editors = editors;
|
|
280
|
+
root.append(benchBar(doc, projects), setupPanel(doc, data, { highlight, editors, getMeta: () => metaOf(data) }),
|
|
281
|
+
casesStrip(doc, data, caseState, { unsaved }), resultColumn(doc));
|
|
282
|
+
return root;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---- the result ------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
/** `log`, `verdict` and `envelope` are the tabs the result always carries, and
|
|
288
|
+
* all three are legal output port ids (PORT_ID_RE): an output that takes one of
|
|
289
|
+
* those names gets its own pane id, or one tab would unhide two panes. */
|
|
290
|
+
const OUT_PANE = { log: 'log-out', verdict: 'verdict-out', envelope: 'envelope-out' };
|
|
291
|
+
// hasOwn: PORT_ID_RE admits `constructor` / `toString`, which a plain lookup finds
|
|
292
|
+
// on Object.prototype and stringifies into the attribute.
|
|
293
|
+
const paneIdFor = (port) => (Object.hasOwn(OUT_PANE, port) ? OUT_PANE[port] : port);
|
|
294
|
+
|
|
295
|
+
function outputPane(doc, port, out, { highlight, renderMarkdown, outputHref }) {
|
|
296
|
+
const pane = h(doc, 'div', 'bench-pane');
|
|
297
|
+
pane.dataset.rpane = paneIdFor(port);
|
|
298
|
+
pane.hidden = true;
|
|
299
|
+
const body = h(doc, 'div', 'bench-out-body');
|
|
300
|
+
if (out.type === 'md') {
|
|
301
|
+
const raw = h(doc, 'button', 'btn btn-ghost btn-mini bench-raw', 'Raw');
|
|
302
|
+
raw.type = 'button';
|
|
303
|
+
pane.appendChild(raw);
|
|
304
|
+
let showing = 'md';
|
|
305
|
+
const paint = () => {
|
|
306
|
+
body.replaceChildren();
|
|
307
|
+
if (showing === 'md') { void renderMarkdown(out.text || '', body); return; }
|
|
308
|
+
body.appendChild(h(doc, 'pre', 'bench-rawtext', out.text || ''));
|
|
309
|
+
};
|
|
310
|
+
raw.addEventListener('click', () => {
|
|
311
|
+
showing = showing === 'md' ? 'raw' : 'md';
|
|
312
|
+
raw.textContent = showing === 'md' ? 'Raw' : 'Rendered';
|
|
313
|
+
paint();
|
|
314
|
+
});
|
|
315
|
+
paint();
|
|
316
|
+
} else {
|
|
317
|
+
let pretty = String(out.text || '');
|
|
318
|
+
try { pretty = JSON.stringify(JSON.parse(pretty), null, 2); } catch { /* keep the raw text */ }
|
|
319
|
+
const pre = h(doc, 'pre', 'bench-json');
|
|
320
|
+
const code = doc.createElement('code');
|
|
321
|
+
code.textContent = pretty;
|
|
322
|
+
pre.appendChild(code);
|
|
323
|
+
body.appendChild(pre);
|
|
324
|
+
// The ONE innerHTML on this path is the injected highlighter's output (C11).
|
|
325
|
+
void Promise.resolve(highlight(pretty, 'json')).then((html) => { if (typeof html === 'string') code.innerHTML = html; }).catch(() => {});
|
|
326
|
+
}
|
|
327
|
+
pane.appendChild(body);
|
|
328
|
+
// Over the inline cap the WS carried the head only; the full text is a route.
|
|
329
|
+
if (out.truncated) {
|
|
330
|
+
const a = doc.createElement('a');
|
|
331
|
+
a.className = 'bench-open-full'; a.href = outputHref(port); a.target = '_blank'; a.rel = 'noopener';
|
|
332
|
+
a.textContent = 'Open full';
|
|
333
|
+
pane.appendChild(a);
|
|
334
|
+
}
|
|
335
|
+
return pane;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function jsonPane(doc, rpane, value) {
|
|
339
|
+
const pane = h(doc, 'div', 'bench-pane');
|
|
340
|
+
pane.dataset.rpane = rpane;
|
|
341
|
+
pane.hidden = true;
|
|
342
|
+
const pre = h(doc, 'pre', 'bench-json');
|
|
343
|
+
const code = doc.createElement('code');
|
|
344
|
+
code.textContent = typeof value === 'string' ? value : JSON.stringify(value ?? null, null, 2);
|
|
345
|
+
pre.appendChild(code);
|
|
346
|
+
pane.appendChild(pre);
|
|
347
|
+
return pane;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The result half, as a fragment the controller drops into `.bench-result-body`.
|
|
351
|
+
* An execution error is a RESULT (spec §4.1), so it renders here, not as a
|
|
352
|
+
* transport failure. */
|
|
353
|
+
/* `highlight(text, lang)` MUST return ESCAPED html (app.js's scriptHighlight: hljs output, or escapeHtml) —
|
|
354
|
+
* its result is assigned to innerHTML in the json pane. */
|
|
355
|
+
export function renderBenchResult(result, { doc = globalThis.document, highlight, renderMarkdown, outputHref } = {}) {
|
|
356
|
+
const frag = doc.createDocumentFragment();
|
|
357
|
+
const line = h(doc, 'div', 'bench-status');
|
|
358
|
+
const dot = h(doc, 'i', 'bench-dot');
|
|
359
|
+
dot.dataset.status = result.status || 'error';
|
|
360
|
+
line.append(dot, h(doc, 'span', 'bench-status-text', result.status || 'error'));
|
|
361
|
+
if (Number.isInteger(result.exitCode)) line.appendChild(h(doc, 'span', 'chip bench-exit', `exit ${result.exitCode}`));
|
|
362
|
+
if (Number.isFinite(result.durationMs)) line.appendChild(h(doc, 'span', 'chip bench-dur', seconds(result.durationMs)));
|
|
363
|
+
const draft = h(doc, 'span', 'chip bench-draft', 'unsaved draft');
|
|
364
|
+
draft.hidden = result.draft !== true;
|
|
365
|
+
line.appendChild(draft);
|
|
366
|
+
frag.appendChild(line);
|
|
367
|
+
if (result.summary) frag.appendChild(h(doc, 'div', 'bench-summary', result.summary));
|
|
368
|
+
if (Array.isArray(result.fired) && result.fired.length) {
|
|
369
|
+
const fired = h(doc, 'div', 'bench-fired');
|
|
370
|
+
for (const port of result.fired) fired.appendChild(h(doc, 'span', 'chip bench-fired-chip', port));
|
|
371
|
+
frag.appendChild(fired);
|
|
372
|
+
}
|
|
373
|
+
if (result.expect) {
|
|
374
|
+
const box = h(doc, 'div', 'bench-expect');
|
|
375
|
+
box.appendChild(h(doc, 'span', `bench-expect-state ${result.expect.pass ? 'pass' : 'fail'}`, result.expect.pass ? 'expect pass' : 'expect fail'));
|
|
376
|
+
for (const d of result.expect.diffs || []) box.appendChild(h(doc, 'div', 'bench-expect-diff', d));
|
|
377
|
+
frag.appendChild(box);
|
|
378
|
+
}
|
|
379
|
+
if (result.error) {
|
|
380
|
+
frag.appendChild(h(doc, 'div', 'bench-error', result.error.message || ''));
|
|
381
|
+
const tail = h(doc, 'div', 'bench-error-tail');
|
|
382
|
+
for (const t of result.error.tail || []) tail.appendChild(h(doc, 'div', '', t));
|
|
383
|
+
if (tail.childNodes.length) frag.appendChild(tail);
|
|
384
|
+
}
|
|
385
|
+
for (const w of result.warnings || []) frag.appendChild(h(doc, 'div', 'bench-warn', w));
|
|
386
|
+
const tabs = h(doc, 'div', 'seg bench-tabs');
|
|
387
|
+
const panes = [];
|
|
388
|
+
const addTab = (rtab, label) => {
|
|
389
|
+
const b = h(doc, 'button', panes.length === 0 ? 'on' : '', label);
|
|
390
|
+
b.type = 'button'; b.dataset.rtab = rtab;
|
|
391
|
+
b.setAttribute('aria-pressed', panes.length === 0 ? 'true' : 'false');
|
|
392
|
+
tabs.appendChild(b);
|
|
393
|
+
};
|
|
394
|
+
addTab('log', 'Log');
|
|
395
|
+
const logPane = h(doc, 'div', 'bench-pane');
|
|
396
|
+
logPane.dataset.rpane = 'log';
|
|
397
|
+
logPane.appendChild(h(doc, 'pre', 'bench-log'));
|
|
398
|
+
panes.push(logPane);
|
|
399
|
+
for (const [port, out] of Object.entries(result.outputs || {})) {
|
|
400
|
+
if (!out || out.type === 'void') continue; // a void output is a fired chip, nothing more
|
|
401
|
+
addTab(paneIdFor(port), port);
|
|
402
|
+
panes.push(outputPane(doc, port, out, { highlight, renderMarkdown, outputHref }));
|
|
403
|
+
}
|
|
404
|
+
addTab('verdict', 'verdict');
|
|
405
|
+
panes.push(jsonPane(doc, 'verdict', result.verdict));
|
|
406
|
+
addTab('envelope', 'envelope');
|
|
407
|
+
panes.push(jsonPane(doc, 'envelope', result.envelopePath || ''));
|
|
408
|
+
frag.appendChild(tabs);
|
|
409
|
+
panes[0].hidden = false;
|
|
410
|
+
for (const p of panes) frag.appendChild(p);
|
|
411
|
+
return frag;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ---- the request -----------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
/** Read the setup column back as the §4.2 request. Unchecked ports are ABSENT,
|
|
417
|
+
* exactly as an unbound port is absent from a real run's envelope (W2). */
|
|
418
|
+
export function collectBenchRequest(root, data, { draft = null } = {}) {
|
|
419
|
+
const meta = metaOf(data);
|
|
420
|
+
const configPorts = meta.ports === 'config';
|
|
421
|
+
const ports = configPorts ? collectPorts(root.querySelector('.bench-setup')) : null;
|
|
422
|
+
const declared = configPorts ? (ports.inputs || []) : (meta.inputs || []);
|
|
423
|
+
const inputs = {};
|
|
424
|
+
for (const p of declared) {
|
|
425
|
+
const bound = root.querySelector(`[data-field="in:${p.id}:bound"]`);
|
|
426
|
+
if (!bound || !bound.checked) continue;
|
|
427
|
+
if (p.type === 'void') { inputs[p.id] = { fired: true }; continue; }
|
|
428
|
+
const ta = root.querySelector(`[data-field="in:${p.id}:text"]`);
|
|
429
|
+
inputs[p.id] = { text: ta ? ta.value : '' };
|
|
430
|
+
}
|
|
431
|
+
const cwdValue = String((root.querySelector('[data-field="bench:cwd"]') || {}).value || '');
|
|
432
|
+
const cwd = cwdValue.startsWith('project:')
|
|
433
|
+
? { kind: 'project', projectKey: cwdValue.slice('project:'.length) }
|
|
434
|
+
: { kind: 'scratch' };
|
|
435
|
+
const selected = root.querySelector('.bench-case.on');
|
|
436
|
+
return {
|
|
437
|
+
key: meta.key || '',
|
|
438
|
+
caseId: selected ? selected.closest('.bench-case-row').dataset.caseId : null,
|
|
439
|
+
all: false,
|
|
440
|
+
draft,
|
|
441
|
+
params: collectParams(root.querySelector('.bench-setup'), meta).values,
|
|
442
|
+
ports,
|
|
443
|
+
inputs,
|
|
444
|
+
cwd,
|
|
445
|
+
timeoutMs: null,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The declared OUTPUT ports the expect chips are drawn from. */
|
|
450
|
+
function declaredOutputs(root, data) {
|
|
451
|
+
const meta = metaOf(data);
|
|
452
|
+
if (meta.ports !== 'config') return meta.outputs || [];
|
|
453
|
+
const live = collectPorts(root.querySelector('.bench-setup'));
|
|
454
|
+
return live.outputs || [];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Read the setup column back as a stored case (Task 1's `Case`). The request
|
|
458
|
+
* shape is untouched: the bench API knows nothing about expectations —
|
|
459
|
+
* `evaluateExpect` runs against the STORED case, server-side. */
|
|
460
|
+
export function collectCase(root, data, { id, name }) {
|
|
461
|
+
const req = collectBenchRequest(root, data);
|
|
462
|
+
const verdict = String((root.querySelector('[data-field="expect:verdict"]') || {}).value || '');
|
|
463
|
+
let expect = null;
|
|
464
|
+
if (EXPECT_VERDICTS.includes(verdict)) {
|
|
465
|
+
expect = { verdict };
|
|
466
|
+
const fired = [];
|
|
467
|
+
for (const p of declaredOutputs(root, data)) {
|
|
468
|
+
const cb = root.querySelector(`[data-field="expect:fired:${p.id}"]`);
|
|
469
|
+
if (cb && cb.checked) fired.push(p.id);
|
|
470
|
+
}
|
|
471
|
+
// Omitted when nothing is ticked (like `summaryIncludes` when blank):
|
|
472
|
+
// `evaluateExpect` reads an EMPTY list as "nothing may fire", which is not
|
|
473
|
+
// what a bare verdict means.
|
|
474
|
+
if (fired.length) expect.fired = fired;
|
|
475
|
+
const summary = String((root.querySelector('[data-field="expect:summaryIncludes"]') || {}).value || '').trim();
|
|
476
|
+
if (summary) expect.summaryIncludes = summary;
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
id,
|
|
480
|
+
name: String(name || '').trim().slice(0, MAX_CASE_NAME),
|
|
481
|
+
params: req.params,
|
|
482
|
+
ports: req.ports,
|
|
483
|
+
inputs: req.inputs,
|
|
484
|
+
cwd: req.cwd,
|
|
485
|
+
timeoutMs: null,
|
|
486
|
+
expect,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---- the controller --------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
/** A case id slugged from its name and made unique. Always CASE_ID_RE: lowercase,
|
|
493
|
+
* non-alphanumerics collapsed to `-`, a leading letter forced, 64 characters. */
|
|
494
|
+
export function caseIdFrom(name, taken = []) {
|
|
495
|
+
const held = new Set(taken);
|
|
496
|
+
let base = String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
497
|
+
if (!base) base = 'case';
|
|
498
|
+
if (!/^[a-z]/.test(base)) base = `c-${base}`;
|
|
499
|
+
base = base.slice(0, 60).replace(/-+$/, '') || 'case';
|
|
500
|
+
for (let i = 1; ; i += 1) {
|
|
501
|
+
const id = i === 1 ? base : `${base}-${i}`;
|
|
502
|
+
if (!held.has(id) && CASE_ID_RE.test(id)) return id;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Drives one mounted `renderBench` tree: Run / Stop / Run all over the bench
|
|
508
|
+
* routes, the `scriptbench-*` frames filtered by benchId, the case list and the
|
|
509
|
+
* case writes. Returns `onFrame` so the page controller can hand it every frame.
|
|
510
|
+
*/
|
|
511
|
+
export function createBenchController({
|
|
512
|
+
root, data, api, ws = null, onCaseState = () => {}, getDraft = () => null,
|
|
513
|
+
confirm = async () => true, renderMarkdown = async () => {}, highlight = async (t) => escapeHtml(t),
|
|
514
|
+
doc = globalThis.document, modal = null,
|
|
515
|
+
} = {}) {
|
|
516
|
+
// `let`: setMeta swaps the declaration under a mounted tree (S12). Every reader below
|
|
517
|
+
// takes the CURRENT one; nothing captures it.
|
|
518
|
+
let meta = metaOf(data);
|
|
519
|
+
const unsaved = root.dataset.unsaved === 'true';
|
|
520
|
+
// `selected` is the case the Setup column currently holds (null = an ad-hoc
|
|
521
|
+
// setup); `lastResult` is the ONE result `Use result` may copy from — a Run all
|
|
522
|
+
// clears it, because an aggregate is not one result.
|
|
523
|
+
const st = {
|
|
524
|
+
benchId: '', running: false, caseId: null, lines: [], dead: false,
|
|
525
|
+
// Frames can beat the POST answer that names the bench: they wait in `early`.
|
|
526
|
+
// `lastSeq` drops a frame the socket delivers twice (live + the subscribe replay).
|
|
527
|
+
pending: false, early: [], lastSeq: 0,
|
|
528
|
+
// A selected case whose Setup was edited runs AD HOC (what is on screen), not the stored case.
|
|
529
|
+
caseEdited: false, filling: false,
|
|
530
|
+
index: caseListFor(data), selected: null, lastResult: null,
|
|
531
|
+
};
|
|
532
|
+
// Case writes queue behind each other (writeCases): the WHOLE list goes out on
|
|
533
|
+
// every action, so two in flight at once would have the later one overwrite the
|
|
534
|
+
// earlier one's case with a list that never held it.
|
|
535
|
+
let writeChain = Promise.resolve();
|
|
536
|
+
const q = (sel) => root.querySelector(sel);
|
|
537
|
+
const say = (textValue, kind) => { const m = q('.bench-msg'); if (m) { m.textContent = textValue || ''; m.className = 'form-msg bench-msg' + (kind ? ` ${kind}` : ''); } };
|
|
538
|
+
// A Run all keeps one result per case, so `Open full` must name WHICH one; a
|
|
539
|
+
// single run has exactly one and omits the id.
|
|
540
|
+
const hrefFor = (caseId) => (port) => api.benchOutput(st.benchId, port, caseId);
|
|
541
|
+
|
|
542
|
+
function setRunning(on) {
|
|
543
|
+
st.running = on;
|
|
544
|
+
q('.bench-run').disabled = on;
|
|
545
|
+
q('.bench-run-all').disabled = on || unsaved || st.index.cases.length === 0;
|
|
546
|
+
q('.bench-stop').disabled = !on;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function status(text, kind) {
|
|
550
|
+
const dot = q('.bench-dot');
|
|
551
|
+
if (dot) dot.dataset.status = kind || text;
|
|
552
|
+
const label = q('.bench-status-text');
|
|
553
|
+
if (label) label.textContent = text;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function resetResult() {
|
|
557
|
+
st.lines = [];
|
|
558
|
+
const body = q('.bench-result-body');
|
|
559
|
+
body.replaceChildren();
|
|
560
|
+
const line = h(doc, 'div', 'bench-status');
|
|
561
|
+
const dot = h(doc, 'i', 'bench-dot');
|
|
562
|
+
dot.dataset.status = 'running';
|
|
563
|
+
line.append(dot, h(doc, 'span', 'bench-status-text', 'running'));
|
|
564
|
+
body.appendChild(line);
|
|
565
|
+
const tabs = h(doc, 'div', 'seg bench-tabs');
|
|
566
|
+
const b = h(doc, 'button', 'on', 'Log');
|
|
567
|
+
b.type = 'button'; b.dataset.rtab = 'log'; b.setAttribute('aria-pressed', 'true');
|
|
568
|
+
tabs.appendChild(b);
|
|
569
|
+
const pane = h(doc, 'div', 'bench-pane');
|
|
570
|
+
pane.dataset.rpane = 'log';
|
|
571
|
+
pane.appendChild(h(doc, 'pre', 'bench-log'));
|
|
572
|
+
body.append(tabs, pane);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function appendLine(text, caseId) {
|
|
576
|
+
st.lines.push(caseId ? `[${caseId}] ${text}` : text);
|
|
577
|
+
const pre = q('.bench-pane[data-rpane="log"] .bench-log');
|
|
578
|
+
if (pre) pre.textContent = `${st.lines.join('\n')}\n`;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function showResult(result, caseId = null) {
|
|
582
|
+
const body = q('.bench-result-body');
|
|
583
|
+
body.replaceChildren(renderBenchResult(result, { doc, highlight, renderMarkdown, outputHref: hrefFor(caseId) }));
|
|
584
|
+
const pre = q('.bench-pane[data-rpane="log"] .bench-log');
|
|
585
|
+
if (pre) pre.textContent = st.lines.length ? `${st.lines.join('\n')}\n` : '';
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const stateOf = (result) => (result.expect ? (result.expect.pass ? 'pass' : 'fail') : 'ran');
|
|
589
|
+
|
|
590
|
+
function paintCaseDot(caseId, state) {
|
|
591
|
+
const row = q(`.bench-case-row[data-case-id="${caseId}"] .script-dot`);
|
|
592
|
+
if (row) row.dataset.state = state;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** `Use result` is live only while ONE result is on screen. */
|
|
596
|
+
function syncUseResult() {
|
|
597
|
+
const use = q('.bench-expect-from-result');
|
|
598
|
+
if (use) use.disabled = !st.lastResult;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Update case belongs to the selection, so it is added and removed with it —
|
|
602
|
+
* never rendered disabled, which would read as "this case cannot be saved". */
|
|
603
|
+
function syncUpdateButton() {
|
|
604
|
+
const actions = q('.bench-case-actions');
|
|
605
|
+
if (!actions) return;
|
|
606
|
+
const existing = q('.bench-case-update');
|
|
607
|
+
const kase = st.selected ? st.index.cases.find((c) => c.id === st.selected) : null;
|
|
608
|
+
const wanted = Boolean(kase && kase.writable);
|
|
609
|
+
if (wanted && !existing) actions.insertBefore(miniBtn(doc, 'bench-case-update', 'Update case'), q('.bench-add-case'));
|
|
610
|
+
else if (!wanted && existing) existing.remove();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function start(request) {
|
|
614
|
+
resetResult();
|
|
615
|
+
// The pane is blank again, so there is no result left for `Use result` to copy —
|
|
616
|
+
// the button must not keep offering the PREVIOUS run's verdict and chips.
|
|
617
|
+
st.lastResult = null;
|
|
618
|
+
syncUseResult();
|
|
619
|
+
setRunning(true);
|
|
620
|
+
say('');
|
|
621
|
+
st.benchId = ''; st.lastSeq = 0; st.early = []; st.pending = true; st.stopWanted = false;
|
|
622
|
+
const r = await api.bench(request);
|
|
623
|
+
st.pending = false;
|
|
624
|
+
// Torn down while the POST was in flight: destroy() had no benchId to stop yet,
|
|
625
|
+
// so stop it here — or the run holds its per-key slot with no Stop on screen.
|
|
626
|
+
if (st.dead) { if (r.ok && r.data && r.data.benchId) void api.benchStop(r.data.benchId); return; }
|
|
627
|
+
if (!r.ok) {
|
|
628
|
+
st.early = [];
|
|
629
|
+
setRunning(false);
|
|
630
|
+
status('error', 'error');
|
|
631
|
+
const body = q('.bench-result-body');
|
|
632
|
+
body.appendChild(h(doc, 'div', 'bench-error', (r.data && r.data.error) || `HTTP ${r.status}`));
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
st.benchId = r.data.benchId;
|
|
636
|
+
// Stop was pressed while the POST was in flight (it is enabled from the first
|
|
637
|
+
// frame of the click): honour it now the id is known, the way destroy() does.
|
|
638
|
+
if (st.stopWanted) { st.stopWanted = false; void api.benchStop(st.benchId); }
|
|
639
|
+
// The server broadcasts every frame to every socket the moment the bench
|
|
640
|
+
// starts, so a fast script can finish BEFORE this line runs: those frames were
|
|
641
|
+
// parked in `early` and are replayed here, in order. The subscription asks the
|
|
642
|
+
// server to replay its buffer too (it covers a socket that was down during the
|
|
643
|
+
// POST); `lastSeq` keeps whichever copy arrives second out of the log.
|
|
644
|
+
// A page RELOAD does not resume a bench: st.benchId is gone and nothing restores it.
|
|
645
|
+
const early = st.early;
|
|
646
|
+
st.early = [];
|
|
647
|
+
for (const m of early) handle(m);
|
|
648
|
+
if (ws) ws.send({ type: 'subscribe', benchId: st.benchId });
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Stop is live from the moment Run is pressed, so it can land while the POST
|
|
652
|
+
* that names the bench is still in flight: remember the wish and honour it in
|
|
653
|
+
* start(), or the click is a no-op and the child keeps going. */
|
|
654
|
+
function stopBench() {
|
|
655
|
+
if (st.pending) { st.stopWanted = true; return; }
|
|
656
|
+
if (st.benchId) void api.benchStop(st.benchId);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function run() {
|
|
660
|
+
const request = collectBenchRequest(root, data, { draft: getDraft() });
|
|
661
|
+
// §4.2: with a caseId the engine runs the STORED case and ignores every loose
|
|
662
|
+
// field. So a selected case whose Setup was edited is sent WITHOUT its id — it
|
|
663
|
+
// runs what is on screen, its dot is left alone, and the Expect row is judged
|
|
664
|
+
// here (below). Update case is what commits the edit.
|
|
665
|
+
if (request.caseId && st.caseEdited) request.caseId = null;
|
|
666
|
+
st.caseId = request.caseId;
|
|
667
|
+
void start(request);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function runAll() {
|
|
671
|
+
if (unsaved) return;
|
|
672
|
+
st.caseId = null;
|
|
673
|
+
// The draft rides along exactly as it does on Run (W10): without it every case
|
|
674
|
+
// would be checked against the SAVED program while the editor shows another
|
|
675
|
+
// one, and the result would carry no `unsaved draft` chip to say so.
|
|
676
|
+
void start({ key: meta.key, caseId: null, all: true, draft: getDraft(), params: {}, ports: null, inputs: {}, cwd: { kind: 'scratch' }, timeoutMs: null });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** A `ports: "config"` script declares its set PER RUN, so the Setup column
|
|
680
|
+
* carries its own port editor — the page's lives on Overview and owns the
|
|
681
|
+
* sidecar. Repaint the editor (only when a row was added or removed) and the
|
|
682
|
+
* Inputs rows from it, keeping whatever was typed for a port that kept its id,
|
|
683
|
+
* and re-read the Expect chips off the new outputs. */
|
|
684
|
+
/** Repaint the input rows (and, for a `ports: "config"` script, its per-run port
|
|
685
|
+
* editor) from a new port set, keeping whatever was typed for a port that kept its
|
|
686
|
+
* id, and re-read the Expect chips off the new outputs. Without an editor (a
|
|
687
|
+
* sidecar-ported script) the set comes from the live declaration. */
|
|
688
|
+
function syncPorts({ edit = null, ports = null } = {}) {
|
|
689
|
+
const setup = q('.bench-setup');
|
|
690
|
+
if (!setup) return;
|
|
691
|
+
const editor = setup.querySelector('.ins-port-editor');
|
|
692
|
+
const held = [...root.querySelectorAll('.bench-port')].map((r) => {
|
|
693
|
+
const bound = r.querySelector(`[data-field="in:${r.dataset.port}:bound"]`);
|
|
694
|
+
const ta = r.querySelector(`[data-field="in:${r.dataset.port}:text"]`);
|
|
695
|
+
return { id: r.dataset.port, bound: Boolean(bound && bound.checked), text: ta ? ta.value : '' };
|
|
696
|
+
});
|
|
697
|
+
const next = ports
|
|
698
|
+
|| (editor ? (edit ? applyPortEdit(collectPorts(setup), edit) : collectPorts(setup)) : { inputs: meta.inputs || [], outputs: meta.outputs || [] });
|
|
699
|
+
const reshaped = Boolean(edit || ports);
|
|
700
|
+
if (reshaped && editor) editor.replaceWith(renderPortEditor(next, { doc, hasVerdict: Boolean(meta.verdict) }));
|
|
701
|
+
q('.bench-inputs').replaceWith(inputRows(doc, next.inputs || []));
|
|
702
|
+
// A row added or removed keeps every other port's ID, so match by id; a plain
|
|
703
|
+
// re-read after an id was EDITED matches by position, or the text typed for the
|
|
704
|
+
// renamed port would be dropped on the floor.
|
|
705
|
+
(next.inputs || []).forEach((p, i) => {
|
|
706
|
+
const was = reshaped ? held.find((x) => x.id === p.id) : held[i];
|
|
707
|
+
if (!was) return;
|
|
708
|
+
const bound = q(`[data-field="in:${p.id}:bound"]`);
|
|
709
|
+
const ta = q(`[data-field="in:${p.id}:text"]`);
|
|
710
|
+
if (bound) bound.checked = was.bound;
|
|
711
|
+
if (ta) ta.value = was.text;
|
|
712
|
+
});
|
|
713
|
+
fillExpect(collectCase(root, data, { id: 'x', name: 'x' }).expect);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* The page's declaration changed under this tree (a keystroke in the editor, a
|
|
718
|
+
* chip click): re-render the params form and the input rows from it, keeping
|
|
719
|
+
* what was typed by id, and re-read the Expect chips. NEVER a remount — the
|
|
720
|
+
* result pane, the selected case and a run in flight all stay (S12).
|
|
721
|
+
*/
|
|
722
|
+
function setMeta(next) {
|
|
723
|
+
if (!next || typeof next !== 'object' || st.dead) return;
|
|
724
|
+
meta = next;
|
|
725
|
+
data.meta = next; // collectBenchRequest, declaredOutputs and caseRows read data.meta
|
|
726
|
+
root.dataset.scriptKey = meta.key || '';
|
|
727
|
+
if (meta.ports === 'config') return; // per-run ports live in the setup's own editor; the sidecar's params are fixed
|
|
728
|
+
st.filling = true; // the synthetic input events below are not the user's
|
|
729
|
+
try {
|
|
730
|
+
const pbox = q('.bench-params');
|
|
731
|
+
if (pbox) {
|
|
732
|
+
const held = {};
|
|
733
|
+
for (const n of pbox.querySelectorAll('[data-field^="param:"]')) held[n.dataset.field.slice('param:'.length)] = n.type === 'checkbox' ? n.checked : n.value;
|
|
734
|
+
if (Array.isArray(root._editors)) { for (const ed of root._editors) ed.destroy(); root._editors.length = 0; }
|
|
735
|
+
pbox.replaceChildren(renderParamsForm(meta, {}, { doc, editorFor: paramEditorHook({ doc, highlight, editors: root._editors }) }));
|
|
736
|
+
for (const p of (Array.isArray(meta.params) ? meta.params : [])) {
|
|
737
|
+
const n = pbox.querySelector(`[data-field="param:${p.id}"]`);
|
|
738
|
+
if (!n || held[p.id] === undefined) continue;
|
|
739
|
+
if (n.type === 'checkbox') n.checked = held[p.id] === true;
|
|
740
|
+
else { n.value = String(held[p.id]); n.dispatchEvent(new (doc.defaultView || globalThis).Event('input', { bubbles: true })); }
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
syncPorts({ ports: { inputs: meta.inputs || [], outputs: meta.outputs || [] } });
|
|
744
|
+
} finally {
|
|
745
|
+
st.filling = false;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function fillFromCase(kase) {
|
|
750
|
+
st.filling = true; // the synthetic input events below are not the user's
|
|
751
|
+
try { fillFromCaseNow(kase); } finally { st.filling = false; }
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function fillFromCaseNow(kase) {
|
|
755
|
+
// A config-ported case carries its own port set (Task 1): restore it BEFORE
|
|
756
|
+
// the bindings below, or the case would run against whatever set the previous
|
|
757
|
+
// selection left behind.
|
|
758
|
+
if (meta.ports === 'config') {
|
|
759
|
+
syncPorts({ ports: (kase && kase.ports) || meta.defaultPorts || { inputs: [], outputs: [] } });
|
|
760
|
+
}
|
|
761
|
+
for (const row of root.querySelectorAll('.bench-port')) {
|
|
762
|
+
const port = row.dataset.port;
|
|
763
|
+
const bound = row.querySelector(`[data-field="in:${port}:bound"]`);
|
|
764
|
+
const ta = row.querySelector(`[data-field="in:${port}:text"]`);
|
|
765
|
+
const value = kase && kase.inputs ? kase.inputs[port] : null;
|
|
766
|
+
if (bound) bound.checked = Boolean(value);
|
|
767
|
+
if (ta) ta.value = value && typeof value.text === 'string' ? value.text : '';
|
|
768
|
+
}
|
|
769
|
+
// EVERY declared param is reset — to the case's value, else the sidecar default —
|
|
770
|
+
// or a value left by the previous case would ride along into this one.
|
|
771
|
+
for (const p of (Array.isArray(meta.params) ? meta.params : [])) {
|
|
772
|
+
const id = p.id;
|
|
773
|
+
const given = kase && kase.params ? kase.params[id] : undefined;
|
|
774
|
+
const value = given !== undefined ? given : (p.default !== undefined ? p.default : '');
|
|
775
|
+
const node = root.querySelector(`[data-field="param:${id}"]`);
|
|
776
|
+
if (!node) continue;
|
|
777
|
+
if (node.type === 'checkbox') node.checked = value === true; else node.value = String(value);
|
|
778
|
+
// A command/code param is a code editor's textarea: writing `.value` skips
|
|
779
|
+
// the highlighted layer, so tell it the same way a keystroke would.
|
|
780
|
+
node.dispatchEvent(new (doc.defaultView || globalThis).Event('input', { bubbles: true }));
|
|
781
|
+
}
|
|
782
|
+
const cwd = q('[data-field="bench:cwd"]');
|
|
783
|
+
if (cwd) cwd.value = kase && kase.cwd && kase.cwd.kind === 'project' ? `project:${kase.cwd.projectKey}` : '';
|
|
784
|
+
const name = q('[data-field="bench:caseName"]');
|
|
785
|
+
if (name) name.value = kase ? (kase.name || '') : '';
|
|
786
|
+
fillExpect(kase && kase.expect ? kase.expect : null);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** The expect row follows the verdict: `null` collapses it to `—` and drops the
|
|
790
|
+
* chips, so there is nothing stale left to read back. */
|
|
791
|
+
function fillExpect(expect) {
|
|
792
|
+
const rowEl = q('.bench-expect-row');
|
|
793
|
+
if (rowEl && rowEl._fill) rowEl._fill(expect);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** The verdict select's own change: keep whatever chips are ticked. */
|
|
797
|
+
function onVerdictChanged() {
|
|
798
|
+
const rowEl = q('.bench-expect-row');
|
|
799
|
+
const verdict = rowEl.querySelector('[data-field="expect:verdict"]').value;
|
|
800
|
+
const kept = collectCase(root, data, { id: 'x', name: 'x' }).expect;
|
|
801
|
+
fillExpect(verdict ? { verdict, fired: (kept && kept.fired) || [], summaryIncludes: (kept && kept.summaryIncludes) || '' } : null);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** `Use result`: the verdict from the result's status (only when it is one the
|
|
805
|
+
* case format can express) and the chips from what actually fired. The summary
|
|
806
|
+
* is the author's sentence, so it is never overwritten. */
|
|
807
|
+
function useResult() {
|
|
808
|
+
const result = st.lastResult;
|
|
809
|
+
if (!result) return;
|
|
810
|
+
const current = collectCase(root, data, { id: 'x', name: 'x' }).expect;
|
|
811
|
+
const verdict = EXPECT_VERDICTS.includes(result.status) ? result.status : (current && current.verdict) || '';
|
|
812
|
+
if (!verdict) return;
|
|
813
|
+
const declared = new Set(declaredOutputs(root, data).map((p) => p.id));
|
|
814
|
+
fillExpect({
|
|
815
|
+
verdict,
|
|
816
|
+
fired: (result.fired || []).filter((p) => declared.has(p)),
|
|
817
|
+
summaryIncludes: (current && current.summaryIncludes) || '',
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function markEdited(on) {
|
|
822
|
+
st.caseEdited = on;
|
|
823
|
+
for (const row of root.querySelectorAll('.bench-case-row')) {
|
|
824
|
+
if (on && row.dataset.caseId === st.selected) row.dataset.edited = 'true'; else delete row.dataset.edited;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function selectCase(caseId) {
|
|
829
|
+
st.selected = caseId || null;
|
|
830
|
+
markEdited(false);
|
|
831
|
+
for (const row of root.querySelectorAll('.bench-case-row')) {
|
|
832
|
+
row.querySelector('.bench-case').classList.toggle('on', row.dataset.caseId === caseId);
|
|
833
|
+
}
|
|
834
|
+
fillFromCase(st.index.cases.find((c) => c.id === caseId) || null);
|
|
835
|
+
syncUpdateButton();
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function clearCase() {
|
|
839
|
+
selectCase('');
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Re-apply the selection to freshly painted rows WITHOUT re-filling the Setup
|
|
843
|
+
* column — a write that does not move the selection (a rename, a delete of
|
|
844
|
+
* another case) must not throw away a typed ad-hoc setup or an edit in
|
|
845
|
+
* progress on the selected one. */
|
|
846
|
+
function reselect(caseId) {
|
|
847
|
+
for (const row of root.querySelectorAll('.bench-case-row')) {
|
|
848
|
+
row.querySelector('.bench-case').classList.toggle('on', row.dataset.caseId === caseId);
|
|
849
|
+
if (st.caseEdited && caseId && row.dataset.caseId === caseId) row.dataset.edited = 'true';
|
|
850
|
+
}
|
|
851
|
+
syncUpdateButton();
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Repaint the rows from `data` and keep the dots that are still meaningful. */
|
|
855
|
+
function repaintCases(dots = new Map()) {
|
|
856
|
+
st.index = caseListFor(data);
|
|
857
|
+
const fresh = caseRows(doc, data, new Map([[meta.key, dots]]));
|
|
858
|
+
q('.bench-case-list').replaceWith(fresh);
|
|
859
|
+
q('.bench-run-all').disabled = st.running || st.index.cases.length === 0;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const currentDots = () => new Map([...root.querySelectorAll('.bench-case-row')]
|
|
863
|
+
.map((r) => [r.dataset.caseId, r.querySelector('.script-dot').dataset.state]));
|
|
864
|
+
|
|
865
|
+
/** The ONE write path: every case action sends the WHOLE writable list (the
|
|
866
|
+
* user's own file, or the W18 overlay for a builtin/plugin script). Writes are
|
|
867
|
+
* SERIAL and each one REBASES: `mutate` is applied to the list only once the
|
|
868
|
+
* previous write has landed. Applying it earlier sent a list read before that
|
|
869
|
+
* write — a rename fired while Save as case was in flight dropped the new case.
|
|
870
|
+
* `mutate` may answer null to write nothing. */
|
|
871
|
+
function writeCases(mutate, { select = null, dots = null } = {}) {
|
|
872
|
+
const write = async () => {
|
|
873
|
+
if (st.dead) return false;
|
|
874
|
+
const cases = mutate(writableNow());
|
|
875
|
+
if (!cases) return false;
|
|
876
|
+
const r = await api.writeCases(meta.key, cases);
|
|
877
|
+
if (!r.ok) { if (!st.dead) say((r.data && r.data.error) || `HTTP ${r.status}`, 'err'); return false; }
|
|
878
|
+
// The write LANDED, so `data` has to follow even when the tab was left while it
|
|
879
|
+
// was in flight (destroy()): it is what the next mount paints and what the next
|
|
880
|
+
// case action sends as the WHOLE list — a stale copy silently undoes this write.
|
|
881
|
+
data[st.index.writableIndex] = Array.isArray(r.data.cases) ? r.data.cases : cases;
|
|
882
|
+
if (st.dead) return false;
|
|
883
|
+
repaintCases(dots || currentDots());
|
|
884
|
+
const want = select === null ? (st.selected || '') : select;
|
|
885
|
+
if (want === (st.selected || '')) reselect(want); else selectCase(want);
|
|
886
|
+
say('');
|
|
887
|
+
return true;
|
|
888
|
+
};
|
|
889
|
+
const next = writeChain.then(write, write);
|
|
890
|
+
writeChain = next.then(() => {}, () => {});
|
|
891
|
+
return next;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const writableNow = () => (Array.isArray(data[st.index.writableIndex]) ? data[st.index.writableIndex] : []);
|
|
895
|
+
const typedName = () => String((q('[data-field="bench:caseName"]') || {}).value || '').trim();
|
|
896
|
+
|
|
897
|
+
async function saveCase() {
|
|
898
|
+
if (unsaved) return;
|
|
899
|
+
const name = typedName();
|
|
900
|
+
if (!name) { say('Name the case first.', 'err'); return; }
|
|
901
|
+
if (writableNow().length >= MAX_CASES) { say(`${MAX_CASES} cases is the limit.`, 'err'); return; }
|
|
902
|
+
const id = caseIdFrom(name, st.index.cases.map((c) => c.id));
|
|
903
|
+
const kase = collectCase(root, data, { id, name });
|
|
904
|
+
// A double-clicked Save is ONE case: by the time the second write runs the id
|
|
905
|
+
// is already there, and a duplicate id is a refusal from the store.
|
|
906
|
+
await writeCases((list) => (list.some((c) => c.id === id) ? null : [...list, kase]), { select: id });
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Update replaces the selected case, so the cap never blocks it. Its dot is
|
|
910
|
+
* reset: the stored case is no longer the one that produced that result. */
|
|
911
|
+
async function updateCase() {
|
|
912
|
+
if (unsaved) return;
|
|
913
|
+
const id = st.selected;
|
|
914
|
+
const kase = id ? st.index.cases.find((c) => c.id === id) : null;
|
|
915
|
+
if (!kase || !kase.writable) return;
|
|
916
|
+
const name = typedName() || kase.name;
|
|
917
|
+
if (!name) { say('Name the case first.', 'err'); return; }
|
|
918
|
+
const built = collectCase(root, data, { id, name });
|
|
919
|
+
const dots = currentDots();
|
|
920
|
+
dots.set(id, 'none');
|
|
921
|
+
if (await writeCases((list) => list.map((c) => (c.id === id ? built : c)), { select: id, dots })) {
|
|
922
|
+
markEdited(false); onCaseState(meta.key, id, 'none');
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function renameCase(id, name) {
|
|
927
|
+
if (!name) { say('Name the case first.', 'err'); return; }
|
|
928
|
+
await writeCases((list) => list.map((c) => (c.id === id ? { ...c, name: name.slice(0, MAX_CASE_NAME) } : c)));
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function deleteCase(id) {
|
|
932
|
+
const kase = st.index.cases.find((c) => c.id === id);
|
|
933
|
+
if (!kase || !kase.writable) return;
|
|
934
|
+
const okToGo = await confirm({
|
|
935
|
+
title: 'Delete case', message: `Delete case "${kase.name || id}"?`,
|
|
936
|
+
confirmLabel: 'Delete', danger: true,
|
|
937
|
+
});
|
|
938
|
+
if (!okToGo || st.dead) return;
|
|
939
|
+
const dots = currentDots();
|
|
940
|
+
dots.delete(id);
|
|
941
|
+
if (await writeCases((list) => list.filter((c) => c.id !== id), { select: st.selected === id ? '' : st.selected, dots })) {
|
|
942
|
+
// W15: the list card's dot counts the cases that ran THIS session. A case that
|
|
943
|
+
// is gone must stop colouring it — otherwise deleting the one that failed
|
|
944
|
+
// leaves the card red for a case nobody can run any more.
|
|
945
|
+
onCaseState(meta.key, id, 'none');
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** Inline rename: Enter commits, Escape cancels, blur commits. No prompt. */
|
|
950
|
+
function beginRename(row) {
|
|
951
|
+
if (row.querySelector('.bench-case-rename-input')) return;
|
|
952
|
+
const id = row.dataset.caseId;
|
|
953
|
+
const label = row.querySelector('.bench-case-name');
|
|
954
|
+
const box = doc.createElement('input');
|
|
955
|
+
box.type = 'text'; box.className = 'input bench-case-rename-input';
|
|
956
|
+
box.value = label.textContent; box.maxLength = MAX_CASE_NAME;
|
|
957
|
+
box.setAttribute('aria-label', 'Case name');
|
|
958
|
+
let settled = false;
|
|
959
|
+
const finish = (commit) => {
|
|
960
|
+
if (settled) return;
|
|
961
|
+
settled = true;
|
|
962
|
+
const value = box.value.trim();
|
|
963
|
+
box.replaceWith(label);
|
|
964
|
+
if (commit) void renameCase(id, value);
|
|
965
|
+
};
|
|
966
|
+
box.addEventListener('keydown', (ev) => {
|
|
967
|
+
if (ev.key === 'Enter') { ev.preventDefault(); finish(true); }
|
|
968
|
+
else if (ev.key === 'Escape') { ev.preventDefault(); finish(false); }
|
|
969
|
+
});
|
|
970
|
+
box.addEventListener('blur', () => finish(true));
|
|
971
|
+
label.replaceWith(box);
|
|
972
|
+
if (typeof box.focus === 'function') box.focus();
|
|
973
|
+
if (typeof box.select === 'function') box.select();
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** A picked file or artifact lands as TEXT in the port and ticks its box (W9). */
|
|
977
|
+
function fillPort(port, { text, label }) {
|
|
978
|
+
const id = port.dataset.port;
|
|
979
|
+
const ta = port.querySelector(`[data-field="in:${id}:text"]`);
|
|
980
|
+
const bound = port.querySelector(`[data-field="in:${id}:bound"]`);
|
|
981
|
+
if (ta) ta.value = text;
|
|
982
|
+
if (bound) bound.checked = true;
|
|
983
|
+
const name = port.querySelector('.bench-src-label');
|
|
984
|
+
if (name) name.textContent = label;
|
|
985
|
+
if (st.selected) markEdited(true);
|
|
986
|
+
say('');
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async function pickFromRun(port) {
|
|
990
|
+
if (!modal) return;
|
|
991
|
+
const types = port.dataset.type === 'json' ? ['json'] : ['md'];
|
|
992
|
+
const got = await openArtifactPicker({ doc, api, types, modal });
|
|
993
|
+
if (st.dead || !got) return;
|
|
994
|
+
fillPort(port, got);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
async function onFileChosen(input) {
|
|
998
|
+
const port = input.closest('.bench-port');
|
|
999
|
+
const file = input.files && input.files[0];
|
|
1000
|
+
input.value = ''; // the same file can be picked twice
|
|
1001
|
+
if (!file) return;
|
|
1002
|
+
try {
|
|
1003
|
+
fillPort(port, await readInputFile(file, { FileReaderImpl: (doc.defaultView || globalThis).FileReader }));
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
say(err.message, 'err');
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function onClick(e) {
|
|
1010
|
+
const t = e.target;
|
|
1011
|
+
const hit = (cls) => (t.closest ? t.closest(`.${cls}`) : null);
|
|
1012
|
+
if (hit('bench-run')) { run(); return; }
|
|
1013
|
+
if (hit('bench-run-all')) { runAll(); return; }
|
|
1014
|
+
if (hit('bench-stop')) { stopBench(); return; }
|
|
1015
|
+
if (hit('bench-save-case')) { void saveCase(); return; }
|
|
1016
|
+
if (hit('bench-case-update')) { void updateCase(); return; }
|
|
1017
|
+
if (hit('bench-add-case')) { clearCase(); return; }
|
|
1018
|
+
if (hit('bench-expect-from-result')) { useResult(); return; }
|
|
1019
|
+
// The Setup column's port editor is the BENCH's, not the sidecar's: keep these
|
|
1020
|
+
// clicks off the page controller, whose own handler would edit `data.meta`.
|
|
1021
|
+
const pedit = t.closest ? t.closest('[data-port-add],[data-port-remove]') : null;
|
|
1022
|
+
if (pedit && pedit.closest('.bench-setup')) {
|
|
1023
|
+
e.stopPropagation();
|
|
1024
|
+
syncPorts({ edit: pedit.dataset.portAdd ? { add: pedit.dataset.portAdd } : { remove: pedit.dataset.portRemove } });
|
|
1025
|
+
if (st.selected) markEdited(true);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
const row = hit('bench-case-row');
|
|
1029
|
+
if (row) {
|
|
1030
|
+
if (hit('bench-case-rename')) { beginRename(row); return; }
|
|
1031
|
+
if (hit('bench-case-delete')) { void deleteCase(row.dataset.caseId); return; }
|
|
1032
|
+
if (hit('bench-case')) { selectCase(row.dataset.caseId); return; }
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
const src = t.closest ? t.closest('[data-in-src]') : null;
|
|
1036
|
+
if (src && src.tagName === 'BUTTON') {
|
|
1037
|
+
const port = src.closest('.bench-port');
|
|
1038
|
+
if (src.dataset.inSrc === 'file') { port.querySelector('.bench-file').click(); return; }
|
|
1039
|
+
if (src.dataset.inSrc === 'run') { void pickFromRun(port); return; }
|
|
1040
|
+
return; // Text is the resting state
|
|
1041
|
+
}
|
|
1042
|
+
const tab = t.closest ? t.closest('.bench-tabs button[data-rtab]') : null;
|
|
1043
|
+
if (!tab) return;
|
|
1044
|
+
for (const b of root.querySelectorAll('.bench-tabs button[data-rtab]')) {
|
|
1045
|
+
const on = b === tab;
|
|
1046
|
+
b.classList.toggle('on', on);
|
|
1047
|
+
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
1048
|
+
}
|
|
1049
|
+
for (const p of root.querySelectorAll('.bench-pane')) p.hidden = p.dataset.rpane !== tab.dataset.rtab;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function onChange(e) {
|
|
1053
|
+
onSetupEdit(e); // a select or a checkbox edits the Setup too
|
|
1054
|
+
const field = (e.target.dataset && e.target.dataset.field) || '';
|
|
1055
|
+
// On `change`, not on every keystroke: a port id is committed on blur, so the
|
|
1056
|
+
// field the user is typing in is never pulled out from under them.
|
|
1057
|
+
if (/^port:(inputs|outputs):/.test(field) && e.target.closest('.bench-setup')) {
|
|
1058
|
+
// This editor is the BENCH's, not the sidecar's: keep the change off the page
|
|
1059
|
+
// controller, which reads `port:*:type` as structural and would repaint the
|
|
1060
|
+
// whole page (remounting this bench). Its own `.script-test-mount` guard cannot
|
|
1061
|
+
// do it — the repaint below detaches the select, so `closest` finds nothing.
|
|
1062
|
+
// The [data-port-add] click has the same rule.
|
|
1063
|
+
e.stopPropagation();
|
|
1064
|
+
// A TYPE change RESHAPES the row — renderPortEditor hides an output's filename
|
|
1065
|
+
// box for `void` — so the editor is repainted, exactly as the Overview form
|
|
1066
|
+
// does it. Every other field keeps the editor as it stands.
|
|
1067
|
+
syncPorts(/:type$/.test(field) ? { ports: collectPorts(q('.bench-setup')) } : {});
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
if (e.target.dataset && e.target.dataset.field === 'expect:verdict') { onVerdictChanged(); return; }
|
|
1071
|
+
if (e.target.classList && e.target.classList.contains('bench-file')) void onFileChosen(e.target);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** One scriptbench-* frame of THIS bench (early frames arrive through start()). */
|
|
1075
|
+
function handle(msg) {
|
|
1076
|
+
if (st.dead || !msg || !st.benchId || msg.benchId !== st.benchId) return;
|
|
1077
|
+
if (Number.isInteger(msg.seq)) { if (msg.seq <= st.lastSeq) return; st.lastSeq = msg.seq; }
|
|
1078
|
+
if (msg.type === 'scriptbench-line') { appendLine(String(msg.text ?? ''), msg.caseId || null); return; }
|
|
1079
|
+
if (msg.type === 'scriptbench-error') {
|
|
1080
|
+
// Transport only (unknown key, a bad request, or BUSY — the 2-global /
|
|
1081
|
+
// 1-per-key cap, which can only surface here because the POST already
|
|
1082
|
+
// answered { benchId }). Run comes back either way.
|
|
1083
|
+
setRunning(false);
|
|
1084
|
+
status('error', 'error');
|
|
1085
|
+
q('.bench-result-body').appendChild(h(doc, 'div', 'bench-error', msg.message || ''));
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
if (msg.type !== 'scriptbench-done') return;
|
|
1089
|
+
setRunning(false);
|
|
1090
|
+
const result = msg.result || {};
|
|
1091
|
+
if (Array.isArray(result.cases)) {
|
|
1092
|
+
for (const row of result.cases) {
|
|
1093
|
+
const state = stateOf(row.result || {});
|
|
1094
|
+
paintCaseDot(row.caseId, state);
|
|
1095
|
+
onCaseState(meta.key, row.caseId, state);
|
|
1096
|
+
}
|
|
1097
|
+
const last = result.cases.length ? result.cases[result.cases.length - 1] : null;
|
|
1098
|
+
showResult(last ? last.result : {}, last ? last.caseId : null);
|
|
1099
|
+
status(`passed ${result.passed || 0} · failed ${result.failed || 0} · unchecked ${result.unchecked || 0}`, 'multi');
|
|
1100
|
+
// An aggregate is not ONE result, so there is nothing for `Use result`
|
|
1101
|
+
// to copy — it goes back to disabled.
|
|
1102
|
+
st.lastResult = null;
|
|
1103
|
+
syncUseResult();
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
// An ad-hoc run carries no case, so the ENGINE has nothing to check against
|
|
1107
|
+
// (§4.2: only a stored case supplies an expectation). The Expect row that is
|
|
1108
|
+
// on screen is evaluated here with the SAME pure function the server uses.
|
|
1109
|
+
const shown = (!st.caseId && !result.expect)
|
|
1110
|
+
? { ...result, expect: evaluateExpect(collectCase(root, data, { id: 'x', name: 'x' }).expect, result) }
|
|
1111
|
+
: result;
|
|
1112
|
+
showResult(shown);
|
|
1113
|
+
st.lastResult = shown;
|
|
1114
|
+
syncUseResult();
|
|
1115
|
+
const state = stateOf(shown);
|
|
1116
|
+
if (st.caseId) paintCaseDot(st.caseId, state);
|
|
1117
|
+
onCaseState(meta.key, st.caseId, state);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** A user edit anywhere in the Setup column while a case is selected: from now
|
|
1121
|
+
* on Run sends what is on screen (see run()). fillFromCase's own synthetic
|
|
1122
|
+
* events are not the user's. */
|
|
1123
|
+
function onSetupEdit(e) {
|
|
1124
|
+
if (st.filling || !st.selected || st.caseEdited) return;
|
|
1125
|
+
if (e.target.closest && e.target.closest('.bench-setup')) markEdited(true);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
root.addEventListener('click', onClick);
|
|
1129
|
+
root.addEventListener('change', onChange);
|
|
1130
|
+
root.addEventListener('input', onSetupEdit);
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
return {
|
|
1134
|
+
onFrame(msg) {
|
|
1135
|
+
if (st.dead || !msg) return;
|
|
1136
|
+
if (st.pending) { if (st.early.length < 5000) st.early.push(msg); return; }
|
|
1137
|
+
handle(msg);
|
|
1138
|
+
},
|
|
1139
|
+
setMeta,
|
|
1140
|
+
run,
|
|
1141
|
+
stop: stopBench,
|
|
1142
|
+
destroy() {
|
|
1143
|
+
// A tab hop or a view change tears this controller down, and nothing else
|
|
1144
|
+
// shows a bench: an unstopped run would hold its per-key slot with no Stop
|
|
1145
|
+
// anywhere on screen, and the next Run would come back BUSY for ever.
|
|
1146
|
+
if (st.running && st.benchId) void api.benchStop(st.benchId);
|
|
1147
|
+
st.dead = true;
|
|
1148
|
+
root.removeEventListener('click', onClick);
|
|
1149
|
+
root.removeEventListener('change', onChange);
|
|
1150
|
+
root.removeEventListener('input', onSetupEdit);
|
|
1151
|
+
if (Array.isArray(root._editors)) { for (const ed of root._editors) ed.destroy(); root._editors.length = 0; }
|
|
1152
|
+
},
|
|
1153
|
+
};
|
|
1154
|
+
}
|