@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
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
// tests/hall_tweak_editor.mjs — the tweak editor page (task 1004321 / BV2.TW10,
|
|
2
|
+
// ADR 0341 D4; docs/design/mocks/tweak-mode/Editor.dc.html).
|
|
3
|
+
//
|
|
4
|
+
// Boots the REAL page: tweak-editor.html's own body parsed into a mini-DOM, the
|
|
5
|
+
// real dom-utils.js, tweak-editor-lib.js and tweak-editor.js in a vm, against a
|
|
6
|
+
// stub client. What the client answers for the editor read is built by the REAL
|
|
7
|
+
// composer (page-status.js composeEditor) over a reading and a draft block the
|
|
8
|
+
// REAL pages.js wrote, so the page is tested on the route's own shape, never a
|
|
9
|
+
// hand-written literal of it.
|
|
10
|
+
//
|
|
11
|
+
// The done-when, as far as a page can prove it: rewrite a page (the whole draft
|
|
12
|
+
// is PUT, debounced), come back to it (the saved draft is what the boxes hold),
|
|
13
|
+
// the Word round trip (upload lands its lines in the boxes), submit, and take
|
|
14
|
+
// the next page (POST on the recommendation's own take path, then open it).
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import vm from 'node:vm';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { makeRunner, makeEl, parseInto, walk, settle } from './helpers.mjs';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const PUB = path.join(ROOT, 'modules/hall-ui/public');
|
|
27
|
+
const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
|
|
28
|
+
const lib = require('../modules/hall-ui/public/tweak-editor-lib.js');
|
|
29
|
+
const pages = require('../modules/copy-desk/pages.js');
|
|
30
|
+
const pageStatus = require('../modules/copy-desk/page-status.js');
|
|
31
|
+
|
|
32
|
+
const { test, summary } = makeRunner();
|
|
33
|
+
|
|
34
|
+
// ── the page's own data, through the real composer ─────────────────────────
|
|
35
|
+
const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
|
|
36
|
+
const READING = {
|
|
37
|
+
id: 'landing:index',
|
|
38
|
+
reading_hash: 'rh-1',
|
|
39
|
+
lines: [
|
|
40
|
+
L('L0001', 'top bar', 'Skip to content', 'shared', { file: 'modules/public-landing/public/index.html', line: 3, string_id: 's1' }),
|
|
41
|
+
L('L0002', 'top bar', 'Projects', 'shared'),
|
|
42
|
+
L('L0003', 'hero', 'Building Digitally,', 'placed', { file: 'modules/public-landing/public/index.html', line: 9, string_id: 'h1' }),
|
|
43
|
+
L('L0004', 'hero', 'AI agents claim tasks and ship the code.', 'placed', { file: 'modules/public-landing/public/index.html', line: 10, string_id: 'h2' }),
|
|
44
|
+
L('L0005', 'hero', '<img src=x onerror="window.pwned=1">', 'unplaced'),
|
|
45
|
+
L('L0006', 'footer', 'Built by agents.', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'f1' }),
|
|
46
|
+
L('L0007', 'top bar', 'Sign in', 'shared', { file: 'modules/public-landing/public/index.html', line: 5, string_id: 's2' }),
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
const PAGE = { id: 'landing:index', surface: 'landing', title: '{{productName}}: {{tagline}}', path: '/', url: 'https://{apex}/' };
|
|
50
|
+
const ME = '7';
|
|
51
|
+
function editorRead({ held = true, draftLines = [{ key: 'L0004', after: 'Agents write the code.' }], state = 'active', holder = ME } = {}) {
|
|
52
|
+
let description = pages.roundTaskBody(PAGE, 1);
|
|
53
|
+
if (draftLines) {
|
|
54
|
+
const r = pages.resolveDraft({ readingPage: READING, body: { reading_hash: 'rh-1', lines: draftLines }, savedAt: '2026-09-28T09:00:00.000Z' });
|
|
55
|
+
assert.equal(r.ok, true, JSON.stringify(r));
|
|
56
|
+
description += '\n\n' + pages.composeDraftBlock(r.draft);
|
|
57
|
+
}
|
|
58
|
+
const round = { id: 55, status: state, holder_id: held ? holder : null, holder_login: held ? (holder === ME ? 'me' : 'bob') : null, holder_worktree: false, description };
|
|
59
|
+
return pageStatus.composeEditor({ page: PAGE, readingPage: READING, round, builderId: ME });
|
|
60
|
+
}
|
|
61
|
+
const NEXT = {
|
|
62
|
+
resume: null,
|
|
63
|
+
recommended: { page_id: 'landing:projects', surface: 'landing', title: '{{productName}} — projects', path: '/projects', why_line: 'landing · 2 people asked for it', take: { method: 'POST', path: '/copy-desk/pages/landing:projects/claim' } },
|
|
64
|
+
alternatives: [
|
|
65
|
+
{ page_id: 'builders:home', surface: 'builders', title: 'Home', path: '/', why_line: 'builders hall · untweaked', take: { method: 'POST', path: '/copy-desk/pages/builders:home/claim' } },
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ── the mini-DOM over the page's own markup ────────────────────────────────
|
|
70
|
+
const HTML = read('tweak-editor.html');
|
|
71
|
+
const BODY = /<body[^>]*>([\s\S]*)<\/body>/.exec(HTML)[1].replace(/<script[\s\S]*?<\/script>/g, '');
|
|
72
|
+
const SOURCES = ['dom-utils.js', 'tweak-editor-lib.js', 'tweak-editor.js'].map((f) => [f, read(f)]);
|
|
73
|
+
|
|
74
|
+
function augment(el) {
|
|
75
|
+
if (el._aug) return el;
|
|
76
|
+
el._aug = true;
|
|
77
|
+
const classes = () => String(el.className || '').split(/\s+/).filter(Boolean);
|
|
78
|
+
el.classList = {
|
|
79
|
+
add: (c) => { const s = new Set(classes()); s.add(c); el.className = [...s].join(' '); },
|
|
80
|
+
remove: (c) => { el.className = classes().filter((x) => x !== c).join(' '); },
|
|
81
|
+
toggle: (c, force) => { const on = force === undefined ? !classes().includes(c) : !!force; if (on) el.classList.add(c); else el.classList.remove(c); return on; },
|
|
82
|
+
contains: (c) => classes().includes(c),
|
|
83
|
+
};
|
|
84
|
+
Object.defineProperty(el, 'hidden', { get() { return 'hidden' in el.attrs; }, set(v) { if (v) el.attrs.hidden = ''; else delete el.attrs.hidden; }, configurable: true });
|
|
85
|
+
Object.defineProperty(el, 'readOnly', { get() { return 'readonly' in el.attrs; }, set(v) { if (v) el.attrs.readonly = ''; else delete el.attrs.readonly; }, configurable: true });
|
|
86
|
+
Object.defineProperty(el, 'disabled', { get() { return 'disabled' in el.attrs; }, set(v) { if (v) el.attrs.disabled = ''; else delete el.attrs.disabled; }, configurable: true });
|
|
87
|
+
if (el.tagName === 'textarea') {
|
|
88
|
+
Object.defineProperty(el, 'value', { get() { return el._value !== null ? el._value : el._text; }, set(v) { el._value = String(v); }, configurable: true });
|
|
89
|
+
}
|
|
90
|
+
Object.defineProperty(el, 'innerHTML', {
|
|
91
|
+
get() { return el._html === null ? '' : el._html; },
|
|
92
|
+
set(v) { el._html = String(v); el._value = null; parseInto(el, el._html); walk(el, (c) => { augment(c); return null; }); },
|
|
93
|
+
configurable: true,
|
|
94
|
+
});
|
|
95
|
+
el.scrollHeight = 40;
|
|
96
|
+
el.inert = false;
|
|
97
|
+
el.focus = () => { boot.focused = el; };
|
|
98
|
+
el.click = () => { el.clicked = (el.clicked || 0) + 1; };
|
|
99
|
+
return el;
|
|
100
|
+
}
|
|
101
|
+
let boot = null;
|
|
102
|
+
|
|
103
|
+
function bootEditor({ search = '?page=landing:index', answers = {} } = {}) {
|
|
104
|
+
const root = augment(makeEl('body', {}));
|
|
105
|
+
root.innerHTML = BODY;
|
|
106
|
+
const byId = (id) => walk(root, (c) => (c.id === id ? c : null));
|
|
107
|
+
const docListeners = {};
|
|
108
|
+
const timers = [];
|
|
109
|
+
let timerId = 0;
|
|
110
|
+
const requests = [];
|
|
111
|
+
const fetches = [];
|
|
112
|
+
const documentObj = {
|
|
113
|
+
title: 'Tweak a page — Cloud Bongos',
|
|
114
|
+
body: root,
|
|
115
|
+
documentElement: { dataset: {} },
|
|
116
|
+
getElementById: byId,
|
|
117
|
+
querySelector: (sel) => root.querySelector(sel),
|
|
118
|
+
querySelectorAll: (sel) => root.querySelectorAll(sel),
|
|
119
|
+
createElement: (tag) => augment(makeEl(tag, {})),
|
|
120
|
+
addEventListener: (type, fn) => { (docListeners[type] = docListeners[type] || []).push(fn); },
|
|
121
|
+
};
|
|
122
|
+
const client = {
|
|
123
|
+
request: async (method, url, opts = {}) => {
|
|
124
|
+
requests.push({ method, url, body: opts.body });
|
|
125
|
+
const key = `${method} ${url.replace(/^\/api\/bongos/, '')}`;
|
|
126
|
+
const a = answers[key];
|
|
127
|
+
if (typeof a === 'function') return a(opts.body);
|
|
128
|
+
if (a) return a;
|
|
129
|
+
return { status: 404, ok: false, data: { error: { code: 'no_answer', message: key } } };
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
const windowObj = {
|
|
133
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
134
|
+
BongosClient: { createClient: () => client },
|
|
135
|
+
location: { host: 'builders.cloudbongos.com', hostname: 'builders.cloudbongos.com', protocol: 'https:', search, pathname: '/builders/tweak-editor', assign: (u) => { out.navigated = u; } },
|
|
136
|
+
history: { replaceState: (_, __, u) => { out.replaced = u; } },
|
|
137
|
+
localStorage: { getItem: () => 'off', setItem() {} },
|
|
138
|
+
addEventListener() {},
|
|
139
|
+
setTimeout: (fn, ms) => { const id = ++timerId; timers.push({ id, fn, ms }); return id; },
|
|
140
|
+
clearTimeout: (id) => { const i = timers.findIndex((t) => t.id === id); if (i >= 0) timers.splice(i, 1); },
|
|
141
|
+
};
|
|
142
|
+
const sandbox = {
|
|
143
|
+
window: windowObj, document: documentObj, location: windowObj.location,
|
|
144
|
+
fetch: async (url, init) => { fetches.push({ url, init }); const a = answers[`FETCH ${init.method} ${url.replace(/^\/api\/bongos/, '')}`]; return { ok: !!(a && a.ok), json: async () => (a ? a.data : null) }; },
|
|
145
|
+
console, URLSearchParams, setTimeout, clearTimeout,
|
|
146
|
+
};
|
|
147
|
+
sandbox.globalThis = sandbox;
|
|
148
|
+
vm.createContext(sandbox);
|
|
149
|
+
for (const [name, src] of SOURCES) vm.runInContext(src, sandbox, { filename: name });
|
|
150
|
+
|
|
151
|
+
const fire = (type, target) => (docListeners[type] || []).forEach((fn) => fn({ type, target }));
|
|
152
|
+
const out = {
|
|
153
|
+
requests, fetches, navigated: null, replaced: null, focused: null,
|
|
154
|
+
el: byId,
|
|
155
|
+
window: windowObj,
|
|
156
|
+
lines: () => root.querySelectorAll('textarea'),
|
|
157
|
+
type: (key, value) => { const ta = byId(`tw-${key}`); ta.value = value; fire('input', ta); },
|
|
158
|
+
click: async (id) => { const el = byId(id); assert.ok(el, `no #${id}`); fire('click', el); await settle(); await settle(); },
|
|
159
|
+
clickEl: async (el) => { fire('click', el); await settle(); await settle(); },
|
|
160
|
+
flushTimers: async () => { while (timers.length) { const [t] = timers.splice(0, 1); t.fn(); await settle(); await settle(); } },
|
|
161
|
+
pending: () => timers.length,
|
|
162
|
+
settle: async () => { for (let i = 0; i < 6; i++) await settle(); },
|
|
163
|
+
};
|
|
164
|
+
boot = out;
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
const ok = (data) => ({ status: 200, ok: true, data });
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
// ── the pure half ─────────────────────────────────────────────────────────
|
|
171
|
+
await test('sections are consecutive runs, so a section that recurs gets its heading again', () => {
|
|
172
|
+
assert.deepEqual(lib.sectionsOf(READING.lines).map((s) => [s.name, s.lines.length]),
|
|
173
|
+
[['top bar', 2], ['hero', 3], ['footer', 1], ['top bar', 1]]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await test('the margin note: shared and engineer lines keep theirs, a changed line says what it was', () => {
|
|
177
|
+
const ed = editorRead();
|
|
178
|
+
const byKey = Object.fromEntries(ed.reading.lines.map((l) => [l.key, l]));
|
|
179
|
+
assert.deepEqual(lib.noteFor(byKey.L0001, 'Skip', ed.notes), { kind: 'shared', text: 'changes this on every page' });
|
|
180
|
+
assert.deepEqual(lib.noteFor(byKey.L0002, 'Projects', ed.notes), { kind: 'engineer', text: 'goes to an engineer' }, 'shared with no registry row goes to an engineer, the submit\'s rule');
|
|
181
|
+
assert.deepEqual(lib.noteFor(byKey.L0004, 'Agents write the code.', ed.notes), { kind: 'was', text: 'was: AI agents claim tasks and ship the code.' });
|
|
182
|
+
assert.equal(lib.noteFor(byKey.L0003, 'Building Digitally,', ed.notes), null);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await test('the autosave body is the WHOLE draft: every changed, non-empty line, and nothing else', () => {
|
|
186
|
+
const values = lib.initialValues(READING.lines, { lines: [{ key: 'L0004', after: 'Agents write the code.' }] });
|
|
187
|
+
values.L0003 = ''; // emptied: removing words is a code change, never saved
|
|
188
|
+
values.L0006 = 'Built by agents.'; // back to the page's words: not a rewrite
|
|
189
|
+
values.L0007 = 'Log in';
|
|
190
|
+
assert.deepEqual(lib.draftBody('rh-1', READING.lines, values), {
|
|
191
|
+
reading_hash: 'rh-1',
|
|
192
|
+
lines: [{ key: 'L0004', after: 'Agents write the code.' }, { key: 'L0007', after: 'Log in' }],
|
|
193
|
+
});
|
|
194
|
+
assert.equal(lib.counterText(2, 7, 'saving'), '2 of 7 rewritten · saving…');
|
|
195
|
+
assert.equal(lib.counterText(2, 7, 'saved'), '2 of 7 rewritten · saved');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await test('names, addresses and the page id', () => {
|
|
199
|
+
assert.equal(lib.displayTitle(PAGE), 'Home', 'a templated title on "/" is the page called Home');
|
|
200
|
+
assert.equal(lib.displayTitle(NEXT.recommended), 'Projects');
|
|
201
|
+
assert.equal(lib.displayTitle({ title: 'Studio' }), 'Studio');
|
|
202
|
+
assert.equal(lib.liveUrl('https://{apex}/projects', { host: 'builders.cloudbongos.com', protocol: 'https:' }), 'https://cloudbongos.com/projects');
|
|
203
|
+
assert.equal(lib.liveUrl('https://builders.{apex}/studio', { host: 'builders.localhost:4631', protocol: 'http:' }), 'http://builders.localhost:4631/studio');
|
|
204
|
+
assert.equal(lib.pageIdFrom('?page=landing%3Aindex'), 'landing:index');
|
|
205
|
+
assert.equal(lib.pageIdFrom('?page=landing:index'), 'landing:index');
|
|
206
|
+
assert.equal(lib.pageIdFrom('?page=../../etc'), null);
|
|
207
|
+
assert.equal(lib.uploadedLabel({ from_file: ['L1', 'L2', 'L3'] }), 'uploaded · 3 lines from your doc');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ── the page, booted ─────────────────────────────────────────────────────
|
|
211
|
+
await test('the page opens on the read: every line in page order, the saved draft in its box, notes and the accent mark', async () => {
|
|
212
|
+
const b = bootEditor({ answers: { 'GET /copy-desk/pages/landing:index/draft': ok(editorRead()) } });
|
|
213
|
+
await b.settle();
|
|
214
|
+
assert.equal(b.requests[0].url, '/api/bongos/copy-desk/pages/landing:index/draft');
|
|
215
|
+
const boxes = b.lines();
|
|
216
|
+
assert.deepEqual(boxes.map((t) => t.dataset.key), READING.lines.map((l) => l.key));
|
|
217
|
+
assert.equal(b.el('tw-L0004').value, 'Agents write the code.', 'the saved draft, not the page text');
|
|
218
|
+
assert.ok(b.el('tw-L0004').classList.contains('tw-line--changed'), 'a changed line carries the accent mark');
|
|
219
|
+
assert.equal(b.el('tw-L0004-note').textContent, 'was: AI agents claim tasks and ship the code.');
|
|
220
|
+
assert.equal(b.el('tw-L0001-note').textContent, 'changes this on every page');
|
|
221
|
+
assert.equal(b.el('tw-L0005-note').textContent, 'goes to an engineer');
|
|
222
|
+
assert.equal(b.el('tw-counter').textContent, '1 of 7 rewritten · saved');
|
|
223
|
+
assert.equal(b.el('tw-name').textContent, 'Home');
|
|
224
|
+
assert.equal(b.el('tw-surface').textContent, '· landing');
|
|
225
|
+
assert.equal(b.el('tw-title').textContent, 'Home');
|
|
226
|
+
assert.equal(b.el('tw-open').href, 'https://cloudbongos.com/');
|
|
227
|
+
assert.equal(b.el('tw-submit').disabled, false);
|
|
228
|
+
assert.equal(b.lines().some((t) => t.readOnly), false);
|
|
229
|
+
// Section headings, one per consecutive run.
|
|
230
|
+
assert.deepEqual(b.el('tw-doc').querySelectorAll('h2').map((h) => h.textContent), ['top bar', 'hero', 'footer', 'top bar']);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await test('a hostile line is text, never markup', async () => {
|
|
234
|
+
const b = bootEditor({ answers: { 'GET /copy-desk/pages/landing:index/draft': ok(editorRead()) } });
|
|
235
|
+
await b.settle();
|
|
236
|
+
assert.equal(b.el('tw-doc').querySelectorAll('img').length, 0);
|
|
237
|
+
assert.equal(b.el('tw-L0005').value, '<img src=x onerror="window.pwned=1">');
|
|
238
|
+
assert.equal(b.window.pwned, undefined);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await test('typing autosaves the whole draft once, debounced, and the counter follows', async () => {
|
|
242
|
+
const puts = [];
|
|
243
|
+
const b = bootEditor({ answers: {
|
|
244
|
+
'GET /copy-desk/pages/landing:index/draft': ok(editorRead()),
|
|
245
|
+
'PUT /copy-desk/pages/landing:index/draft': (body) => { puts.push(body); return ok({ ok: true, outcome: 'saved' }); },
|
|
246
|
+
} });
|
|
247
|
+
await b.settle();
|
|
248
|
+
b.type('L0007', 'Log');
|
|
249
|
+
b.type('L0007', 'Log in');
|
|
250
|
+
assert.equal(b.el('tw-counter').textContent, '2 of 7 rewritten · saved', 'the count moves as you type');
|
|
251
|
+
assert.ok(b.el('tw-L0007-note').textContent === 'changes this on every page', 'a shared line keeps its note when changed');
|
|
252
|
+
assert.equal(puts.length, 0, 'debounced: nothing sent mid-word');
|
|
253
|
+
await b.flushTimers();
|
|
254
|
+
// vm-born objects: compared through JSON, never by prototype.
|
|
255
|
+
assert.equal(JSON.stringify(puts), JSON.stringify([{ reading_hash: 'rh-1', lines: [{ key: 'L0004', after: 'Agents write the code.' }, { key: 'L0007', after: 'Log in' }] }]));
|
|
256
|
+
assert.equal(b.el('tw-counter').textContent, '2 of 7 rewritten · saved');
|
|
257
|
+
// Nothing changed since: a second flush sends nothing.
|
|
258
|
+
b.el('tw-L0007').value = 'Log in';
|
|
259
|
+
await b.flushTimers();
|
|
260
|
+
assert.equal(puts.length, 1);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await test('a refused save says so in the counter and in the server\'s words', async () => {
|
|
264
|
+
const b = bootEditor({ answers: {
|
|
265
|
+
'GET /copy-desk/pages/landing:index/draft': ok(editorRead()),
|
|
266
|
+
'PUT /copy-desk/pages/landing:index/draft': { status: 409, ok: false, data: { error: { code: 'page_not_held', details: { what_this_means: 'Bob holds this page now, so only they can change its draft.' } } } },
|
|
267
|
+
} });
|
|
268
|
+
await b.settle();
|
|
269
|
+
b.type('L0003', 'Build here,');
|
|
270
|
+
await b.flushTimers();
|
|
271
|
+
assert.equal(b.el('tw-counter').textContent, '2 of 7 rewritten · not saved');
|
|
272
|
+
assert.equal(b.el('tw-notice').hidden, false);
|
|
273
|
+
assert.match(b.el('tw-notice').innerHTML, /Bob holds this page now/);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await test('a page you do not hold is read-only, with the one thing to do: take it', async () => {
|
|
277
|
+
let claimed = 0;
|
|
278
|
+
const answers = {
|
|
279
|
+
'GET /copy-desk/pages/landing:index/draft': () => ok(claimed ? editorRead() : editorRead({ held: false, state: 'ready' })),
|
|
280
|
+
'POST /copy-desk/pages/landing:index/claim': () => { claimed++; return ok({ ok: true, outcome: 'claimed' }); },
|
|
281
|
+
};
|
|
282
|
+
const b = bootEditor({ answers });
|
|
283
|
+
await b.settle();
|
|
284
|
+
assert.ok(b.lines().every((t) => t.readOnly));
|
|
285
|
+
assert.equal(b.el('tw-submit').disabled, true);
|
|
286
|
+
assert.ok(b.el('tw-claim'), 'the notice carries the take button');
|
|
287
|
+
await b.click('tw-claim');
|
|
288
|
+
await b.settle();
|
|
289
|
+
assert.equal(claimed, 1);
|
|
290
|
+
assert.equal(b.lines().some((t) => t.readOnly), false, 'taken: the words are yours to type over');
|
|
291
|
+
assert.equal(b.el('tw-notice').hidden, true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await test('someone else writing it: read-only, and named', async () => {
|
|
295
|
+
const b = bootEditor({ answers: { 'GET /copy-desk/pages/landing:index/draft': ok(editorRead({ holder: '8' })) } });
|
|
296
|
+
await b.settle();
|
|
297
|
+
assert.ok(b.lines().every((t) => t.readOnly));
|
|
298
|
+
assert.match(b.el('tw-notice').innerHTML, /bob is writing this page now/);
|
|
299
|
+
assert.equal(b.el('tw-claim'), null);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
await test('the Word upload lands its lines in the boxes and says how many', async () => {
|
|
303
|
+
const b = bootEditor({ answers: {
|
|
304
|
+
'GET /copy-desk/pages/landing:index/draft': ok(editorRead()),
|
|
305
|
+
'FETCH POST /copy-desk/pages/landing:index/draft.docx': ok({ ok: true, draft: { lines: [{ key: 'L0003', after: 'Build here,' }, { key: 'L0004', after: 'Agents write the code.' }] }, merge: { from_file: ['L0003'], kept_from_draft: ['L0004'] } }),
|
|
306
|
+
} });
|
|
307
|
+
await b.settle();
|
|
308
|
+
b.el('tw-file').fire('change', { target: { files: [{ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }], value: 'x' } });
|
|
309
|
+
await b.settle();
|
|
310
|
+
assert.equal(b.fetches[0].url, '/api/bongos/copy-desk/pages/landing:index/draft.docx');
|
|
311
|
+
assert.equal(b.fetches[0].init.method, 'POST');
|
|
312
|
+
assert.equal(b.el('tw-L0003').value, 'Build here,');
|
|
313
|
+
assert.equal(b.el('tw-upload').textContent, 'uploaded · 1 line from your doc');
|
|
314
|
+
assert.equal(b.el('tw-counter').textContent, '2 of 7 rewritten · saved');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await test('a refused upload names each moved line, escaped', async () => {
|
|
318
|
+
const b = bootEditor({ answers: {
|
|
319
|
+
'GET /copy-desk/pages/landing:index/draft': ok(editorRead()),
|
|
320
|
+
'FETCH POST /copy-desk/pages/landing:index/draft.docx': { ok: false, data: { error: { code: 'structure_changed', details: { what_this_means: 'The file\'s lines no longer match the page.', moved: [{ change: 'added', key: null, section: 'hero', first_words: '<b>new</b> line' }] } } } },
|
|
321
|
+
} });
|
|
322
|
+
await b.settle();
|
|
323
|
+
b.el('tw-file').fire('change', { target: { files: [{ type: '' }], value: 'x' } });
|
|
324
|
+
await b.settle();
|
|
325
|
+
const html = b.el('tw-notice').innerHTML;
|
|
326
|
+
assert.match(html, /no longer match the page/);
|
|
327
|
+
assert.match(html, /hero: “<b>new<\/b> line” \(added\)/);
|
|
328
|
+
assert.equal(b.el('tw-notice').querySelectorAll('b').length, 0);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
await test('submit: saves first, then "Home is in.", next up, and take next opens the next page', async () => {
|
|
332
|
+
const order = [];
|
|
333
|
+
const b = bootEditor({ answers: {
|
|
334
|
+
'GET /copy-desk/pages/landing:index/draft': ok(editorRead()),
|
|
335
|
+
'PUT /copy-desk/pages/landing:index/draft': () => { order.push('save'); return ok({ ok: true }); },
|
|
336
|
+
'POST /copy-desk/pages/landing:index/submit': () => { order.push('submit'); return ok({ ok: true, outcome: 'submitted', next: { path: '/copy-desk/next' } }); },
|
|
337
|
+
'GET /copy-desk/next': ok(NEXT),
|
|
338
|
+
'POST /copy-desk/pages/landing:projects/claim': () => { order.push('take'); return ok({ ok: true, outcome: 'created' }); },
|
|
339
|
+
} });
|
|
340
|
+
await b.settle();
|
|
341
|
+
b.type('L0003', 'Build here,');
|
|
342
|
+
await b.click('tw-submit');
|
|
343
|
+
await b.settle();
|
|
344
|
+
assert.deepEqual(order, ['save', 'submit'], 'the pending words are saved before the page is frozen');
|
|
345
|
+
assert.equal(b.el('tw-done').hidden, false);
|
|
346
|
+
assert.equal(b.el('tw-done-title').textContent, 'Home is in.');
|
|
347
|
+
assert.equal(b.el('tw-next-title').textContent, 'Projects');
|
|
348
|
+
assert.equal(b.el('tw-next-why').textContent, 'landing · 2 people asked for it');
|
|
349
|
+
assert.ok(b.lines().every((t) => t.readOnly), 'a submitted page is frozen');
|
|
350
|
+
assert.equal(b.el('tw-paper').inert, true);
|
|
351
|
+
assert.equal(b.el('tw-pick').hidden, false);
|
|
352
|
+
await b.click('tw-take');
|
|
353
|
+
assert.deepEqual(order, ['save', 'submit', 'take']);
|
|
354
|
+
assert.equal(b.navigated, '/builders/tweak-editor?page=landing:projects');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
await test('pick another swaps next up in place, and take next takes that one', async () => {
|
|
358
|
+
let taken = null;
|
|
359
|
+
const b = bootEditor({ answers: {
|
|
360
|
+
'GET /copy-desk/pages/landing:index/draft': ok(editorRead()),
|
|
361
|
+
'POST /copy-desk/pages/landing:index/submit': ok({ ok: true, outcome: 'submitted' }),
|
|
362
|
+
'GET /copy-desk/next': ok(NEXT),
|
|
363
|
+
'POST /copy-desk/pages/builders:home/claim': () => { taken = 'builders:home'; return ok({ ok: true }); },
|
|
364
|
+
} });
|
|
365
|
+
await b.settle();
|
|
366
|
+
await b.click('tw-submit');
|
|
367
|
+
await b.settle();
|
|
368
|
+
await b.click('tw-pick');
|
|
369
|
+
assert.equal(b.el('tw-picks').hidden, false);
|
|
370
|
+
assert.deepEqual(b.el('tw-picks').querySelectorAll('button').map((x) => x.textContent), ['Home']);
|
|
371
|
+
const chip = b.el('tw-picks').querySelectorAll('button')[0];
|
|
372
|
+
await b.clickEl(chip);
|
|
373
|
+
assert.equal(b.el('tw-next-title').textContent, 'Home');
|
|
374
|
+
assert.equal(b.el('tw-next-why').textContent, 'builders hall · untweaked');
|
|
375
|
+
assert.equal(b.el('tw-picks').hidden, true);
|
|
376
|
+
await b.click('tw-take');
|
|
377
|
+
assert.equal(taken, 'builders:home');
|
|
378
|
+
assert.equal(b.navigated, '/builders/tweak-editor?page=builders:home');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
await test('a refused submit names each line and freezes nothing', async () => {
|
|
382
|
+
const b = bootEditor({ answers: {
|
|
383
|
+
'GET /copy-desk/pages/landing:index/draft': ok(editorRead()),
|
|
384
|
+
'POST /copy-desk/pages/landing:index/submit': { status: 409, ok: false, data: { error: { code: 'lines_refused', details: { what_this_means: 'Some lines cannot be frozen as written.', refused: [{ key: 'L0004', section: 'hero', before: 'AI agents claim tasks and ship the code.', code: 'target_gone' }] } } } },
|
|
385
|
+
} });
|
|
386
|
+
await b.settle();
|
|
387
|
+
await b.click('tw-submit');
|
|
388
|
+
await b.settle();
|
|
389
|
+
assert.equal(b.el('tw-done').hidden, true);
|
|
390
|
+
assert.match(b.el('tw-notice').innerHTML, /hero: “AI agents claim tasks and ship the code.” \(target gone\)/);
|
|
391
|
+
assert.equal(b.el('tw-submit').disabled, false);
|
|
392
|
+
assert.equal(b.lines().some((t) => t.readOnly), false);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
await test('no page named: the page you hold opens (resume)', async () => {
|
|
396
|
+
const b = bootEditor({ search: '', answers: {
|
|
397
|
+
'GET /copy-desk/next': ok({ ...NEXT, resume: { page_id: 'landing:index', take: { path: '/copy-desk/pages/landing:index/claim' } } }),
|
|
398
|
+
'GET /copy-desk/pages/landing:index/draft': ok(editorRead()),
|
|
399
|
+
} });
|
|
400
|
+
await b.settle();
|
|
401
|
+
assert.equal(b.replaced, '?page=landing:index');
|
|
402
|
+
assert.equal(b.lines().length, 7);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// ── the page's contract with the hall ────────────────────────────────────
|
|
406
|
+
await test('the markup: one h1, the shell\'s full-screen switch, the lib before the page, no web font', () => {
|
|
407
|
+
assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
|
|
408
|
+
assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
|
|
409
|
+
assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
|
|
410
|
+
assert.match(HTML, /shell\.js\?v=2026-09-27-fullscreen/);
|
|
411
|
+
assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
|
|
412
|
+
for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks']) {
|
|
413
|
+
assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);
|
|
414
|
+
}
|
|
415
|
+
assert.match(HTML, />← studio</);
|
|
416
|
+
assert.match(HTML, />submit page</);
|
|
417
|
+
assert.match(HTML, />back to the studio</);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
await test('the page writes only the copy-desk routes it names', () => {
|
|
421
|
+
const js = read('tweak-editor.js');
|
|
422
|
+
const writes = [...js.matchAll(/api\.request\('(POST|PUT|PATCH|DELETE)', ([^,]+),/g)].map((m) => `${m[1]} ${m[2]}`);
|
|
423
|
+
assert.deepEqual(writes.sort(), [
|
|
424
|
+
'POST WRITES.claim(p.take.path)', 'POST WRITES.claim(path)', 'POST WRITES.submit(st.pageId)', 'PUT WRITES.draft(st.pageId)',
|
|
425
|
+
].sort());
|
|
426
|
+
const fetchCalls = [...js.matchAll(/fetch\(([^,]+),/g)].map((m) => m[1]);
|
|
427
|
+
assert.deepEqual(fetchCalls.sort(), ['WRITES.draft(st.pageId)', 'WRITES.upload(st.pageId)']);
|
|
428
|
+
assert.match(js, /const WRITES = Object\.freeze\(\{\n draft: \(id\) => `\$\{API\}\/copy-desk\/pages\/\$\{id\}\/draft`,\n upload: \(id\) => `\$\{API\}\/copy-desk\/pages\/\$\{id\}\/draft\.docx`,\n submit: \(id\) => `\$\{API\}\/copy-desk\/pages\/\$\{id\}\/submit`,/);
|
|
429
|
+
});
|
|
430
|
+
} finally {
|
|
431
|
+
summary();
|
|
432
|
+
}
|