@bongos/core 1.19.1056 → 1.19.1058
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 +81 -26
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +6 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +108 -1
- package/docs/api-reference.md +5 -2
- package/docs/architecture.md +2 -1
- package/docs/file-map.md +3 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +1178 -0
- package/docs/page-readings.json +4108 -0
- package/modules/copy-desk/page-data.js +79 -0
- package/modules/copy-desk/page-status.js +357 -0
- package/modules/copy-desk/pages.js +204 -0
- package/modules/copy-desk/routes/copy-desk.js +155 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +16 -6
- package/modules/copy-desk/tests/copy_page_status.mjs +240 -0
- package/modules/copy-desk/tests/fixtures/page-tweak.cjs +154 -0
- package/modules/lifecycle/lifecycle.js +7 -0
- package/modules/lifecycle/page-tweak-reads.js +87 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/page-reader.js +674 -0
- package/scripts/gds/publish-manifest.js +7 -0
- package/scripts/gds/ship-check.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_reads.mjs +194 -0
- package/tests/page_reader.mjs +200 -0
|
@@ -124,6 +124,13 @@ const PUBLISH_ALLOWLIST = [
|
|
|
124
124
|
// only because that is where their generator has always written them.
|
|
125
125
|
'docs/copy-registry.json',
|
|
126
126
|
'docs/copy-inventory.md', // the human twin, generated in the same pass
|
|
127
|
+
// Tweak Mode's two page artifacts (ADR 0341 D1, task 1004316), the same kind
|
|
128
|
+
// of runtime data: modules/copy-desk/page-data.js reads both on every page
|
|
129
|
+
// status read, and neither generator can run on a deployed box (the reader
|
|
130
|
+
// opens a browser against a source tree). Without the inventory an instance
|
|
131
|
+
// answers 503 page_inventory_missing; without the readings drift reads unknown.
|
|
132
|
+
'docs/page-inventory.json',
|
|
133
|
+
'docs/page-readings.json',
|
|
127
134
|
'docs/file-map.md',
|
|
128
135
|
'docs/repo-map.md',
|
|
129
136
|
'docs/handoff-template.md', // the portable session-handoff format (methodology)
|
|
@@ -35,6 +35,8 @@ const STEPS = [
|
|
|
35
35
|
{ key: 'api-docs', label: 'docs/api/openapi.json + api-reference fresh', argv: ['scripts/gds/gen-api-docs.js', '--check'], fix: 'node scripts/gds/gen-api-docs.js' },
|
|
36
36
|
{ key: 'copy-registry', label: 'docs/copy-registry.json fresh', argv: ['scripts/gds/copy-inventory.js', '--check'], fix: 'node scripts/gds/copy-inventory.js', optional: true },
|
|
37
37
|
{ key: 'page-inventory', label: 'docs/page-inventory.json fresh (every page + its stable id)', argv: ['scripts/gds/page-inventory.js', '--check'], fix: 'node scripts/gds/page-inventory.js', optional: true },
|
|
38
|
+
// The page readings (task 1004315, ADR 0341 D3): files_hash only, no browser; the fix is the reader's own re-read.
|
|
39
|
+
{ key: 'page-readings', label: 'docs/page-readings.json fresh (every page\'s reading matches its files)', argv: ['scripts/gds/page-reader.js', '--check'], fix: 'node scripts/gds/page-reader.js --stale', optional: true },
|
|
38
40
|
{ key: 'linkify', label: 'no bare #NNN task refs in committed markdown', argv: ['scripts/gds/linkify-refs.js', '--check'], fix: 'node scripts/gds/linkify-refs.js', optional: true },
|
|
39
41
|
{ key: 'skill-lint', label: 'SKILL.md frontmatter parses', argv: ['scripts/gds/skill-lint.js'], fix: 'fix the ✗ lines (description: >- block scalars)' },
|
|
40
42
|
{ key: 'render-fit', label: 'rendered text fit + sibling overlap on the pages this branch changed (advisory)', argv: ['scripts/gds/render-check.js'], fix: 'look at the shots it names; fix the element, or say in the ship notes why it is right', advisory: true, optional: true, skipWith: '--no-render', slow: true },
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1058'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// tests/copy_desk_page_reads.mjs — Tweak Mode's three reads, the WHOLE path
|
|
2
|
+
// (task 1004316 / BV2.TW05, ADR 0341 D1, D8, D11).
|
|
3
|
+
//
|
|
4
|
+
// modules/copy-desk/tests/copy_page_status.mjs proves the derivation on
|
|
5
|
+
// hand-shaped rows. This proves the path those rows actually travel: the real
|
|
6
|
+
// lifecycle port's SQL (listPageTweakTasks, shippedTasksTouchingSince) and the
|
|
7
|
+
// real reward port's creditLedger, answered by a fake pool, through the real
|
|
8
|
+
// copy-desk routes and the real error envelope, reading the page artifacts from
|
|
9
|
+
// disk through page-data.js. A field the SQL names and the derivation does not
|
|
10
|
+
// read (or the reverse) reds here and nowhere else.
|
|
11
|
+
//
|
|
12
|
+
// And the degrade (ADR 0341 D1): an instance with no page inventory answers a
|
|
13
|
+
// named 503 carrying the generator command, never a crash, while the tally,
|
|
14
|
+
// which needs no inventory, still answers; an instance with the inventory but
|
|
15
|
+
// no readings still reports status and marks drift unknown.
|
|
16
|
+
|
|
17
|
+
import { strict as assert } from 'node:assert';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { makeRunner } from './helpers.mjs';
|
|
23
|
+
|
|
24
|
+
process.env.NODE_ENV = 'test';
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
|
|
27
|
+
const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
|
|
28
|
+
const api = require('../src/module-api.js');
|
|
29
|
+
|
|
30
|
+
// The fake pool, dispatching on the SQL each port read sends. Installed BEFORE
|
|
31
|
+
// the ports are required: economy/credits.js destructures the pool at load.
|
|
32
|
+
const seen = [];
|
|
33
|
+
const flat = (sql) => String(sql).replace(/\s+/g, ' ').trim();
|
|
34
|
+
api.pool = {
|
|
35
|
+
async query(sql, params = []) {
|
|
36
|
+
const q = flat(sql);
|
|
37
|
+
seen.push({ q, params });
|
|
38
|
+
if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) {
|
|
39
|
+
assert.match(q, /WHERE t\.source = 'page-tweak'/);
|
|
40
|
+
// The fixture rows stand in for this SELECT, so every column they carry
|
|
41
|
+
// must be one the SELECT really names: a renamed alias would otherwise
|
|
42
|
+
// leave the fake answering with a field the real query never returns.
|
|
43
|
+
const select = q.slice(q.indexOf(') SELECT '), q.indexOf(' FROM tasks t LEFT JOIN'));
|
|
44
|
+
// One pass over claims for the round's artist, never a per-task subquery.
|
|
45
|
+
assert.doesNotMatch(q, /LATERAL/);
|
|
46
|
+
for (const col of Object.keys(F.TASKS[0])) {
|
|
47
|
+
assert.match(select, new RegExp(`(?:t\\.${col}\\b|AS ${col}\\b)`), `the SELECT must name ${col}`);
|
|
48
|
+
}
|
|
49
|
+
return { rows: F.TASKS };
|
|
50
|
+
}
|
|
51
|
+
if (/t\.touches && \$1::text\[\]/.test(q)) {
|
|
52
|
+
return { rows: F.STUDIO_SHIPS.filter((s) => new Date(s.shipped_at) > new Date(params[1])) };
|
|
53
|
+
}
|
|
54
|
+
if (/FROM credit_log/.test(q)) {
|
|
55
|
+
const prefix = String(params[1] || '').replace(/%$/, '').replace(/\\([\\%_])/g, '$1');
|
|
56
|
+
const rows = F.CREDIT_ROWS
|
|
57
|
+
.filter((r) => String(r.builder_id) === String(params[0]) && r.reason.startsWith(prefix))
|
|
58
|
+
.map((r) => ({ ...r }));
|
|
59
|
+
return { rows: rows.map((r, _, all) => ({ ...r, total_count: all.length })) };
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`unexpected SQL in the page reads: ${q.slice(0, 120)}`);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Sign-in is the one thing stubbed: the caller is whoever the header names.
|
|
66
|
+
api.requireBuilder = (req, res, next) => {
|
|
67
|
+
req.builder = { id: String(req.headers['x-builder'] || F.ALICE.id), rank: 'thetes' };
|
|
68
|
+
next();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const seams = require('../src/module-seams.js');
|
|
72
|
+
if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', require('../modules/lifecycle/lifecycle.js'));
|
|
73
|
+
if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
|
|
74
|
+
|
|
75
|
+
const pageData = require('../modules/copy-desk/page-data.js');
|
|
76
|
+
const express = require('express');
|
|
77
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
78
|
+
const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
|
|
79
|
+
|
|
80
|
+
const app = express();
|
|
81
|
+
app.use(attachFail);
|
|
82
|
+
app.use(buildRouter());
|
|
83
|
+
const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
|
|
84
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
85
|
+
|
|
86
|
+
async function get(p, builder) {
|
|
87
|
+
const r = await fetch(base + p, { headers: builder ? { 'x-builder': builder } : {} });
|
|
88
|
+
return { status: r.status, body: await r.json() };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Three roots: both artifacts, the inventory alone, and nothing at all.
|
|
92
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-reads-'));
|
|
93
|
+
const root = (name, files) => {
|
|
94
|
+
const dir = path.join(tmp, name);
|
|
95
|
+
fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
|
|
96
|
+
for (const [rel, v] of Object.entries(files)) fs.writeFileSync(path.join(dir, rel), JSON.stringify(v));
|
|
97
|
+
return dir;
|
|
98
|
+
};
|
|
99
|
+
const FULL = root('full', { 'docs/page-inventory.json': F.INVENTORY, 'docs/page-readings.json': F.READINGS });
|
|
100
|
+
const INV_ONLY = root('inventory-only', { 'docs/page-inventory.json': F.INVENTORY });
|
|
101
|
+
const EMPTY = root('empty', {});
|
|
102
|
+
|
|
103
|
+
const { test, summary } = makeRunner();
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
pageData._setRoot(FULL);
|
|
107
|
+
|
|
108
|
+
await test('GET /copy-desk/pages: the three fixture cases, and the per-surface rollup', async () => {
|
|
109
|
+
const { status, body } = await get('/copy-desk/pages');
|
|
110
|
+
assert.equal(status, 200);
|
|
111
|
+
const p = (id) => body.pages.find((x) => x.id === id);
|
|
112
|
+
assert.equal(p('landing:index').status_label, 'Untweaked');
|
|
113
|
+
assert.equal(p('landing:projects').status_label, 'Text tweaked 2x');
|
|
114
|
+
assert.equal(p('landing:projects').drift.changed, false);
|
|
115
|
+
assert.equal(p('builders:studio').status_label, 'Text tweaked 1x');
|
|
116
|
+
assert.equal(p('builders:studio').drift.changed_count, 1);
|
|
117
|
+
assert.equal(p('builders:studio').open_round.state, 'writing');
|
|
118
|
+
assert.deepEqual(body.surfaces.map((s) => s.line), ['1 of 2 tweaked', '1 of 1 tweaked', '0 of 1 tweaked']);
|
|
119
|
+
assert.deepEqual(body.readings, { ok: true, at: 'docs/page-readings.json' });
|
|
120
|
+
assert.deepEqual(body.inventory, { at: 'docs/page-inventory.json' });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await test('GET /copy-desk/pages/:pageId: changelog, changed-since lines and the ships that changed them', async () => {
|
|
124
|
+
seen.length = 0;
|
|
125
|
+
const { status, body } = await get('/copy-desk/pages/builders:studio');
|
|
126
|
+
assert.equal(status, 200);
|
|
127
|
+
assert.deepEqual(body.changelog.map((c) => [c.task_id, c.artist.login, c.line_count]), [['201', 'alice', 1]]);
|
|
128
|
+
assert.deepEqual(body.drift.changed_lines, [{ key: 'L0002', section: 'greeting', text: 'Welcome back' }]);
|
|
129
|
+
assert.deepEqual(body.drift.ships.map((s) => s.task_id), ['555']);
|
|
130
|
+
assert.deepEqual(body.open_round.holder, { id: '8', login: 'bob' });
|
|
131
|
+
// The ships read is asked about THIS page's files, since THIS page's last round.
|
|
132
|
+
const ships = seen.find((s) => /t\.touches &&/.test(s.q));
|
|
133
|
+
assert.deepEqual(ships.params[0], F.STUDIO_FILES);
|
|
134
|
+
assert.equal(ships.params[1], '2026-09-20T10:00:00.000Z');
|
|
135
|
+
|
|
136
|
+
const twice = await get('/copy-desk/pages/landing:projects');
|
|
137
|
+
assert.deepEqual(twice.body.changelog.map((c) => c.artist.login), ['bob', 'alice']);
|
|
138
|
+
const untweaked = await get('/copy-desk/pages/landing:index');
|
|
139
|
+
assert.deepEqual([untweaked.body.count, untweaked.body.changelog.length, untweaked.body.drift.ships.length], [0, 0, 0]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await test('an id the inventory does not know is unknown_page, never a blank page', async () => {
|
|
143
|
+
for (const id of ['landing:nope', 'not-an-id']) {
|
|
144
|
+
const { status, body } = await get(`/copy-desk/pages/${id}`);
|
|
145
|
+
assert.equal(status, 404, id);
|
|
146
|
+
assert.equal(body.error.code, 'unknown_page', id);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await test('GET /copy-desk/tally: the caller\'s own, through the reward port, 0 where no payout row exists yet', async () => {
|
|
151
|
+
seen.length = 0;
|
|
152
|
+
const alice = await get('/copy-desk/tally', F.ALICE.id);
|
|
153
|
+
assert.equal(alice.status, 200);
|
|
154
|
+
assert.equal(alice.body.credits, 60);
|
|
155
|
+
assert.equal(alice.body.pages_tweaked, 2);
|
|
156
|
+
assert.equal(alice.body.builder_id, '7');
|
|
157
|
+
assert.equal(alice.body.truncated, false);
|
|
158
|
+
const reads = seen.filter((s) => /FROM credit_log/.test(s.q));
|
|
159
|
+
assert.equal(reads.length, 1, 'one bounded ledger read, never a paging loop');
|
|
160
|
+
assert.equal(String(reads[0].params[0]), '7', 'the ledger is read for the CALLER');
|
|
161
|
+
const bob = await get('/copy-desk/tally', F.BOB.id);
|
|
162
|
+
assert.deepEqual([bob.body.credits, bob.body.this_week, bob.body.pages_tweaked], [0, 0, 1]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await test('no readings: status still reads from the ledger, and drift says it does not know', async () => {
|
|
166
|
+
pageData._setRoot(INV_ONLY);
|
|
167
|
+
const { status, body } = await get('/copy-desk/pages');
|
|
168
|
+
assert.equal(status, 200);
|
|
169
|
+
assert.equal(body.readings.ok, false);
|
|
170
|
+
assert.equal(body.readings.code, 'page_readings_missing');
|
|
171
|
+
const studio = body.pages.find((x) => x.id === 'builders:studio');
|
|
172
|
+
assert.equal(studio.status_label, 'Text tweaked 1x');
|
|
173
|
+
assert.deepEqual([studio.drift.known, studio.drift.reason], [false, 'readings_missing']);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await test('no inventory: a named 503 with the command that fixes it, and the tally still answers', async () => {
|
|
177
|
+
pageData._setRoot(EMPTY);
|
|
178
|
+
for (const p of ['/copy-desk/pages', '/copy-desk/pages/landing:index']) {
|
|
179
|
+
const { status, body } = await get(p);
|
|
180
|
+
assert.equal(status, 503, p);
|
|
181
|
+
assert.equal(body.error.code, 'page_inventory_missing', p);
|
|
182
|
+
assert.equal(body.error.details.run, 'node scripts/gds/page-inventory.js', p);
|
|
183
|
+
}
|
|
184
|
+
const tally = await get('/copy-desk/tally', F.ALICE.id);
|
|
185
|
+
assert.equal(tally.status, 200);
|
|
186
|
+
assert.equal(tally.body.credits, 60);
|
|
187
|
+
});
|
|
188
|
+
} finally {
|
|
189
|
+
pageData._setRoot(null);
|
|
190
|
+
server.close();
|
|
191
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
summary();
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// tests/page_reader.mjs — the page reader (BV2.TW04, task 1004315; ADR 0341 D3;
|
|
2
|
+
// the Tweak Mode chain, goal 1000095).
|
|
3
|
+
//
|
|
4
|
+
// What these tests defend:
|
|
5
|
+
// 1. NOTHING IS DROPPED. A visible string the copy registry cannot place is
|
|
6
|
+
// LISTED as unplaced, with a key and a section — the task's own done-when.
|
|
7
|
+
// Only a string that is nothing but the page's own data is left out, and
|
|
8
|
+
// the registry is always asked first, so a placed string is never mistaken
|
|
9
|
+
// for data.
|
|
10
|
+
// 2. PLACEMENT. Exact text, a holed row's pattern, a segment of a holed row —
|
|
11
|
+
// a row in the page's own files first, never another page's html.
|
|
12
|
+
// 3. SHARED. Shell text is marked shared, keeps its registry row when it has
|
|
13
|
+
// one, and is outside reading_hash.
|
|
14
|
+
// 4. THE CONTRACT. docs/page-readings.json covers every inventory page, each
|
|
15
|
+
// line carries key/section/text/placement, keys run L0001.. in order, and
|
|
16
|
+
// both hashes are what the generator computes.
|
|
17
|
+
// 5. FRESHNESS WITHOUT A BROWSER. --check compares files_hash, names the re-read
|
|
18
|
+
// command, and never loads the browser driver.
|
|
19
|
+
//
|
|
20
|
+
// DB-free, network-free, browser-free.
|
|
21
|
+
// Run: node --test tests/page_reader.mjs
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import { test } from 'node:test';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
33
|
+
const reader = require(path.join(ROOT, 'scripts', 'gds', 'page-reader.js'));
|
|
34
|
+
const shipCheck = require(path.join(ROOT, 'scripts', 'gds', 'ship-check.js'));
|
|
35
|
+
|
|
36
|
+
const REGISTRY = {
|
|
37
|
+
entries: [
|
|
38
|
+
{ id: 'aaa000000001', text: 'Welcome back', file: 'mods/pub/home.html', line: 10 },
|
|
39
|
+
{ id: 'aaa000000002', text: '${…} of ${…} shipped this week', file: 'mods/pub/home.js', line: 40 },
|
|
40
|
+
{ id: 'aaa000000003', text: 'Signed in as ${…}. Sign out', file: 'mods/pub/shell.js', line: 7 },
|
|
41
|
+
{ id: 'aaa000000004', text: 'Welcome back', file: 'mods/pub/other.html', line: 3 },
|
|
42
|
+
{ id: 'aaa000000005', text: 'Only on the other page', file: 'mods/pub/other.html', line: 5 },
|
|
43
|
+
{ id: 'aaa000000006', text: 'Loaded late', file: 'mods/pub/late.js', line: 2 },
|
|
44
|
+
{ id: 'aaa000000007', text: 'Studio — {{worldName}}', file: 'mods/pub/home.html', line: 1 },
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
const PAGE = { id: 'builders:home', surface: 'builders', title: 'Home', files: ['mods/pub/home.html', 'mods/pub/shell.js', 'mods/pub/home.js'] };
|
|
48
|
+
const matcher = reader.buildMatcher(REGISTRY);
|
|
49
|
+
const place = (t) => reader.placeText(t, matcher, { files: PAGE.files, surfaceDir: 'mods/pub' });
|
|
50
|
+
const fakeFs = { readFileSync: () => 'x' };
|
|
51
|
+
const assemble = (captures, opts = {}) => reader.assembleReading(PAGE, captures, matcher, { surfaceDir: 'mods/pub', fsImpl: fakeFs, ...opts });
|
|
52
|
+
|
|
53
|
+
test('an unplaced string is listed, not dropped', () => {
|
|
54
|
+
const r = assemble([{ state: 'default', strings: [
|
|
55
|
+
{ text: 'Welcome back', section: 'Home', shared: null },
|
|
56
|
+
{ text: 'A sentence no file in the registry holds', section: 'Home', shared: null },
|
|
57
|
+
{ text: 'Welcome back', section: 'Later', shared: null },
|
|
58
|
+
] }]);
|
|
59
|
+
const line = r.lines.find((l) => l.text === 'A sentence no file in the registry holds');
|
|
60
|
+
assert.ok(line, 'the unplaceable string is in the reading');
|
|
61
|
+
assert.equal(line.placement, 'unplaced');
|
|
62
|
+
assert.equal(line.section, 'Home');
|
|
63
|
+
assert.match(line.key, /^L\d{4}$/);
|
|
64
|
+
assert.equal(line.file, undefined, 'an unplaced line names no file');
|
|
65
|
+
assert.equal(r.counts.unplaced, 1);
|
|
66
|
+
assert.deepEqual(r.lines.map((l) => l.key), ['L0001', 'L0002', 'L0003'], 'keys run in page order');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('an unplaced string that CONTAINS data keeps its words, with the data holed', () => {
|
|
70
|
+
const data = new Set(['Ada Lovelace', 'ready']);
|
|
71
|
+
const r = assemble([{ state: 'default', strings: [
|
|
72
|
+
{ text: 'Good morning, Ada Lovelace', section: '', shared: null },
|
|
73
|
+
{ text: 'Ada Lovelace', section: '', shared: null },
|
|
74
|
+
{ text: '42', section: '', shared: null },
|
|
75
|
+
] }], { dataValues: data });
|
|
76
|
+
assert.deepEqual(r.lines.map((l) => [l.text, l.placement]), [['Good morning, {…}', 'unplaced']]);
|
|
77
|
+
assert.equal(r.omitted.data, 1, 'the pure-data string is counted, and the number is not copy');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('clock times and dates in an unplaced string are holed, so a re-read reads the same', () => {
|
|
81
|
+
assert.equal(reader.holeData('· since 10:43 PM', new Set()).text, '· since {…}');
|
|
82
|
+
assert.equal(reader.holeData('Filed Sep 10, 2026 · Blocking 2 tasks:', new Set()).text, 'Filed {…} · Blocking 2 tasks:');
|
|
83
|
+
assert.equal(reader.holeData('Jul 20, 2026', new Set()).data, true, 'a bare date is data');
|
|
84
|
+
assert.equal(reader.holeData('Licensed AGPL-3.0', new Set()).text, 'Licensed AGPL-3.0', 'a version number is left alone');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('the registry is asked before the data: a placed string is never taken for data', () => {
|
|
88
|
+
const r = assemble([{ state: 'default', strings: [{ text: 'Welcome back', section: '', shared: null }] }], { dataValues: new Set(['Welcome back']) });
|
|
89
|
+
assert.equal(r.lines.length, 1);
|
|
90
|
+
assert.equal(r.lines[0].placement, 'placed');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('placement: exact, pattern and segment; the page\'s own file wins; never another page\'s html', () => {
|
|
94
|
+
assert.deepEqual(place('Welcome back'), { file: 'mods/pub/home.html', line: 10, string_id: 'aaa000000001', text: 'Welcome back', how: 'exact' });
|
|
95
|
+
const pat = place('3 of 9 shipped this week');
|
|
96
|
+
assert.equal(pat.string_id, 'aaa000000002');
|
|
97
|
+
assert.equal(pat.text, '{…} of {…} shipped this week', 'a pattern-placed line reads as its row, with the registry\'s {…} holes');
|
|
98
|
+
assert.equal(place('shipped this week').string_id, 'aaa000000002', 'a literal segment of a holed row places');
|
|
99
|
+
assert.equal(place('Only on the other page'), null, 'a row in ANOTHER page\'s html does not place a string on this one');
|
|
100
|
+
assert.equal(place('Loaded late').file, 'mods/pub/late.js', 'a script elsewhere on the surface is the fallback');
|
|
101
|
+
assert.equal(place('Studio — Cloud Bongos').text, 'Studio — {{worldName}}', 'entities decode and a {{token}} is a hole');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('shared text is marked shared, keeps its row, and stays out of reading_hash', () => {
|
|
105
|
+
const own = { text: 'Welcome back', section: 'Home', shared: null };
|
|
106
|
+
const a = assemble([{ state: 'default', strings: [{ text: 'Signed in as Ada. Sign out', section: '', shared: 'top bar' }, own] }]);
|
|
107
|
+
const top = a.lines[0];
|
|
108
|
+
assert.equal(top.placement, 'shared');
|
|
109
|
+
assert.equal(top.section, 'top bar');
|
|
110
|
+
assert.equal(top.string_id, 'aaa000000003');
|
|
111
|
+
const b = assemble([{ state: 'default', strings: [{ text: 'Something else entirely', section: '', shared: 'top bar' }, own] }]);
|
|
112
|
+
assert.equal(b.lines[0].placement, 'shared', 'an unplaceable shell string is still shared');
|
|
113
|
+
assert.equal(a.reading_hash, b.reading_hash, 'a changed shell line does not move the page\'s own hash');
|
|
114
|
+
const c = assemble([{ state: 'default', strings: [{ text: 'Welcome back', section: 'Elsewhere', shared: null }] }]);
|
|
115
|
+
assert.notEqual(a.reading_hash, c.reading_hash, 'a changed own line does');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('other states add their lines where they sit, named, after the default state\'s', () => {
|
|
119
|
+
const r = assemble([
|
|
120
|
+
{ state: 'closed', strings: [{ text: 'Welcome back', section: 'Home', shared: null }, { text: 'Loaded late', section: 'Home', shared: null }] },
|
|
121
|
+
{ state: 'open', strings: [{ text: 'Welcome back', section: 'Home', shared: null }, { text: 'A panel only open shows', section: 'Home', shared: null }, { text: 'Loaded late', section: 'Home', shared: null }] },
|
|
122
|
+
]);
|
|
123
|
+
assert.deepEqual(r.lines.map((l) => [l.text, l.state || null]), [['Welcome back', null], ['A panel only open shows', 'open'], ['Loaded late', null]]);
|
|
124
|
+
assert.deepEqual(r.states_read, ['closed', 'open']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a string repeated in one section is one line with its occurrences', () => {
|
|
128
|
+
const r = assemble([{ state: 'default', strings: [1, 2, 3].map(() => ({ text: 'Welcome back', section: 'List', shared: null })) }]);
|
|
129
|
+
assert.equal(r.lines.length, 1);
|
|
130
|
+
assert.equal(r.lines[0].occurrences, 3);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('files_hash folds CRLF, and moves when a file changes or disappears', () => {
|
|
134
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-reader-'));
|
|
135
|
+
fs.writeFileSync(path.join(dir, 'a.html'), 'one\ntwo\n');
|
|
136
|
+
const lf = reader.filesHash(['a.html'], { root: dir });
|
|
137
|
+
fs.writeFileSync(path.join(dir, 'a.html'), 'one\r\ntwo\r\n');
|
|
138
|
+
assert.equal(reader.filesHash(['a.html'], { root: dir }), lf, 'a Windows checkout agrees with CI');
|
|
139
|
+
fs.writeFileSync(path.join(dir, 'a.html'), 'one\ntwo!\n');
|
|
140
|
+
assert.notEqual(reader.filesHash(['a.html'], { root: dir }), lf);
|
|
141
|
+
fs.rmSync(path.join(dir, 'a.html'));
|
|
142
|
+
assert.notEqual(reader.filesHash(['a.html'], { root: dir }), lf);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('checkReadings names a missing reading, a changed page and an extra one', () => {
|
|
146
|
+
const inv = { pages: [{ id: 's:a', files: ['x'] }, { id: 's:b', files: ['y'] }] };
|
|
147
|
+
const f = { readFileSync: (p) => (p.endsWith('x') ? 'X' : 'Y') };
|
|
148
|
+
const good = reader.filesHash(['x'], { fsImpl: f });
|
|
149
|
+
const stale = reader.checkReadings(inv, { pages: [{ id: 's:a', files_hash: good }, { id: 's:gone', files_hash: 'z' }] }, { fsImpl: f });
|
|
150
|
+
assert.deepEqual(stale, [{ id: 's:b', why: 'no reading' }, { id: 's:gone', why: 'not in the inventory' }]);
|
|
151
|
+
assert.deepEqual(reader.checkReadings(inv, { pages: [{ id: 's:a', files_hash: 'old' }, { id: 's:b', files_hash: reader.filesHash(['y'], { fsImpl: f }) }] }, { fsImpl: f }), [{ id: 's:a', why: 'its files changed' }]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('the committed readings cover every inventory page, in the D3 shape', () => {
|
|
155
|
+
const inv = JSON.parse(fs.readFileSync(path.join(ROOT, reader.INVENTORY_REL), 'utf8'));
|
|
156
|
+
const doc = JSON.parse(fs.readFileSync(path.join(ROOT, reader.READINGS_REL), 'utf8'));
|
|
157
|
+
assert.deepEqual(doc.pages.map((p) => p.id), inv.pages.map((p) => p.id), 'one reading per inventory page, in inventory order');
|
|
158
|
+
let unplaced = 0;
|
|
159
|
+
for (const p of doc.pages) {
|
|
160
|
+
assert.ok(p.lines.length > 0, `${p.id} has lines`);
|
|
161
|
+
p.lines.forEach((l, i) => {
|
|
162
|
+
assert.equal(l.key, `L${String(i + 1).padStart(4, '0')}`, `${p.id} keys run in order`);
|
|
163
|
+
assert.ok(typeof l.section === 'string' && l.section, `${p.id} ${l.key} has a section`);
|
|
164
|
+
assert.ok(typeof l.text === 'string' && l.text, `${p.id} ${l.key} has text`);
|
|
165
|
+
assert.ok(reader.PLACEMENTS.includes(l.placement), `${p.id} ${l.key} placement`);
|
|
166
|
+
if (l.placement === 'placed') assert.ok(l.file && Number.isInteger(l.line) && /^[0-9a-f]{12}$/.test(l.string_id), `${p.id} ${l.key} names its registry row`);
|
|
167
|
+
if (l.placement === 'unplaced') { unplaced += 1; assert.equal(l.string_id, undefined); }
|
|
168
|
+
});
|
|
169
|
+
assert.equal(p.reading_hash, reader.readingHash(p.lines), `${p.id} reading_hash is over its lines`);
|
|
170
|
+
assert.equal(p.counts.lines, p.lines.length);
|
|
171
|
+
}
|
|
172
|
+
assert.ok(unplaced > 0, 'the real pages carry unplaced strings, and they are listed');
|
|
173
|
+
assert.deepEqual(reader.checkReadings(inv, doc), [], 'the committed readings match this tree (re-read: node scripts/gds/page-reader.js --stale)');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('--check needs no browser and names the re-read command', () => {
|
|
177
|
+
// A preload that makes the browser driver unloadable: if --check ever reached
|
|
178
|
+
// for it, this run would fail.
|
|
179
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-reader-nobrowser-'));
|
|
180
|
+
const pre = path.join(dir, 'no-browser.cjs');
|
|
181
|
+
fs.writeFileSync(pre, "const M=require('module');const o=M._load;M._load=function(r,...a){if(/playwright|ui-design[\\\\/]kit/.test(r))throw new Error('browser reached: '+r);return o.call(this,r,...a);};\n");
|
|
182
|
+
const r = spawnSync(process.execPath, ['--require', pre, path.join(ROOT, 'scripts', 'gds', 'page-reader.js'), '--check'], { cwd: ROOT, encoding: 'utf8' });
|
|
183
|
+
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
184
|
+
assert.match(reader.REREAD_COMMAND, /page-reader\.js --stale/);
|
|
185
|
+
const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'page-reader.js'), 'utf8');
|
|
186
|
+
assert.match(src, /Re-read them \(opens a browser\): \$\{REREAD_COMMAND\}/, 'a stale --check names the command');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('ship-check runs the browser-free --check, and fitness does not render', () => {
|
|
190
|
+
const step = shipCheck.STEPS.find((s) => s.key === 'page-readings');
|
|
191
|
+
assert.ok(step, 'ship-check has a page-readings row');
|
|
192
|
+
assert.deepEqual(step.argv, ['scripts/gds/page-reader.js', '--check']);
|
|
193
|
+
assert.equal(step.fix, reader.REREAD_COMMAND);
|
|
194
|
+
const fitness = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'fitness.js'), 'utf8');
|
|
195
|
+
assert.doesNotMatch(fitness, /page-reader/, 'fitness never runs the reader (a render is the builder\'s own command)');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('a hall page with no states file is read once, at its own path', () => {
|
|
199
|
+
assert.deepEqual(reader.planRenders({ id: 'builders:x', surface: 'builders', path: '/x', states_files: [] }).map((p) => [p.state, p.url]), [['default', '/x']]);
|
|
200
|
+
});
|