@bongos/core 1.19.790 → 1.19.791
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/.bongos-core.json +46 -26
- package/docs/copy-inventory.md +59 -59
- package/docs/copy-registry.json +68 -68
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/hall-kit.js +7 -65
- package/modules/hall-ui/public/hall-render.js +1 -1
- package/modules/hall-ui/public/hall-visual.js +85 -0
- package/modules/hall-ui/public/index.html +1 -0
- package/modules/hall-ui/public/profile.html +1 -0
- package/modules/hall-ui/public/profile.js +2 -2
- package/modules/hall-ui/public/studio.css +1 -1
- package/modules/hall-ui/public/studio.html +1 -0
- package/modules/hall-ui/public/studio.js +4 -3
- package/modules/hall-ui/public/style.css +0 -16
- package/modules/hall-ui/public/task.html +1 -0
- package/modules/hall-ui/public/task.js +10 -13
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/accessibility-replay.js +195 -0
- package/src/module-api.js +1 -1
- package/tests/accessibility_replay.mjs +201 -0
- package/tests/fixtures/accessibility-replay.json +93 -0
- package/tests/hall_studio_world.mjs +19 -11
- package/tests/task_visuals.mjs +91 -27
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// tests/accessibility_replay.mjs
|
|
2
|
+
//
|
|
3
|
+
// The two-profile replay regression (task 1002517; clauses 5 and 8 of criterion
|
|
4
|
+
// `accessibility-fluency-profile`) — the proof the criterion named for itself
|
|
5
|
+
// and that had never been run.
|
|
6
|
+
//
|
|
7
|
+
// This is NOT a second copy of tests/session_accessibility.mjs. That file pins
|
|
8
|
+
// the scorer's arithmetic against fixtures it builds line by line. This one runs
|
|
9
|
+
// a fixed set of eight beginner questions through two real answer registers and
|
|
10
|
+
// asserts what the criterion actually claims: accessible prose clears a floor,
|
|
11
|
+
// an expert's prose is not scored for being an expert's prose, and the two
|
|
12
|
+
// verdicts come from the same corpus — so neither is a tautology.
|
|
13
|
+
//
|
|
14
|
+
// The assertion that keeps the rest honest is DISCRIMINATION (leg 3). A scorer
|
|
15
|
+
// that had gone blind — three detectors quietly matching nothing — produces a
|
|
16
|
+
// perfect 10 on the non-technical run and passes leg 1 looking exactly like a
|
|
17
|
+
// working one. Leg 3 reads the same engineer prose as a newcomer would and
|
|
18
|
+
// requires every detector to register hits and the score to fall through the
|
|
19
|
+
// floor. Nothing else here can pass while the detectors are dead.
|
|
20
|
+
//
|
|
21
|
+
// Run: node tests/accessibility_replay.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const H = require('../scripts/gds/accessibility-replay.js');
|
|
28
|
+
const acc = require('../modules/sessions/accessibility.js');
|
|
29
|
+
|
|
30
|
+
const corpus = H.loadCorpus();
|
|
31
|
+
const NEWCOMER = corpus.runs.non_technical.profile;
|
|
32
|
+
|
|
33
|
+
let passed = 0;
|
|
34
|
+
let failed = 0;
|
|
35
|
+
function test(name, fn) {
|
|
36
|
+
try {
|
|
37
|
+
fn();
|
|
38
|
+
passed++;
|
|
39
|
+
console.log(` ok ${name}`);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
failed++;
|
|
42
|
+
console.log(` FAIL ${name}`);
|
|
43
|
+
console.log(` ${err.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const signalsOf = (r) => Object.fromEntries(r.result.signals.map((s) => [s.type, s]));
|
|
48
|
+
|
|
49
|
+
// ---- the floor is the scorer's own line, not a second number ---------------
|
|
50
|
+
|
|
51
|
+
test('FLOOR is bandFor()\'s workable boundary, so retuning the bands cannot move it silently', () => {
|
|
52
|
+
assert.equal(H.bandFor(H.FLOOR), 'workable', 'FLOOR sits exactly on the workable boundary');
|
|
53
|
+
assert.equal(H.bandFor(H.FLOOR - 0.1), 'dense', 'a tenth below the floor is already the dense band');
|
|
54
|
+
assert.equal(corpus.floor, H.FLOOR, 'the corpus records the same floor the harness enforces');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ---- the corpus is a fixed set, and both runs answer all of it -------------
|
|
58
|
+
|
|
59
|
+
test('the prompt set is fixed, non-trivial, and answered in full by both runs', () => {
|
|
60
|
+
assert.ok(corpus.prompts.length >= 8, 'at least eight beginner prompts');
|
|
61
|
+
const ids = corpus.prompts.map((p) => p.id);
|
|
62
|
+
assert.equal(new Set(ids).size, ids.length, 'prompt ids are unique');
|
|
63
|
+
for (const runName of ['non_technical', 'engineer']) {
|
|
64
|
+
const run = corpus.runs[runName];
|
|
65
|
+
for (const id of ids) {
|
|
66
|
+
const turns = run.replies[id];
|
|
67
|
+
assert.ok(Array.isArray(turns) && turns.length > 0, `${runName} answers ${id}`);
|
|
68
|
+
for (const t of turns) assert.ok(typeof t === 'string' && t.trim(), `${runName}/${id} turn is prose`);
|
|
69
|
+
}
|
|
70
|
+
for (const [field, value] of Object.entries(run.profile)) {
|
|
71
|
+
assert.ok(acc.PROFILE_VALUES[field]?.includes(value), `${runName} profile ${field}=${value} is a real value`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ---- leg 1: the dimension recognises accessible prose ----------------------
|
|
77
|
+
|
|
78
|
+
test('leg 1 — the non-technical run clears the floor under the non-technical profile', () => {
|
|
79
|
+
const r = H.replay(corpus, 'non_technical');
|
|
80
|
+
assert.ok(r.result.score != null, 'the non-technical run is scored, not waived away');
|
|
81
|
+
assert.ok(
|
|
82
|
+
r.result.score >= H.FLOOR,
|
|
83
|
+
`non-technical run scored ${r.result.score}, floor is ${H.FLOOR}`
|
|
84
|
+
);
|
|
85
|
+
assert.equal(r.result.confidence, 'normal', 'the sample is big enough for the score to stand behind');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ---- leg 2: an expert's prose is not scored for being an expert's prose ----
|
|
89
|
+
|
|
90
|
+
test('leg 2 — the engineer run is not scored at all under the engineer profile', () => {
|
|
91
|
+
const r = H.replay(corpus, 'engineer');
|
|
92
|
+
assert.equal(r.result.score, null, 'an all-waived session scores null, never a number');
|
|
93
|
+
assert.equal(r.result.band, 'n/a');
|
|
94
|
+
assert.equal(r.result.signals.length, 3);
|
|
95
|
+
for (const s of r.result.signals) {
|
|
96
|
+
assert.equal(s.waived, true, `${s.type} is waived for this profile`);
|
|
97
|
+
assert.ok(/technical_level|toolchain_familiarity/.test(s.reason), `${s.type} names the setting that waived it`);
|
|
98
|
+
}
|
|
99
|
+
assert.equal(r.result.confidence, 'normal', 'the prose was read — the null is a waiver, not an empty sample');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('leg 2 holds when the same expert answers even more briefly', () => {
|
|
103
|
+
const full = H.replay(corpus, 'engineer');
|
|
104
|
+
const terse = H.replay(corpus, 'engineer', { variant: 'terse' });
|
|
105
|
+
assert.ok(terse.result.sampled_chars < full.result.sampled_chars, 'the terse variant really is shorter');
|
|
106
|
+
assert.equal(terse.result.score, null, 'still nothing to regress');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ---- leg 3: the same prose, read as a newcomer would read it ---------------
|
|
110
|
+
|
|
111
|
+
test('leg 3 — the SAME engineer prose falls through the floor under the non-technical profile', () => {
|
|
112
|
+
const r = H.replay(corpus, 'engineer', { profile: NEWCOMER });
|
|
113
|
+
assert.ok(r.result.score != null, 'nothing is waived for a newcomer');
|
|
114
|
+
assert.ok(
|
|
115
|
+
r.result.score < H.FLOOR,
|
|
116
|
+
`engineer prose read as a newcomer scored ${r.result.score}, which should be under ${H.FLOOR}`
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('leg 3 — every detector registers hits, so a scorer that went blind cannot pass', () => {
|
|
121
|
+
const s = signalsOf(H.replay(corpus, 'engineer', { profile: NEWCOMER }));
|
|
122
|
+
for (const type of Object.keys(acc.SIGNAL_SPEC)) {
|
|
123
|
+
assert.ok(s[type], `${type} was scored`);
|
|
124
|
+
assert.ok(s[type].count > 0, `${type} found something in the engineer prose (found ${s[type].count})`);
|
|
125
|
+
assert.ok(s[type].evidence, `${type} can name what it found`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---- the perfect run is forgiveness, not avoidance -------------------------
|
|
130
|
+
|
|
131
|
+
test('the non-technical run scores well WITH a jargon term and implementation framing in it, not by dodging them', () => {
|
|
132
|
+
const prose = Object.values(corpus.runs.non_technical.replies).flat().join('\n');
|
|
133
|
+
const present = acc.JARGON_TERMS.filter((t) => new RegExp(`\\b${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(prose));
|
|
134
|
+
assert.ok(present.length > 0, 'the accessible prose does use at least one term from the jargon list');
|
|
135
|
+
assert.ok(/\barchitecture\b/i.test(prose), 'and frames something in architecture terms at least once');
|
|
136
|
+
|
|
137
|
+
const s = signalsOf(H.replay(corpus, 'non_technical'));
|
|
138
|
+
assert.equal(s.unexplained_jargon.count, 0, `"${present.join('", "')}" is used and DEFINED, so it is not a finding`);
|
|
139
|
+
assert.equal(s.implementation_over_outcome.count, 0, 'the architecture turn also names the cost, so it is not a finding');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ---- leg 4: the metric reads density, not volume --------------------------
|
|
143
|
+
|
|
144
|
+
test('leg 4 — saying the same thing twice doubles the counts and leaves every rate, and the score, identical', () => {
|
|
145
|
+
const once = H.replay(corpus, 'engineer', { profile: NEWCOMER });
|
|
146
|
+
const twice = H.replay(corpus, 'engineer', { profile: NEWCOMER, variant: 'doubled' });
|
|
147
|
+
assert.equal(twice.result.sampled_chars, once.result.sampled_chars * 2, 'twice the prose');
|
|
148
|
+
|
|
149
|
+
const a = signalsOf(once);
|
|
150
|
+
const b = signalsOf(twice);
|
|
151
|
+
for (const type of Object.keys(acc.SIGNAL_SPEC)) {
|
|
152
|
+
assert.equal(b[type].count, a[type].count * 2, `${type} finds twice as much in twice the prose`);
|
|
153
|
+
assert.equal(b[type].rate, a[type].rate, `${type} rate is unchanged — the metric reads density, not volume`);
|
|
154
|
+
}
|
|
155
|
+
assert.equal(twice.result.score, once.result.score, 'so the score does not move with length');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('answering more briefly never costs points', () => {
|
|
159
|
+
for (const runName of ['non_technical', 'engineer']) {
|
|
160
|
+
const full = H.replay(corpus, runName);
|
|
161
|
+
const terse = H.replay(corpus, runName, { variant: 'terse' });
|
|
162
|
+
assert.ok(terse.result.sampled_chars < full.result.sampled_chars, `${runName} terse variant is shorter`);
|
|
163
|
+
if (full.result.score == null) {
|
|
164
|
+
assert.equal(terse.result.score, null, `${runName} stays unscored`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
assert.ok(
|
|
168
|
+
terse.result.score >= full.result.score,
|
|
169
|
+
`${runName} terse scored ${terse.result.score} against ${full.result.score} full — brevity must not cost`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---- the two waivers are independent settings ------------------------------
|
|
175
|
+
|
|
176
|
+
test('an engineer who is new to the terminal still gets the click-path signal', () => {
|
|
177
|
+
const r = H.replay(corpus, 'engineer', { profile: H.ENGINEER_NEW_TO_TOOLCHAIN });
|
|
178
|
+
const s = signalsOf(r);
|
|
179
|
+
assert.equal(r.result.signals.filter((x) => x.waived).length, 2, 'technical_level waives two, not three');
|
|
180
|
+
assert.equal(s.terminal_where_ui_exists.waived, false, 'the click-path signal is waived by TOOLCHAIN familiarity, not by technical level');
|
|
181
|
+
assert.ok(s.terminal_where_ui_exists.count > 0, 'and it still finds the commands handed over');
|
|
182
|
+
assert.ok(r.result.score != null, 'so this profile still gets a number');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ---- the harness is runnable, which is half of what the task asked for -----
|
|
186
|
+
|
|
187
|
+
test('the report renders and names every run', () => {
|
|
188
|
+
const out = H.formatReport(corpus);
|
|
189
|
+
assert.ok(out.includes(`floor ${H.FLOOR.toFixed(1)}`), 'the report states the floor it enforces');
|
|
190
|
+
assert.ok(out.includes('non-technical profile'), 'and both profiles appear');
|
|
191
|
+
assert.ok(out.includes('engineer profile'));
|
|
192
|
+
|
|
193
|
+
let json = '';
|
|
194
|
+
assert.equal(H.main(['--json'], (s) => { json += s; }), 0, 'the JSON mode exits clean');
|
|
195
|
+
const parsed = JSON.parse(json);
|
|
196
|
+
assert.equal(parsed.floor, H.FLOOR, 'and carries the floor a caller would check against');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
console.log('');
|
|
200
|
+
console.log(`Accessibility two-profile replay (clauses 5 + 8): ${passed} passed, ${failed} failed`);
|
|
201
|
+
if (failed > 0) process.exit(1);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "The fixed beginner-prompt set for the accessibility replay regression (task 1002517, clauses 5 and 8 of criterion accessibility-fluency-profile). EIGHT prompts a newcomer to this instance actually asks; each one is the documented trigger of a real surface, so the click-paths named in the non-technical replies are paths that EXIST (Settings -> API keys, Settings -> Session, Settings -> Interaction, the hall task list, the hall blockers page, the hall leaderboard, the hall Deploy page). Prompt 'make-it-live' is the owner's own words, verbatim, 2026-08-22 (task 1003159). Each prompt carries TWO answer runs: the same question answered in the register each profile asks for. The runs are the CONTROLLED variable and the prompt set is fixed - that is what makes this a replay rather than a fixture parade. Nothing here is tuned to a target score: the replies are written in each register and the harness measures what falls out, so a detector that goes blind moves these numbers.",
|
|
3
|
+
"floor": 6.0,
|
|
4
|
+
"_floor_comment": "6.0 is not a new number. It is bandFor()'s 'workable' boundary in modules/sessions/accessibility.js - the point the scorer itself calls the difference between prose a newcomer can work with and prose that is merely dense. accessibility_replay.mjs asserts the two stay tied, so retuning the bands cannot silently move the floor.",
|
|
5
|
+
"prompts": [
|
|
6
|
+
{ "id": "what-to-work-on", "prompt": "What can I work on?" },
|
|
7
|
+
{ "id": "make-it-live", "prompt": "I don't think I can make anything live." },
|
|
8
|
+
{ "id": "art-key", "prompt": "My image generation stopped working." },
|
|
9
|
+
{ "id": "session-expired", "prompt": "It says my session expired." },
|
|
10
|
+
{ "id": "how-you-talk", "prompt": "Can you stop using so much jargon with me?" },
|
|
11
|
+
{ "id": "credits", "prompt": "How do credits work here?" },
|
|
12
|
+
{ "id": "stuck", "prompt": "What's stuck waiting on me?" },
|
|
13
|
+
{ "id": "why-slow", "prompt": "Why is that page so slow?" }
|
|
14
|
+
],
|
|
15
|
+
"runs": {
|
|
16
|
+
"non_technical": {
|
|
17
|
+
"_comment": "The register the non-technical profile asks for: lead with the answer, name the click-path before the terminal, define a term the first time it is needed, and frame the trade-off as cost, time and what the reader will see. Two turns deliberately exercise the scorer's FORGIVING paths rather than dodging them - 'why-slow' turn 1 uses the word migration and defines it in the same breath, and turn 2 names the architecture AND what it costs - because a run that scores well only by avoiding every trigger proves nothing about the detectors.",
|
|
18
|
+
"profile": { "technical_level": "non_technical", "toolchain_familiarity": "new" },
|
|
19
|
+
"replies": {
|
|
20
|
+
"what-to-work-on": [
|
|
21
|
+
"Four jobs are open to you right now. They are on the hall task list, newest first, and each row shows what it pays and roughly how long it takes.",
|
|
22
|
+
"The second one is the quickest - about twenty minutes, and it pays thirty credits. Tell me its number and I will take it on for you."
|
|
23
|
+
],
|
|
24
|
+
"make-it-live": [
|
|
25
|
+
"You can do this yourself now, and it is two clicks. In the hall, open Deploy: the page shows the version your site is actually serving and the newer one it would move to.",
|
|
26
|
+
"The first click previews the move and the second makes it. If the new version does not answer within a minute it puts the old one back on its own, so a bad release cannot leave your site down. It costs nothing and takes about two minutes."
|
|
27
|
+
],
|
|
28
|
+
"art-key": [
|
|
29
|
+
"Your free art coverage has ended, so pictures now come out of an art key of your own - think of a key as a password that lets us make images on your account, and you pay only for what you make.",
|
|
30
|
+
"Add it in Settings, under API keys. Do not paste it to me in chat; the Settings page is the only place it should ever go. Most people spend a few dollars per month on this."
|
|
31
|
+
],
|
|
32
|
+
"session-expired": [
|
|
33
|
+
"That message means your sign-in has timed out. Nothing is lost, and it happens every few weeks on purpose.",
|
|
34
|
+
"Open Settings, then Session, and choose Re-issue. That hands you a fresh code and I take it from there. It takes under a minute."
|
|
35
|
+
],
|
|
36
|
+
"how-you-talk": [
|
|
37
|
+
"Yes - and it is a setting, so you will not have to keep asking. Open Settings, then Interaction, and set your technical level to non-technical.",
|
|
38
|
+
"Every session after that starts already knowing to define a term the first time it comes up, and to talk in cost, time and what you will see on screen. You will notice the difference in the very next reply."
|
|
39
|
+
],
|
|
40
|
+
"credits": [
|
|
41
|
+
"Credits are the record of what building here actually cost. When a piece of work ships you earn what the session really cost plus twenty per cent, and one credit is one dollar.",
|
|
42
|
+
"The figure on a task row is only a guide - you are paid on the real cost, never the estimate. Your running total sits on the hall leaderboard."
|
|
43
|
+
],
|
|
44
|
+
"stuck": [
|
|
45
|
+
"Four things are waiting on you rather than on me. Two need a yes or no and two need a number I cannot guess for you.",
|
|
46
|
+
"They are all on the hall blockers page, oldest first. The one at the top has been sitting eleven days and is holding up two other jobs, so it is the one worth five minutes."
|
|
47
|
+
],
|
|
48
|
+
"why-slow": [
|
|
49
|
+
"The page re-reads the whole list every time someone opens it. The fix is a migration - that is a one-off change to the way the information is kept - and it would take about an hour.",
|
|
50
|
+
"The architecture underneath is fine; the cost is that repeated reading. After the change the page should open in under a second instead of four."
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"engineer": {
|
|
55
|
+
"_comment": "The same eight questions answered the way an engineer profile asks to be answered: terse, command-first, and in the vocabulary of the trade with nothing defined. This run is DELIBERATELY dense in all three signals - that is the point. Under the engineer profile it must not be scored for any of it; under the non-technical profile the very same prose must fall through the floor. One run, two profiles, opposite verdicts is what proves the dimension is profile-relative rather than a taste test.",
|
|
56
|
+
"profile": { "technical_level": "engineer", "toolchain_familiarity": "comfortable" },
|
|
57
|
+
"replies": {
|
|
58
|
+
"what-to-work-on": [
|
|
59
|
+
"Four ready. Run `node scripts/gds/start.js`, then claim a task by id.",
|
|
60
|
+
"Second row is a refactor behind the module boundary. The claim is idempotent, so re-running it is a no-op."
|
|
61
|
+
],
|
|
62
|
+
"make-it-live": [
|
|
63
|
+
"Pin move is gate 3. `bongos upgrade --registry --to <ver> --commit-pin` on the control plane, or the Deploy page enqueues the same intent.",
|
|
64
|
+
"The control flow is enqueue, drain, health-gate, rollback. SIGTERM on a failed probe and the symlink goes back."
|
|
65
|
+
],
|
|
66
|
+
"art-key": [
|
|
67
|
+
"Art key is unset. Run `node scripts/gds/api.js` against the settings route, or set it in Settings - either path ends up in the same env file.",
|
|
68
|
+
"The upstream call is async and the error surfaces on stderr, not in the hall."
|
|
69
|
+
],
|
|
70
|
+
"session-expired": [
|
|
71
|
+
"Session token expired. Run `bongos auth --reissue`.",
|
|
72
|
+
"The JWT is short-lived by design and the refresh is idempotent, so a double call is harmless."
|
|
73
|
+
],
|
|
74
|
+
"how-you-talk": [
|
|
75
|
+
"That is the interaction profile. Run `node scripts/gds/api.js PATCH /settings`, or set it in Settings.",
|
|
76
|
+
"It is injected per session by the Conductor hook and never memoized, so the change takes on the next turn."
|
|
77
|
+
],
|
|
78
|
+
"credits": [
|
|
79
|
+
"Cost-plus. Run `node scripts/gds/api.js GET /builders/me` for your balance, or read the leaderboard.",
|
|
80
|
+
"The write is an upsert keyed on the session, so a replayed ship cannot double-pay."
|
|
81
|
+
],
|
|
82
|
+
"stuck": [
|
|
83
|
+
"Four open blockers. Run `node scripts/gds/blocker-review.js` to walk them.",
|
|
84
|
+
"Resolving one will upsert the linked rows to ready inside the same transaction."
|
|
85
|
+
],
|
|
86
|
+
"why-slow": [
|
|
87
|
+
"N+1 on the list read.",
|
|
88
|
+
"The ORM will hydrate each row separately. Fold it into one query against the schema with a jsonb aggregate."
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -106,15 +106,17 @@ test('the verdict is offered only to a viewer the routes will accept', () => {
|
|
|
106
106
|
assert.match(JS, /studio-verdict__sealed/, 'a viewer below the floor gets a sentence, not three dead buttons');
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
-
test('the review card shows the reviewed work\'s picture, through the
|
|
110
|
-
// "the picture is the card" — and drawn by the
|
|
111
|
-
//
|
|
112
|
-
|
|
109
|
+
test('the review card shows the reviewed work\'s picture, through the shared renderer', () => {
|
|
110
|
+
// "the picture is the card" — and drawn by the ONE full-size renderer rather
|
|
111
|
+
// than a hand-rolled <figure>. That renderer moved out of hall-kit.js into
|
|
112
|
+
// hall-visual.js in task 1003835, so the Studio now reaches for OTBVisual; the
|
|
113
|
+
// rule this pins is unchanged, only where the one copy lives.
|
|
114
|
+
assert.match(JS, /vis\.taskVisualFigureHtml\(/, 'the plate comes from the shared renderer');
|
|
113
115
|
assert.ok(!/<figure/.test(JS), 'studio.js builds no <figure> of its own');
|
|
114
|
-
assert.match(HTML, /hall-
|
|
116
|
+
assert.match(HTML, /hall-visual\.js/, 'and the page loads the file that owns it');
|
|
115
117
|
// The degrade: a review with no visual (or one that could not be read) draws
|
|
116
118
|
// nothing at all rather than an empty frame, so it still reads as a review.
|
|
117
|
-
assert.match(JS, /if \(!p\.shipped \|\| !
|
|
119
|
+
assert.match(JS, /if \(!p\.shipped \|\| !vis/, 'no reviewed task, no renderer → no plate');
|
|
118
120
|
// The reviewed task is read for the card in focus, once — not 50 times up front.
|
|
119
121
|
assert.match(JS, /p\.hydrated = true;/, 'the read is made once per review');
|
|
120
122
|
assert.match(JS, /\$\{API\}\/tasks\/\$\{encodeURIComponent\(p\.shippedId\)\}/, 'and it reads the SHIPPED task the review names');
|
|
@@ -204,7 +206,8 @@ test('the accent is spent only where the rule allows', () => {
|
|
|
204
206
|
});
|
|
205
207
|
|
|
206
208
|
// ---------------------------------------------------------------------------
|
|
207
|
-
// Boot the REAL studio.js under a DOM stub, with the REAL hall-kit.js
|
|
209
|
+
// Boot the REAL studio.js under a DOM stub, with the REAL hall-kit.js and
|
|
210
|
+
// hall-visual.js beside it
|
|
208
211
|
// (the bootShell pattern below, pointed at the page script). The static-source
|
|
209
212
|
// assertions above pin the wiring; these render the card and read what it
|
|
210
213
|
// actually says, which is the only way to pin a DEGRADE — "no picture" and
|
|
@@ -215,11 +218,15 @@ test('the accent is spent only where the rule allows', () => {
|
|
|
215
218
|
// NEW_TASK_ID so the "needs work" pair can be followed to its merge.
|
|
216
219
|
const NEW_TASK_ID = '2001';
|
|
217
220
|
async function bootStudio({ reviews = [], shipped = {}, rank = 'metic', flags = null, onWrite = null } = {}) {
|
|
218
|
-
// The REAL
|
|
219
|
-
// and a stub here would make the degrade tests below pass for the wrong
|
|
220
|
-
// (no
|
|
221
|
+
// The REAL modules, not stubs — the plate's URL guard and escaping are their
|
|
222
|
+
// code, and a stub here would make the degrade tests below pass for the wrong
|
|
223
|
+
// reason (no renderer also means no plate). Both are node-require-safe by their
|
|
224
|
+
// own contract. hall-visual.js carries the renderer since task 1003835; the kit
|
|
225
|
+
// is still loaded because the page still uses the rest of it.
|
|
221
226
|
const kit = (await import(`file://${path.join(HALL, 'hall-kit.js').replace(/\\/g, '/')}`)).default;
|
|
222
|
-
|
|
227
|
+
const vis = (await import(`file://${path.join(HALL, 'hall-visual.js').replace(/\\/g, '/')}`)).default;
|
|
228
|
+
assert.equal(typeof vis.taskVisualFigureHtml, 'function', 'the real visual module loaded');
|
|
229
|
+
assert.equal(typeof kit.taskVisualFigureHtml, 'undefined', 'and the kit no longer carries a second copy');
|
|
223
230
|
const els = new Map();
|
|
224
231
|
const el = (id) => {
|
|
225
232
|
if (!els.has(id)) els.set(id, { id, innerHTML: '', textContent: '', hidden: false, value: '' });
|
|
@@ -258,6 +265,7 @@ async function bootStudio({ reviews = [], shipped = {}, rank = 'metic', flags =
|
|
|
258
265
|
errMessage: (d) => (d && d.error && d.error.message) || '',
|
|
259
266
|
},
|
|
260
267
|
OTBKit: kit,
|
|
268
|
+
OTBVisual: vis,
|
|
261
269
|
scrollTo: () => {},
|
|
262
270
|
};
|
|
263
271
|
const document = { addEventListener: (t, h) => handlers.push({ t, h }) };
|
package/tests/task_visuals.mjs
CHANGED
|
@@ -13,13 +13,16 @@ import { createRequire } from 'node:module';
|
|
|
13
13
|
import os from 'node:os';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import fs from 'node:fs/promises';
|
|
16
|
+
import fsSync from 'node:fs';
|
|
16
17
|
const require = createRequire(import.meta.url);
|
|
17
18
|
|
|
18
19
|
// Point storage at a throwaway dir BEFORE requiring the module.
|
|
19
20
|
const TMP = path.join(os.tmpdir(), `otb-taskvisual-${process.pid}`);
|
|
20
21
|
process.env.TASK_VISUALS_DIR = TMP;
|
|
21
22
|
const tv = require('../modules/lifecycle/task-visuals.js');
|
|
22
|
-
|
|
23
|
+
// The renderers left hall-kit.js for hall-visual.js (task 1003835) — the task
|
|
24
|
+
// record page draws a visual and needs nothing else the kit holds.
|
|
25
|
+
const vis = require('../modules/hall-ui/public/hall-visual.js');
|
|
23
26
|
|
|
24
27
|
let passed = 0, failed = 0;
|
|
25
28
|
function t(name, fn) {
|
|
@@ -140,7 +143,7 @@ await t('persistImageBuffer: writes a name the serve route accepts, under the ri
|
|
|
140
143
|
assert.equal(tv.taskIdFromName(r.file), 1003109);
|
|
141
144
|
assert.equal(r.url, `/api/bongos/task-visuals/${r.file}`);
|
|
142
145
|
// Both prefixes are permanent aliases, so a URL minted under either resolves.
|
|
143
|
-
assert.equal(
|
|
146
|
+
assert.equal(vis.isTaskVisualUrl(r.url), true);
|
|
144
147
|
assert.equal(r.bytes, PNG.length);
|
|
145
148
|
const onDisk = await fs.readFile(path.join(TMP, r.file));
|
|
146
149
|
assert.deepEqual(onDisk, PNG);
|
|
@@ -170,23 +173,23 @@ await t('deleteStored: removes by name or URL, is idempotent, refuses a traversa
|
|
|
170
173
|
});
|
|
171
174
|
|
|
172
175
|
// ---- the client-side URL guard --------------------------------------------
|
|
173
|
-
await t('hall-
|
|
174
|
-
assert.equal(
|
|
175
|
-
assert.equal(
|
|
176
|
+
await t('hall-visual: only a server-minted visual URL is ever rendered', () => {
|
|
177
|
+
assert.equal(vis.isTaskVisualUrl('/api/gds/task-visuals/task-1003109-0011223344556677.png'), true);
|
|
178
|
+
assert.equal(vis.isTaskVisualUrl('/api/bongos/task-visuals/task-1-aabbccddeeff0011.webp'), true);
|
|
176
179
|
// Anything that is not that exact shape renders nothing — including an
|
|
177
180
|
// absolute URL to another origin, and a javascript: payload.
|
|
178
|
-
assert.equal(
|
|
179
|
-
assert.equal(
|
|
180
|
-
assert.equal(
|
|
181
|
-
assert.equal(
|
|
182
|
-
assert.equal(
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
await t('hall-
|
|
186
|
-
assert.equal(
|
|
187
|
-
assert.equal(
|
|
188
|
-
assert.equal(
|
|
189
|
-
const html =
|
|
181
|
+
assert.equal(vis.isTaskVisualUrl('https://evil.example/x.png'), false);
|
|
182
|
+
assert.equal(vis.isTaskVisualUrl('javascript:alert(1)'), false);
|
|
183
|
+
assert.equal(vis.isTaskVisualUrl('/api/gds/task-visuals/../../secrets'), false);
|
|
184
|
+
assert.equal(vis.isTaskVisualUrl(''), false);
|
|
185
|
+
assert.equal(vis.isTaskVisualUrl(null), false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await t('hall-visual: no visual renders nothing at all — never a placeholder', () => {
|
|
189
|
+
assert.equal(vis.shipVisualThumbHtml({ id: 1 }), '');
|
|
190
|
+
assert.equal(vis.shipVisualThumbHtml({ id: 1, visual_url: null }), '');
|
|
191
|
+
assert.equal(vis.shipVisualThumbHtml(null), '');
|
|
192
|
+
const html = vis.shipVisualThumbHtml({ id: 1, visual_url: '/api/gds/task-visuals/task-1-0011223344556677.png', visual_alt: 'a "before" shot' });
|
|
190
193
|
assert.ok(html.includes('ship-visual-thumb'));
|
|
191
194
|
// The caption is attacker-adjacent text (a builder wrote it) — it must be escaped.
|
|
192
195
|
assert.ok(html.includes('"before"'));
|
|
@@ -194,18 +197,18 @@ await t('hall-kit: no visual renders nothing at all — never a placeholder', ()
|
|
|
194
197
|
});
|
|
195
198
|
|
|
196
199
|
// ---- the full-size figure (task 1003829) -----------------------------------
|
|
197
|
-
await t('hall-
|
|
200
|
+
await t('hall-visual: the full-size figure is the same guard, at reading size', () => {
|
|
198
201
|
// Same no-placeholder rule as the thumbnail: nothing at all without a visual.
|
|
199
|
-
assert.equal(
|
|
200
|
-
assert.equal(
|
|
201
|
-
assert.equal(
|
|
202
|
+
assert.equal(vis.taskVisualFigureHtml({ id: 1 }), '');
|
|
203
|
+
assert.equal(vis.taskVisualFigureHtml({ id: 1, visual_url: null }), '');
|
|
204
|
+
assert.equal(vis.taskVisualFigureHtml(null), '');
|
|
202
205
|
// And the same URL wall — an off-origin src or a javascript: payload is not
|
|
203
206
|
// "rendered without a caption", it is not rendered.
|
|
204
|
-
assert.equal(
|
|
205
|
-
assert.equal(
|
|
207
|
+
assert.equal(vis.taskVisualFigureHtml({ id: 1, visual_url: 'https://evil.example/x.png' }), '');
|
|
208
|
+
assert.equal(vis.taskVisualFigureHtml({ id: 1, visual_url: 'javascript:alert(1)' }), '');
|
|
206
209
|
|
|
207
210
|
const url = '/api/gds/task-visuals/task-1-0011223344556677.png';
|
|
208
|
-
const html =
|
|
211
|
+
const html = vis.taskVisualFigureHtml({ id: 1, visual_url: url, visual_alt: 'a "before" shot' });
|
|
209
212
|
assert.ok(html.includes('<figure class="task-visual">'));
|
|
210
213
|
assert.ok(html.includes('class="task-visual__img"'));
|
|
211
214
|
// The caption is the SHIPPER's text — attacker-adjacent, so escaped in both
|
|
@@ -215,19 +218,80 @@ await t('hall-kit: the full-size figure is the same guard, at reading size', ()
|
|
|
215
218
|
|
|
216
219
|
// The caller owns the size, so it owns the class base; anything but
|
|
217
220
|
// [A-Za-z0-9_-] is stripped rather than interpolated into the attribute.
|
|
218
|
-
const studio =
|
|
221
|
+
const studio = vis.taskVisualFigureHtml({ id: 1, visual_url: url }, { className: 'studio-plate' });
|
|
219
222
|
assert.ok(studio.includes('<figure class="studio-plate">'));
|
|
220
223
|
assert.ok(studio.includes('class="studio-plate__img"'));
|
|
221
|
-
const hostile =
|
|
224
|
+
const hostile = vis.taskVisualFigureHtml({ id: 1, visual_url: url }, { className: 'x" onload="a' });
|
|
222
225
|
assert.ok(hostile.includes('<figure class="xonloada">'), hostile);
|
|
223
226
|
|
|
224
227
|
// No written alt → no caption at all. The fallback alt is a machine sentence
|
|
225
228
|
// for a screen reader, never a caption shown to a reader.
|
|
226
|
-
const bare =
|
|
229
|
+
const bare = vis.taskVisualFigureHtml({ id: 7, visual_url: url });
|
|
227
230
|
assert.ok(!bare.includes('figcaption'));
|
|
228
231
|
assert.ok(bare.includes('alt="Visual for task 7"'));
|
|
229
232
|
});
|
|
230
233
|
|
|
234
|
+
// ---- the convergence is the deliverable (task 1003835) ---------------------
|
|
235
|
+
//
|
|
236
|
+
// Reading the source is the right altitude here: the defect this task closes is
|
|
237
|
+
// a SECOND definition existing, which no behavioural assertion against one of
|
|
238
|
+
// them can see. A duplicate renderer passes every test above.
|
|
239
|
+
|
|
240
|
+
const HALL = (f) => fsSync.readFileSync(new URL(`../modules/hall-ui/public/${f}`, import.meta.url), 'utf8');
|
|
241
|
+
|
|
242
|
+
await t('hall-kit no longer defines a visual renderer', () => {
|
|
243
|
+
const kitSrc = HALL('hall-kit.js');
|
|
244
|
+
// The function DEFINITIONS are what must be gone — the pointer comment naming
|
|
245
|
+
// them is the thing that tells the next reader where they went, so match on
|
|
246
|
+
// the definition syntax rather than on the bare name.
|
|
247
|
+
assert.ok(!/function\s+taskVisualFigureHtml\s*\(/.test(kitSrc), 'hall-kit.js still defines taskVisualFigureHtml');
|
|
248
|
+
assert.ok(!/function\s+shipVisualThumbHtml\s*\(/.test(kitSrc), 'hall-kit.js still defines shipVisualThumbHtml');
|
|
249
|
+
assert.ok(!/function\s+isTaskVisualUrl\s*\(/.test(kitSrc), 'hall-kit.js still defines isTaskVisualUrl');
|
|
250
|
+
// …and it must not re-export them either, or callers keep two ways in.
|
|
251
|
+
const kitApi = require('../modules/hall-ui/public/hall-kit.js');
|
|
252
|
+
assert.equal(kitApi.taskVisualFigureHtml, undefined);
|
|
253
|
+
assert.equal(kitApi.shipVisualThumbHtml, undefined);
|
|
254
|
+
assert.equal(kitApi.isTaskVisualUrl, undefined);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await t('task.js renders through the shared module, not its own <figure>', () => {
|
|
258
|
+
const src = HALL('task.js');
|
|
259
|
+
assert.ok(src.includes('vis.taskVisualFigureHtml(t, { className: \'tk-visual\' })'),
|
|
260
|
+
'task.js should delegate to OTBVisual with its own class base');
|
|
261
|
+
// The hand-rolled markup is the thing that came back three times. If any of it
|
|
262
|
+
// reappears in this file, the convergence has been undone.
|
|
263
|
+
assert.ok(!src.includes('<figure class="tk-visual">'), 'task.js hand-rolls the figure again');
|
|
264
|
+
assert.ok(!src.includes('tk-visual__img'), 'task.js hand-rolls the img class again');
|
|
265
|
+
assert.ok(!src.includes('task-visuals/task-'), 'task.js carries its own copy of the URL guard again');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await t('every page whose script draws a visual actually loads hall-visual.js', () => {
|
|
269
|
+
// A missing <script> is the one failure this split introduces, and it is
|
|
270
|
+
// silent: the guard in each caller degrades to rendering nothing, so a visual
|
|
271
|
+
// would simply stop appearing with no error anywhere.
|
|
272
|
+
for (const page of ['index.html', 'profile.html', 'studio.html', 'task.html']) {
|
|
273
|
+
assert.ok(HALL(page).includes('/builders/hall-visual.js'), `${page} does not load hall-visual.js`);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await t('the callers reach for OTBVisual, and no longer for the kit', () => {
|
|
278
|
+
for (const f of ['hall-render.js', 'profile.js', 'studio.js', 'task.js']) {
|
|
279
|
+
const src = HALL(f);
|
|
280
|
+
assert.ok(!/OTBKit(\.|\[')?\s*(shipVisualThumbHtml|taskVisualFigureHtml|isTaskVisualUrl)/.test(src),
|
|
281
|
+
`${f} still reads a visual renderer off OTBKit`);
|
|
282
|
+
}
|
|
283
|
+
assert.ok(HALL('hall-render.js').includes('window.OTBVisual.shipVisualThumbHtml'));
|
|
284
|
+
assert.ok(HALL('studio.js').includes('vis.taskVisualFigureHtml('));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await t('the dead .td-visual rules are gone, and nothing emits the class', () => {
|
|
288
|
+
// task 1003829's comment and this task's own brief both asserted a second
|
|
289
|
+
// renderer in task-detail.js. `git log -S` shows that file never carried one:
|
|
290
|
+
// the CSS was the fossil of a renderer that was planned and not built.
|
|
291
|
+
assert.ok(!HALL('style.css').includes('.td-visual'), 'style.css still carries the dead rules');
|
|
292
|
+
assert.ok(!HALL('task-detail.js').includes('td-visual'));
|
|
293
|
+
});
|
|
294
|
+
|
|
231
295
|
// ---- cleanup ---------------------------------------------------------------
|
|
232
296
|
await fs.rm(TMP, { recursive: true, force: true });
|
|
233
297
|
|