@bongos/core 1.19.1061 → 1.19.1063
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 +94 -34
- 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 +7 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +178 -4
- package/docs/api-reference.md +6 -3
- package/docs/copy-inventory.md +44 -7
- package/docs/copy-registry.json +393 -26
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +36 -4
- package/docs/page-readings.json +34 -1
- package/modules/copy-desk/docx.js +818 -0
- package/modules/copy-desk/page-status.js +63 -0
- package/modules/copy-desk/pages.js +17 -0
- package/modules/copy-desk/routes/copy-desk.js +198 -9
- package/modules/copy-desk/tests/copy_docx.mjs +347 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +32 -3
- package/modules/copy-desk/tests/fixtures/word-docx.mjs +131 -0
- package/modules/hall-ui/public/tweak-editor-lib.js +137 -0
- package/modules/hall-ui/public/tweak-editor.css +437 -0
- package/modules/hall-ui/public/tweak-editor.html +111 -0
- package/modules/hall-ui/public/tweak-editor.js +447 -0
- package/modules/hall-ui/public/tweak-editor.states.json +99 -0
- package/modules/hall-ui/records/tweak-editor.md +32 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/fitness-checks-write-validation.js +4 -0
- package/scripts/hall-preview/server.js +21 -0
- package/src/bongos/serve-internal.js +5 -1
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_docx.mjs +279 -0
- package/tests/copy_desk_page_editor.mjs +181 -0
- package/tests/fitness.mjs +7 -2
- package/tests/hall_audit.mjs +5 -0
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/hall_tweak_editor.mjs +432 -0
|
@@ -132,6 +132,22 @@ const FIXTURE_FALLBACKS = [
|
|
|
132
132
|
// the npm-release module's line on a task's page (task 1004301): one reading serves any
|
|
133
133
|
// id, as the core-upgrade preview above does — the page shows one task.
|
|
134
134
|
[/^npm-release\/task\/\d+$/, 'npm-release__task'],
|
|
135
|
+
// the tweak editor's read (task 1004321): one page's lines and draft serve any
|
|
136
|
+
// page id, since the editor shows one page. A page id carries a colon, which a
|
|
137
|
+
// Windows file name cannot, so there is no per-id file to find first.
|
|
138
|
+
[/^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/draft$/, 'copy-desk__pages__draft'],
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
// CANNED WRITES (task 1004321). The tweak editor's whole flow is writes (the
|
|
142
|
+
// autosave, the submit, the claim "take next" posts), so without an answer the
|
|
143
|
+
// harness could only ever show its "not saved" state, never the page the
|
|
144
|
+
// artboard draws after a save or a submit. Each member answers a FIXED fixture
|
|
145
|
+
// and changes nothing: the harness has no state to change, and the page reads
|
|
146
|
+
// the same fixtures again on reload. Anything absent is still refused.
|
|
147
|
+
const CANNED_WRITES = [
|
|
148
|
+
['PUT', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/draft$/, 'copy-desk__pages__draft.put'],
|
|
149
|
+
['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/submit$/, 'copy-desk__pages__submit.post'],
|
|
150
|
+
['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/claim$/, 'copy-desk__pages__claim.post'],
|
|
135
151
|
];
|
|
136
152
|
|
|
137
153
|
// A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
|
|
@@ -254,6 +270,11 @@ app.use((req, res, next) => {
|
|
|
254
270
|
const READ_SHAPED_POSTS = new Set([
|
|
255
271
|
'inbox/preview-landing', // the landing verdict for a draft (task 1004083)
|
|
256
272
|
]);
|
|
273
|
+
const canned = CANNED_WRITES.find(([method, re]) => method === req.method && re.test(apiPath));
|
|
274
|
+
if (canned) {
|
|
275
|
+
const hit = loadFixture(canned[2]);
|
|
276
|
+
if (hit !== undefined) return res.json(hit);
|
|
277
|
+
}
|
|
257
278
|
if (req.method !== 'GET' && !READ_SHAPED_POSTS.has(apiPath)) {
|
|
258
279
|
return res.status(501).json({ error: { code: 'preview_readonly', message: 'the preview harness serves fixtures; writes are not wired' } });
|
|
259
280
|
}
|
|
@@ -1205,8 +1205,12 @@ function mountInternalSurfaces(app) {
|
|
|
1205
1205
|
// GET /sky/mine, with the room inside it as the list drawer. The old ambient
|
|
1206
1206
|
// page at `sky` (task 1004105) is gone; its URL 301s to /thinking below, so it
|
|
1207
1207
|
// leaves this regex — one URL cannot be two tiers, and a redirect is not a page.
|
|
1208
|
+
// task 1004321 (BV2.TW10): `tweak-editor`, Tweak Mode's editor, the artist's
|
|
1209
|
+
// own room beside the studio, on the studio's tier. Its read (GET
|
|
1210
|
+
// /copy-desk/pages/:pageId/draft) is any-builder like the .docx download, and
|
|
1211
|
+
// every write is the page claim's holder only, checked server-side.
|
|
1208
1212
|
const SIGNEDIN_BUILDERS_PAGE_RE =
|
|
1209
|
-
/^\/builders\/(?:work|settings|atlas|modules|goals|goal-map|roadmap|copy-desk|studio|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
1213
|
+
/^\/builders\/(?:work|settings|atlas|modules|goals|goal-map|roadmap|copy-desk|studio|tweak-editor|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
1210
1214
|
// The diagram image assets under /builders/diagrams/ ride the same tier as the
|
|
1211
1215
|
// page that embeds them.
|
|
1212
1216
|
const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
|
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.1063'; // 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,279 @@
|
|
|
1
|
+
// tests/copy_desk_page_docx.mjs — download a page as .docx, edit it the way
|
|
2
|
+
// Word stores an edit, and upload it back into the draft, the WHOLE path (task
|
|
3
|
+
// 1004320 / BV2.TW09, ADR 0341 D12).
|
|
4
|
+
//
|
|
5
|
+
// The done-when: a round trip with edits lands those edits in the draft, and a
|
|
6
|
+
// structure-changing upload is refused by name.
|
|
7
|
+
//
|
|
8
|
+
// Driven through the real copy-desk routes (with the real raw-body parser and
|
|
9
|
+
// its cap), the real lifecycle port (the page claim, updateHeldTaskDescription,
|
|
10
|
+
// submitPageTweak, createTask) and the real page reads, against the shared
|
|
11
|
+
// stateful fake ledger (tests/fixtures/page-ledger.mjs). The reading is a file
|
|
12
|
+
// on disk, read through the real page-data.js. The file is edited by the
|
|
13
|
+
// Word-shaped fixture, which re-packs it with its own zip writer.
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { makeRunner } from './helpers.mjs';
|
|
21
|
+
import { installPageLedger } from './fixtures/page-ledger.mjs';
|
|
22
|
+
import { resave, typeInto, wordRuns, lineParagraph } from '../modules/copy-desk/tests/fixtures/word-docx.mjs';
|
|
23
|
+
|
|
24
|
+
process.env.NODE_ENV = 'test';
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
|
|
27
|
+
const pages = require('../modules/copy-desk/pages.js');
|
|
28
|
+
const docx = require('../modules/copy-desk/docx.js');
|
|
29
|
+
const api = require('../src/module-api.js');
|
|
30
|
+
|
|
31
|
+
const { db } = installPageLedger(api, {
|
|
32
|
+
builders: [
|
|
33
|
+
{ id: '7', github_login: 'alice', display_name: 'Alice', preferred_disciplines: ['artist'] },
|
|
34
|
+
{ id: '8', github_login: 'bob', display_name: 'Bob', preferred_disciplines: ['artist'] },
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
const ALICE = '7';
|
|
38
|
+
const BOB = '8';
|
|
39
|
+
|
|
40
|
+
api.requireBuilder = (req, res, next) => {
|
|
41
|
+
req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'metic' };
|
|
42
|
+
next();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const seams = require('../src/module-seams.js');
|
|
46
|
+
const lifecyclePort = require('../modules/lifecycle/lifecycle.js');
|
|
47
|
+
if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', lifecyclePort);
|
|
48
|
+
|
|
49
|
+
const pageData = require('../modules/copy-desk/page-data.js');
|
|
50
|
+
const express = require('express');
|
|
51
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
52
|
+
const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
|
|
53
|
+
|
|
54
|
+
const app = express();
|
|
55
|
+
app.use(express.json());
|
|
56
|
+
app.use(attachFail);
|
|
57
|
+
app.use(buildRouter());
|
|
58
|
+
const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
|
|
59
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
60
|
+
|
|
61
|
+
async function call(method, p, builder, body = {}) {
|
|
62
|
+
const r = await fetch(base + p, { method, headers: { 'content-type': 'application/json', 'x-builder': builder }, body: method === 'GET' ? undefined : JSON.stringify(body) });
|
|
63
|
+
return { status: r.status, body: await r.json() };
|
|
64
|
+
}
|
|
65
|
+
async function getDocx(p, builder) {
|
|
66
|
+
const r = await fetch(base + p, { headers: { 'x-builder': builder } });
|
|
67
|
+
return { status: r.status, headers: r.headers, buf: Buffer.from(await r.arrayBuffer()) };
|
|
68
|
+
}
|
|
69
|
+
async function putDocx(p, builder, buf, type = docx.DOCX_MIME) {
|
|
70
|
+
const r = await fetch(base + p, { method: 'POST', headers: { 'content-type': type, 'x-builder': builder }, body: buf });
|
|
71
|
+
return { status: r.status, body: await r.json() };
|
|
72
|
+
}
|
|
73
|
+
const errCode = (b) => (b && b.error && b.error.code) || null;
|
|
74
|
+
const errDetail = (b) => (b && b.error && b.error.details) || {};
|
|
75
|
+
|
|
76
|
+
const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
|
|
77
|
+
const STUDIO_LINES = [
|
|
78
|
+
L('L0001', 'top bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
|
|
79
|
+
L('L0002', 'top bar', 'Ctrl K', 'shared'),
|
|
80
|
+
L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
|
|
81
|
+
L('L0004', 'greeting', '{…} pages tweaked', 'placed', { file: 'modules/hall-ui/public/studio.js', line: 88, string_id: 'st0004' }),
|
|
82
|
+
L('L0005', 'greeting', 'Your desk is quiet today', 'unplaced'),
|
|
83
|
+
L('L0006', 'footer', 'Resume where you left off', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 90, string_id: 'st0006' }),
|
|
84
|
+
];
|
|
85
|
+
const INDEX_LINES = [
|
|
86
|
+
L('L0001', 'hero', 'Build here', 'placed', { file: 'modules/public-landing/public/index.html', line: 3, string_id: 'ix0001' }),
|
|
87
|
+
];
|
|
88
|
+
const readingsWith = (studioHash) => ({
|
|
89
|
+
schema: 1,
|
|
90
|
+
pages: [
|
|
91
|
+
{ id: 'builders:studio', reading_hash: studioHash, lines: STUDIO_LINES },
|
|
92
|
+
{ id: 'landing:index', reading_hash: 'index-h1', lines: INDEX_LINES },
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-docx-'));
|
|
97
|
+
fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
|
|
98
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-inventory.json'), JSON.stringify(F.INVENTORY));
|
|
99
|
+
function writeReadings(r) {
|
|
100
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-readings.json'), JSON.stringify(r));
|
|
101
|
+
pageData._setRoot(tmp); // drop the (mtime, size) cache
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { test, summary } = makeRunner();
|
|
105
|
+
const STUDIO = '/copy-desk/pages/builders:studio';
|
|
106
|
+
const INDEX = '/copy-desk/pages/landing:index';
|
|
107
|
+
const studioTask = () => db.tasks.find((t) => t.source_ref === 'page-tweak/builders:studio/r1');
|
|
108
|
+
const draftLines = () => pages.parseDraftBlock(studioTask().description).draft.lines.map((l) => [l.key, l.before, l.after]);
|
|
109
|
+
let downloaded = null;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
writeReadings(readingsWith('studio-h1'));
|
|
113
|
+
|
|
114
|
+
await test('the download works before anyone claims the page: the reading, as a Word file', async () => {
|
|
115
|
+
const r = await getDocx(`${INDEX}/draft.docx`, BOB);
|
|
116
|
+
assert.equal(r.status, 200);
|
|
117
|
+
assert.equal(r.headers.get('content-type'), docx.DOCX_MIME);
|
|
118
|
+
assert.equal(r.headers.get('content-disposition'), 'attachment; filename="tweak-landing-index.docx"');
|
|
119
|
+
const u = docx.readUpload(r.buf);
|
|
120
|
+
assert.equal(u.ok, true);
|
|
121
|
+
assert.equal(u.page_id, 'landing:index');
|
|
122
|
+
assert.deepEqual([...u.baseline], [['L0001', 'Build here']]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await test('the download is built from the reading plus the artist\'s current draft', async () => {
|
|
126
|
+
assert.equal((await call('POST', `${STUDIO}/claim`, ALICE)).status, 201);
|
|
127
|
+
const saved = await call('PUT', `${STUDIO}/draft`, ALICE, { reading_hash: 'studio-h1', lines: [{ key: 'L0003', after: 'Welcome home' }] });
|
|
128
|
+
assert.equal(saved.body.outcome, 'saved');
|
|
129
|
+
const r = await getDocx(`${STUDIO}/draft.docx`, ALICE);
|
|
130
|
+
assert.equal(r.status, 200);
|
|
131
|
+
downloaded = r.buf;
|
|
132
|
+
const u = docx.readUpload(r.buf);
|
|
133
|
+
assert.deepEqual([u.page_id, u.reading_hash], ['builders:studio', 'studio-h1']);
|
|
134
|
+
assert.equal(u.baseline.get('L0003'), 'Welcome home', 'the draft rewrite, not the page text');
|
|
135
|
+
assert.equal(u.baseline.get('L0006'), 'Resume where you left off');
|
|
136
|
+
assert.deepEqual(u.paragraphs.filter((p) => p.tags.length).map((p) => p.tags[0]), STUDIO_LINES.map((l) => l.key));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await test('a round trip with edits (Word\'s split runs and smart quotes) lands those edits in the draft', async () => {
|
|
140
|
+
const writes = db.writes;
|
|
141
|
+
const edited = resave(downloaded, {
|
|
142
|
+
document: (xml) => typeInto(typeInto(xml, 'L0006', wordRuns(['Pick ', 'up where ', 'you’re ', 'at'])),
|
|
143
|
+
'L0004', wordRuns(['{…} ', 'pages rewritten'])),
|
|
144
|
+
});
|
|
145
|
+
const r = await putDocx(`${STUDIO}/draft.docx`, ALICE, edited);
|
|
146
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
147
|
+
assert.equal(r.body.outcome, 'saved');
|
|
148
|
+
assert.deepEqual(r.body.merge, { mode: 'tag', baseline: true, from_file: ['L0004', 'L0006'], kept_from_draft: ['L0003'] });
|
|
149
|
+
assert.deepEqual(r.body.counter, { changed: 3, total: 6 });
|
|
150
|
+
assert.deepEqual(draftLines(), [
|
|
151
|
+
['L0003', 'Welcome back', 'Welcome home'],
|
|
152
|
+
['L0004', '{…} pages tweaked', '{…} pages rewritten'],
|
|
153
|
+
['L0006', 'Resume where you left off', 'Pick up where you\'re at'],
|
|
154
|
+
]);
|
|
155
|
+
// Into the DRAFT and nowhere else: one write, the round still writing,
|
|
156
|
+
// no frozen batch, one draft block, and the reading on disk untouched.
|
|
157
|
+
assert.equal(db.writes, writes + 1);
|
|
158
|
+
assert.equal(studioTask().status, 'active');
|
|
159
|
+
assert.equal(pages.blockPresence(studioTask().description).batch, false);
|
|
160
|
+
assert.equal((studioTask().description.match(/```page-tweak-draft/g) || []).length, 1);
|
|
161
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(tmp, 'docs/page-readings.json'), 'utf8')), readingsWith('studio-h1'));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await test('the same upload again writes nothing', async () => {
|
|
165
|
+
const writes = db.writes;
|
|
166
|
+
const stamp = studioTask().updated_at;
|
|
167
|
+
const edited = resave(downloaded, {
|
|
168
|
+
document: (xml) => typeInto(typeInto(xml, 'L0006', wordRuns(['Pick ', 'up where ', 'you’re ', 'at'])),
|
|
169
|
+
'L0004', wordRuns(['{…} ', 'pages rewritten'])),
|
|
170
|
+
});
|
|
171
|
+
const r = await putDocx(`${STUDIO}/draft.docx`, ALICE, edited);
|
|
172
|
+
assert.equal(r.body.outcome, 'unchanged');
|
|
173
|
+
assert.equal(db.writes, writes);
|
|
174
|
+
assert.equal(studioTask().updated_at, stamp);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await test('a rewrite made in the studio after the download survives an upload of the older file', async () => {
|
|
178
|
+
// The artist keeps working in the studio (a whole-draft save), then
|
|
179
|
+
// uploads the file they downloaded earlier with one more Word edit.
|
|
180
|
+
const saved = await call('PUT', `${STUDIO}/draft`, ALICE, { reading_hash: 'studio-h1', lines: [{ key: 'L0003', after: 'Hello again' }, { key: 'L0005', after: 'Nothing is waiting' }] });
|
|
181
|
+
assert.equal(saved.body.outcome, 'saved');
|
|
182
|
+
const edited = resave(downloaded, { document: (xml) => typeInto(xml, 'L0001', wordRuns(['Your ', 'studio'])) });
|
|
183
|
+
const r = await putDocx(`${STUDIO}/draft.docx`, ALICE, edited);
|
|
184
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
185
|
+
assert.deepEqual(r.body.merge.from_file, ['L0001']);
|
|
186
|
+
assert.deepEqual(draftLines().map(([k, , a]) => [k, a]), [
|
|
187
|
+
['L0001', 'Your studio'],
|
|
188
|
+
['L0003', 'Hello again'],
|
|
189
|
+
['L0005', 'Nothing is waiting'],
|
|
190
|
+
]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await test('a structure-changing upload is refused by name, and nothing is saved', async () => {
|
|
194
|
+
const writes = db.writes;
|
|
195
|
+
const buf = resave(downloaded, {
|
|
196
|
+
document: (xml) => {
|
|
197
|
+
const p = lineParagraph(xml, 'L0003');
|
|
198
|
+
return xml.replace(lineParagraph(xml, 'L0005'), '').replace(p, () => `${p}<w:p><w:r><w:t>An extra thought</w:t></w:r></w:p>`);
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
const r = await putDocx(`${STUDIO}/draft.docx`, ALICE, buf);
|
|
202
|
+
assert.equal(r.status, 409);
|
|
203
|
+
assert.equal(errCode(r.body), 'structure_changed');
|
|
204
|
+
assert.deepEqual(errDetail(r.body).moved, [
|
|
205
|
+
{ change: 'added', key: null, section: 'greeting', first_words: 'An extra thought' },
|
|
206
|
+
{ change: 'dropped', key: 'L0005', section: 'greeting', first_words: 'Your desk is quiet today' },
|
|
207
|
+
]);
|
|
208
|
+
assert.match(errDetail(r.body).what_this_means, /added, removed or moved/);
|
|
209
|
+
assert.equal(db.writes, writes);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await test('another page\'s file is wrong_page; a file from an older reading is reading_moved', async () => {
|
|
213
|
+
assert.equal((await call('POST', `${INDEX}/claim`, BOB)).status, 201);
|
|
214
|
+
const wrong = await putDocx(`${INDEX}/draft.docx`, BOB, downloaded);
|
|
215
|
+
assert.equal(wrong.status, 409);
|
|
216
|
+
assert.equal(errCode(wrong.body), 'wrong_page');
|
|
217
|
+
assert.equal(errDetail(wrong.body).file_page_id, 'builders:studio');
|
|
218
|
+
|
|
219
|
+
writeReadings(readingsWith('studio-h2'));
|
|
220
|
+
const moved = await putDocx(`${STUDIO}/draft.docx`, ALICE, downloaded);
|
|
221
|
+
assert.equal(moved.status, 409);
|
|
222
|
+
assert.equal(errCode(moved.body), 'reading_moved');
|
|
223
|
+
assert.deepEqual([errDetail(moved.body).reading_hash, errDetail(moved.body).file_reading_hash], ['studio-h2', 'studio-h1']);
|
|
224
|
+
writeReadings(readingsWith('studio-h1'));
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test('only the holder may upload: page_not_held names the holder', async () => {
|
|
228
|
+
const r = await putDocx(`${STUDIO}/draft.docx`, BOB, downloaded);
|
|
229
|
+
assert.equal(r.status, 409);
|
|
230
|
+
assert.equal(errCode(r.body), 'page_not_held');
|
|
231
|
+
assert.deepEqual(errDetail(r.body).holder, { id: ALICE, login: 'alice', name: 'Alice' });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await test('the upload is user input: anything not a .docx is refused, and the size is capped', async () => {
|
|
235
|
+
const writes = db.writes;
|
|
236
|
+
const json = await call('POST', `${STUDIO}/draft.docx`, ALICE, { lines: [] });
|
|
237
|
+
assert.equal(json.status, 415);
|
|
238
|
+
assert.equal(errCode(json.body), 'not_a_docx');
|
|
239
|
+
const text = await putDocx(`${STUDIO}/draft.docx`, ALICE, Buffer.from('hello'), 'text/plain');
|
|
240
|
+
assert.equal(errCode(text.body), 'not_a_docx');
|
|
241
|
+
const junk = await putDocx(`${STUDIO}/draft.docx`, ALICE, Buffer.from('not a zip at all'));
|
|
242
|
+
assert.equal(junk.status, 415);
|
|
243
|
+
assert.equal(errCode(junk.body), 'not_a_docx');
|
|
244
|
+
const zip = await putDocx(`${STUDIO}/draft.docx`, ALICE, docx.writeZip([{ name: 'notes.txt', data: 'hi' }]), 'application/zip');
|
|
245
|
+
assert.equal(errCode(zip.body), 'not_a_docx');
|
|
246
|
+
const big = await putDocx(`${STUDIO}/draft.docx`, ALICE, Buffer.alloc(docx.MAX_UPLOAD_BYTES + 1024, 7));
|
|
247
|
+
assert.equal(big.status, 413);
|
|
248
|
+
assert.equal(errCode(big.body), 'upload_too_large');
|
|
249
|
+
assert.equal(db.writes, writes);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await test('no round is no_open_round; after submit the draft is frozen, so an upload is page_in_flight', async () => {
|
|
253
|
+
const indexFile = (await getDocx(`${INDEX}/draft.docx`, BOB)).buf;
|
|
254
|
+
// Bob holds index; a page nobody opened a round on has nowhere to land.
|
|
255
|
+
const inv = F.INVENTORY.pages.find((p) => p.id !== 'builders:studio' && p.id !== 'landing:index');
|
|
256
|
+
assert.ok(inv, 'the fixture inventory has a page with no round');
|
|
257
|
+
const readings = readingsWith('studio-h1');
|
|
258
|
+
readings.pages.push({ id: inv.id, reading_hash: 'x-h1', lines: [L('L0001', 'hero', 'Hi', 'placed', { file: 'f', line: 1, string_id: 's' })] });
|
|
259
|
+
writeReadings(readings);
|
|
260
|
+
const other = (await getDocx(`/copy-desk/pages/${inv.id}/draft.docx`, BOB)).buf;
|
|
261
|
+
const none = await putDocx(`/copy-desk/pages/${inv.id}/draft.docx`, BOB, other);
|
|
262
|
+
assert.equal(errCode(none.body), 'no_open_round');
|
|
263
|
+
writeReadings(readingsWith('studio-h1'));
|
|
264
|
+
const edited = resave(indexFile, { document: (xml) => typeInto(xml, 'L0001', wordRuns(['Build ', 'it here'])) });
|
|
265
|
+
assert.equal((await putDocx(`${INDEX}/draft.docx`, BOB, edited)).body.outcome, 'saved');
|
|
266
|
+
assert.equal((await call('POST', `${INDEX}/submit`, BOB)).body.outcome, 'submitted');
|
|
267
|
+
const after = await putDocx(`${INDEX}/draft.docx`, BOB, edited);
|
|
268
|
+
assert.equal(after.status, 409);
|
|
269
|
+
assert.equal(errCode(after.body), 'page_in_flight');
|
|
270
|
+
// The submitted batch carries the Word edit, with its target from the reading.
|
|
271
|
+
const t = db.tasks.find((x) => x.source_ref === 'page-tweak/landing:index/r1');
|
|
272
|
+
assert.deepEqual(pages.parseBatchBlock(t.description).batch.lines.map((l) => [l.key, l.file, l.before, l.after]),
|
|
273
|
+
[['L0001', 'modules/public-landing/public/index.html', 'Build here', 'Build it here']]);
|
|
274
|
+
});
|
|
275
|
+
} finally {
|
|
276
|
+
server.close();
|
|
277
|
+
pageData._setRoot(null);
|
|
278
|
+
}
|
|
279
|
+
summary();
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// tests/copy_desk_page_editor.mjs — the read the tweak editor opens on,
|
|
2
|
+
// GET /copy-desk/pages/:pageId/draft, the WHOLE path (task 1004321 / BV2.TW10,
|
|
3
|
+
// ADR 0341 D4).
|
|
4
|
+
//
|
|
5
|
+
// The done-when's middle: an artist rewrites a page, closes the tab, and comes
|
|
6
|
+
// back to the draft. So the read must give back every line of the page in page
|
|
7
|
+
// order with its margin note, say whether the caller holds the round, and hand
|
|
8
|
+
// back exactly the draft the autosave stored, and never a draft saved on
|
|
9
|
+
// another reading (its keys may name other lines).
|
|
10
|
+
//
|
|
11
|
+
// Driven through the real copy-desk routes, the real lifecycle port (the page
|
|
12
|
+
// claim, updateHeldTaskDescription, submitPageTweak) and the real page reads,
|
|
13
|
+
// against the shared stateful fake ledger (tests/fixtures/page-ledger.mjs).
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { makeRunner } from './helpers.mjs';
|
|
21
|
+
import { installPageLedger } from './fixtures/page-ledger.mjs';
|
|
22
|
+
|
|
23
|
+
process.env.NODE_ENV = 'test';
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
|
|
26
|
+
const pageStatus = require('../modules/copy-desk/page-status.js');
|
|
27
|
+
const api = require('../src/module-api.js');
|
|
28
|
+
|
|
29
|
+
installPageLedger(api, {
|
|
30
|
+
builders: [
|
|
31
|
+
{ id: '7', github_login: 'alice', display_name: 'Alice', preferred_disciplines: ['artist'] },
|
|
32
|
+
{ id: '8', github_login: 'bob', display_name: 'Bob', preferred_disciplines: ['artist'] },
|
|
33
|
+
],
|
|
34
|
+
});
|
|
35
|
+
const ALICE = '7';
|
|
36
|
+
const BOB = '8';
|
|
37
|
+
|
|
38
|
+
api.requireBuilder = (req, res, next) => {
|
|
39
|
+
req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'metic' };
|
|
40
|
+
next();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const seams = require('../src/module-seams.js');
|
|
44
|
+
const lifecyclePort = require('../modules/lifecycle/lifecycle.js');
|
|
45
|
+
if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', lifecyclePort);
|
|
46
|
+
|
|
47
|
+
const pageData = require('../modules/copy-desk/page-data.js');
|
|
48
|
+
const express = require('express');
|
|
49
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
50
|
+
const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
|
|
51
|
+
|
|
52
|
+
const app = express();
|
|
53
|
+
app.use(express.json());
|
|
54
|
+
app.use(attachFail);
|
|
55
|
+
app.use(buildRouter());
|
|
56
|
+
const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
|
|
57
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
58
|
+
|
|
59
|
+
async function call(method, p, builder, body = {}) {
|
|
60
|
+
const r = await fetch(base + p, { method, headers: { 'content-type': 'application/json', 'x-builder': builder }, body: method === 'GET' ? undefined : JSON.stringify(body) });
|
|
61
|
+
return { status: r.status, body: await r.json() };
|
|
62
|
+
}
|
|
63
|
+
const errCode = (b) => (b && b.error && b.error.code) || null;
|
|
64
|
+
|
|
65
|
+
const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
|
|
66
|
+
const STUDIO_LINES = [
|
|
67
|
+
L('L0001', 'top bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
|
|
68
|
+
L('L0002', 'top bar', 'Ctrl K', 'shared'),
|
|
69
|
+
L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
|
|
70
|
+
L('L0004', 'greeting', 'Your desk is quiet today', 'unplaced'),
|
|
71
|
+
L('L0005', 'footer', 'Resume where you left off', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 90, string_id: 'st0005' }),
|
|
72
|
+
];
|
|
73
|
+
const readingsWith = (studioHash, lines = STUDIO_LINES) => ({
|
|
74
|
+
schema: 1,
|
|
75
|
+
pages: [{ id: 'builders:studio', reading_hash: studioHash, lines }],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-editor-'));
|
|
79
|
+
fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
|
|
80
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-inventory.json'), JSON.stringify(F.INVENTORY));
|
|
81
|
+
function writeReadings(r) {
|
|
82
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-readings.json'), JSON.stringify(r));
|
|
83
|
+
pageData._setRoot(tmp); // drop the (mtime, size) cache
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { test, summary } = makeRunner();
|
|
87
|
+
const STUDIO = '/copy-desk/pages/builders:studio';
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
writeReadings(readingsWith('studio-h1'));
|
|
91
|
+
|
|
92
|
+
await test('before anyone claims: every line in page order, with its margin note, and no round', async () => {
|
|
93
|
+
const r = await call('GET', `${STUDIO}/draft`, ALICE);
|
|
94
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
95
|
+
assert.equal(r.body.page.title, 'Studio');
|
|
96
|
+
assert.equal(r.body.page.surface_word, 'builders hall');
|
|
97
|
+
assert.equal(r.body.reading.reading_hash, 'studio-h1');
|
|
98
|
+
assert.deepEqual(r.body.reading.lines.map((l) => [l.key, l.section, l.text, l.note]), [
|
|
99
|
+
['L0001', 'top bar', 'Studio', 'shared'],
|
|
100
|
+
// Shared shell text with no registry row cannot be applied mechanically,
|
|
101
|
+
// so it goes to an engineer at submit, and the note says so.
|
|
102
|
+
['L0002', 'top bar', 'Ctrl K', 'engineer'],
|
|
103
|
+
['L0003', 'greeting', 'Welcome back', null],
|
|
104
|
+
['L0004', 'greeting', 'Your desk is quiet today', 'engineer'],
|
|
105
|
+
['L0005', 'footer', 'Resume where you left off', null],
|
|
106
|
+
]);
|
|
107
|
+
assert.deepEqual(r.body.notes, { shared: 'changes this on every page', engineer: 'goes to an engineer' });
|
|
108
|
+
assert.equal(r.body.round, null);
|
|
109
|
+
assert.equal(r.body.draft, null);
|
|
110
|
+
assert.deepEqual(r.body.counter, { changed: 0, total: 5 });
|
|
111
|
+
// The read never exposes where a line lives in the code.
|
|
112
|
+
assert.equal(r.body.reading.lines.some((l) => 'file' in l || 'string_id' in l), false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await test('rewrite, close the tab, come back: the read hands back the saved draft, held by you', async () => {
|
|
116
|
+
assert.equal((await call('POST', `${STUDIO}/claim`, ALICE)).status, 201);
|
|
117
|
+
const saved = await call('PUT', `${STUDIO}/draft`, ALICE, {
|
|
118
|
+
reading_hash: 'studio-h1',
|
|
119
|
+
lines: [{ key: 'L0003', after: 'Welcome home' }, { key: 'L0001', after: 'The studio' }],
|
|
120
|
+
});
|
|
121
|
+
assert.equal(saved.body.outcome, 'saved');
|
|
122
|
+
const back = await call('GET', `${STUDIO}/draft`, ALICE);
|
|
123
|
+
assert.equal(back.status, 200);
|
|
124
|
+
assert.equal(back.body.round.state, 'writing');
|
|
125
|
+
assert.equal(back.body.round.held_by_you, true);
|
|
126
|
+
assert.equal(back.body.round.claim.path, '/copy-desk/pages/builders:studio/claim');
|
|
127
|
+
assert.deepEqual(back.body.draft.lines, [{ key: 'L0001', after: 'The studio' }, { key: 'L0003', after: 'Welcome home' }]);
|
|
128
|
+
assert.equal(back.body.draft.saved_at, saved.body.draft.saved_at);
|
|
129
|
+
assert.deepEqual(back.body.counter, { changed: 2, total: 5 });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await test('another artist sees the round held by its writer, not by them', async () => {
|
|
133
|
+
const r = await call('GET', `${STUDIO}/draft`, BOB);
|
|
134
|
+
assert.equal(r.body.round.held_by_you, false);
|
|
135
|
+
assert.deepEqual(r.body.round.holder, { id: ALICE, login: 'alice' });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await test('a draft saved on an older reading is reported, never laid over the new lines', async () => {
|
|
139
|
+
writeReadings(readingsWith('studio-h2'));
|
|
140
|
+
const r = await call('GET', `${STUDIO}/draft`, ALICE);
|
|
141
|
+
assert.equal(r.body.draft, null);
|
|
142
|
+
assert.equal(r.body.draft_on_older_reading, true);
|
|
143
|
+
assert.deepEqual(r.body.counter, { changed: 0, total: 5 });
|
|
144
|
+
writeReadings(readingsWith('studio-h1'));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await test('after submit the round reads submitted and nobody holds it', async () => {
|
|
148
|
+
assert.equal((await call('POST', `${STUDIO}/submit`, ALICE)).body.outcome, 'submitted');
|
|
149
|
+
const r = await call('GET', `${STUDIO}/draft`, ALICE);
|
|
150
|
+
assert.equal(r.body.round.state, 'submitted');
|
|
151
|
+
assert.equal(r.body.round.held_by_you, false);
|
|
152
|
+
assert.equal(r.body.round.holder, null);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await test('an unknown page is unknown_page, and a page with no reading is page_unread', async () => {
|
|
156
|
+
const r = await call('GET', '/copy-desk/pages/landing:nowhere/draft', ALICE);
|
|
157
|
+
assert.equal(r.status, 404);
|
|
158
|
+
assert.equal(errCode(r.body), 'unknown_page');
|
|
159
|
+
const u = await call('GET', '/copy-desk/pages/landing:index/draft', ALICE);
|
|
160
|
+
assert.equal(u.status, 503);
|
|
161
|
+
assert.equal(errCode(u.body), 'page_unread');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await test('composeEditor keeps only draft lines the reading still has', () => {
|
|
165
|
+
const out = pageStatus.composeEditor({
|
|
166
|
+
page: { id: 'builders:studio', title: 'Studio', surface: 'builders' },
|
|
167
|
+
readingPage: { reading_hash: 'h', lines: [STUDIO_LINES[2]] },
|
|
168
|
+
round: { id: 9, status: 'active', holder_id: 7, holder_login: 'alice', description: '```page-tweak-draft\n' + JSON.stringify({ schema: 1, page_id: 'builders:studio', reading_hash: 'h', saved_at: 'x', lines: [
|
|
169
|
+
{ key: 'L0003', section: 'greeting', before: 'Welcome back', after: 'Hi', placement: 'placed' },
|
|
170
|
+
{ key: 'L0099', section: 'gone', before: 'Old', after: 'New', placement: 'placed' },
|
|
171
|
+
] }) + '\n```\n' },
|
|
172
|
+
builderId: 7,
|
|
173
|
+
});
|
|
174
|
+
assert.deepEqual(out.draft.lines, [{ key: 'L0003', after: 'Hi' }]);
|
|
175
|
+
assert.equal(out.round.held_by_you, true);
|
|
176
|
+
});
|
|
177
|
+
} finally {
|
|
178
|
+
server.close();
|
|
179
|
+
pageData._setRoot(null);
|
|
180
|
+
}
|
|
181
|
+
summary();
|
package/tests/fitness.mjs
CHANGED
|
@@ -922,9 +922,14 @@ test('write-route validation check actually CATCHES an unvalidated write route (
|
|
|
922
922
|
assert.ok(/POST \/widgets/.test(flagged[0]), 'the offending route is named');
|
|
923
923
|
});
|
|
924
924
|
|
|
925
|
-
test('write-route validation exemptions are the known dynamic-key prefs routes
|
|
925
|
+
test('write-route validation exemptions are the known dynamic-key prefs routes, plus the one raw-file upload', () => {
|
|
926
|
+
// The .docx upload (task 1004320) has no JSON body to hold a field schema: its
|
|
927
|
+
// body is the file's bytes, capped by the route's parser and read by
|
|
928
|
+
// docx.readUpload. Named here so a second raw-body exemption is a reviewed diff.
|
|
929
|
+
const RAW_FILE_UPLOADS = ['POST /copy-desk/pages/:pageId/draft.docx'];
|
|
926
930
|
const exempt = [...fitness.WRITE_VALIDATION_EXEMPT];
|
|
927
|
-
assert.ok(exempt.length >= 1 && exempt.every((e) => e.startsWith('PATCH /me/')
|
|
931
|
+
assert.ok(exempt.length >= 1 && exempt.every((e) => e.startsWith('PATCH /me/') || RAW_FILE_UPLOADS.includes(e)),
|
|
932
|
+
'exemptions are all /me/* prefs patches or a named raw-file upload: ' + exempt.join(', '));
|
|
928
933
|
});
|
|
929
934
|
|
|
930
935
|
test('identity gate strips comments on CRLF checkouts too (task 1002428)', () => {
|
package/tests/hall_audit.mjs
CHANGED
|
@@ -424,6 +424,11 @@ const LITERAL_EXEMPT = {
|
|
|
424
424
|
'thinking.css': 'the sky\'s own sheet is its token tier (task 1004231, ADR 0331 — the design of record)',
|
|
425
425
|
'sky-interaction.js': 'the sky\'s hover card writes the template\'s ring colour inline (task 1004231)',
|
|
426
426
|
'sky-panel.js': 'the sky\'s panel writes the template\'s dead-bar colour inline (task 1004231)',
|
|
427
|
+
// The tweak editor (task 1004321 / BV2.TW10): docs/design/mocks/tweak-mode/
|
|
428
|
+
// Editor.dc.html is the owner-approved master design, built 1:1: the night
|
|
429
|
+
// studio, the glass bar, and a sheet of PAPER that stays paper in both modes,
|
|
430
|
+
// so its palette cannot be the hall's mode-flipping tier. The sky precedent.
|
|
431
|
+
'tweak-editor.css': 'the tweak editor\'s stage is the artboard\'s own palette (task 1004321, the pixel contract: paper in both modes)',
|
|
427
432
|
};
|
|
428
433
|
const COLOUR_PROPS = 'color|background|background-color|border|border-color|border-top-color|border-bottom-color|fill|stroke|outline|outline-color|box-shadow|text-shadow';
|
|
429
434
|
const colourLiteralRe = () =>
|
|
@@ -62,6 +62,9 @@ const ORACLE = {
|
|
|
62
62
|
// whole gate, and its two reads (GET /tasks, GET /copy-desk/queue) already gate
|
|
63
63
|
// themselves server-side.
|
|
64
64
|
'/builders/studio': 'requireBuilderPage',
|
|
65
|
+
// task 1004321 (BV2.TW10): the tweak editor, on the studio's own tier. Its read
|
|
66
|
+
// is any-builder like the .docx download; every write is the page holder's only.
|
|
67
|
+
'/builders/tweak-editor': 'requireBuilderPage',
|
|
65
68
|
'/builders/ideas': 'requireBuilderPage',
|
|
66
69
|
// THE ROOM (task 1004098 / BV2.R03) — a builder's own idea trail. Sign-in and
|
|
67
70
|
// no rank floor, the tier /ideas sits on: there is no permission gate on
|