@bongos/core 1.19.1059 → 1.19.1061
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 +58 -28
- 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 +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +193 -3
- package/docs/api-reference.md +5 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/flags.js +14 -1
- package/modules/copy-desk/page-status.js +177 -3
- package/modules/copy-desk/pages.js +200 -0
- package/modules/copy-desk/routes/copy-desk.js +349 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +36 -1
- package/modules/copy-desk/tests/copy_page_draft.mjs +202 -0
- package/modules/copy-desk/tests/copy_page_next.mjs +184 -0
- package/modules/lifecycle/lifecycle.js +8 -0
- package/modules/lifecycle/page-tweak-reads.js +11 -2
- package/modules/lifecycle/page-tweak-write.js +192 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_flags_db.mjs +12 -0
- package/tests/copy_desk_page_claim.mjs +9 -124
- package/tests/copy_desk_page_next.mjs +172 -0
- package/tests/copy_desk_page_submit.mjs +289 -0
- package/tests/fixtures/page-ledger.mjs +206 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// tests/copy_desk_page_next.mjs — "recommend the next page" and "take next",
|
|
2
|
+
// the WHOLE path (task 1004318 / BV2.TW07, ADR 0341 D5, D8, D11).
|
|
3
|
+
//
|
|
4
|
+
// The done-when: the recommendation follows the order on a fixture, skips held
|
|
5
|
+
// pages, and "take next" claims the recommended page in one call.
|
|
6
|
+
//
|
|
7
|
+
// modules/copy-desk/tests/copy_page_next.mjs proves the order on hand-shaped
|
|
8
|
+
// rows. This drives the path those rows travel: GET /copy-desk/next through
|
|
9
|
+
// the real route, the real lifecycle read (listPageTweakTasks) and the real
|
|
10
|
+
// page-ask summary SQL, then POST the recommendation's own `take` path through
|
|
11
|
+
// the real page claim (webClaimPageTweak, createTask), all against one stateful
|
|
12
|
+
// fake ledger (tests/fixtures/page-ledger.mjs). So a claim made by "take next"
|
|
13
|
+
// is what the next read sees, with no hand-built rows in between.
|
|
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 pages = require('../modules/copy-desk/pages.js');
|
|
27
|
+
const api = require('../src/module-api.js');
|
|
28
|
+
|
|
29
|
+
const { db } = 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
|
+
{ id: '9', github_login: 'carol', display_name: 'Carol', preferred_disciplines: ['engineer'] },
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
const ALICE = '7';
|
|
37
|
+
const BOB = '8';
|
|
38
|
+
const CAROL = '9';
|
|
39
|
+
|
|
40
|
+
api.requireBuilder = (req, res, next) => {
|
|
41
|
+
req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'thetes' };
|
|
42
|
+
next();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const seams = require('../src/module-seams.js');
|
|
46
|
+
if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', require('../modules/lifecycle/lifecycle.js'));
|
|
47
|
+
|
|
48
|
+
const pageData = require('../modules/copy-desk/page-data.js');
|
|
49
|
+
const express = require('express');
|
|
50
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
51
|
+
const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
|
|
52
|
+
|
|
53
|
+
const app = express();
|
|
54
|
+
app.use(express.json());
|
|
55
|
+
app.use(attachFail);
|
|
56
|
+
app.use(buildRouter());
|
|
57
|
+
const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
|
|
58
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
59
|
+
|
|
60
|
+
async function call(method, p, builder, body) {
|
|
61
|
+
const r = await fetch(base + p, {
|
|
62
|
+
method, headers: { 'content-type': 'application/json', 'x-builder': builder },
|
|
63
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
64
|
+
});
|
|
65
|
+
return { status: r.status, body: await r.json() };
|
|
66
|
+
}
|
|
67
|
+
const getNext = (builder) => call('GET', '/copy-desk/next', builder);
|
|
68
|
+
const errCode = (b) => (b && b.error && b.error.code) || null;
|
|
69
|
+
const order = (b) => [b.recommended, ...b.alternatives].filter(Boolean).map((c) => c.page_id);
|
|
70
|
+
|
|
71
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-next-'));
|
|
72
|
+
fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
|
|
73
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-inventory.json'), JSON.stringify(F.INVENTORY));
|
|
74
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-readings.json'), JSON.stringify(F.READINGS));
|
|
75
|
+
|
|
76
|
+
// The world before anyone asks: builders:studio was tweaked once by alice
|
|
77
|
+
// (her web claim, released at submit), and has drifted since ("Welcome home"
|
|
78
|
+
// became "Welcome back"). Every other page is untweaked.
|
|
79
|
+
db.tasks.push({
|
|
80
|
+
id: '4001', status: 'shipped', title: 'Tweak: Studio (builders:studio)', source: 'page-tweak',
|
|
81
|
+
source_ref: pages.roundSourceRef('builders:studio', 1), touches: F.STUDIO_FILES,
|
|
82
|
+
shipped_at: '2026-09-20T10:00:00.000Z',
|
|
83
|
+
description: F.describe({
|
|
84
|
+
pageId: 'builders:studio', submittedAt: '2026-09-19T10:00:00Z',
|
|
85
|
+
lines: [{ key: 'L0002', section: 'greeting', before: 'Welcome', after: 'Welcome home' }],
|
|
86
|
+
appliedAt: '2026-09-19T18:00:00Z', linesAfter: ['Welcome home', 'Resume'],
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
db.claims.push({ id: '8001', task_id: '4001', builder_id: ALICE, worktree_name: null, creator_session_id: null, claimed_at: '2026-09-18T10:00:00.000Z', released_at: '2026-09-19T10:00:00.000Z' });
|
|
90
|
+
db.grades.set('4001', true);
|
|
91
|
+
|
|
92
|
+
const { test, summary } = makeRunner();
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
pageData._setRoot(tmp);
|
|
96
|
+
|
|
97
|
+
await test('anyone asks; the recommendation reads asked, then changed, then untweaked', async () => {
|
|
98
|
+
assert.equal((await call('POST', '/copy-desk/pages/landing:projects/asks', CAROL, { reason: 'The project cards all read the same.' })).status, 201);
|
|
99
|
+
assert.equal((await call('POST', '/copy-desk/pages/landing:projects/asks', BOB, { reason: 'Agreed, the cards need a line each.' })).status, 201);
|
|
100
|
+
assert.equal((await call('POST', '/copy-desk/pages/status:explore/asks', CAROL, { reason: 'Explore does not say what it shows.' })).status, 201);
|
|
101
|
+
|
|
102
|
+
const { status, body } = await getNext(ALICE);
|
|
103
|
+
assert.equal(status, 200, JSON.stringify(body));
|
|
104
|
+
assert.deepEqual(order(body), ['landing:projects', 'status:explore', 'builders:studio', 'landing:index']);
|
|
105
|
+
assert.equal(body.recommended.why, '2 people asked for it');
|
|
106
|
+
assert.equal(body.recommended.why_line, 'landing · 2 people asked for it · untweaked');
|
|
107
|
+
assert.equal(body.alternatives[1].why, '1 line changed since your last pass', 'the round artist is the web claim, alice');
|
|
108
|
+
assert.equal(body.asked_pages, 2);
|
|
109
|
+
assert.equal(body.resume, null);
|
|
110
|
+
assert.deepEqual(body.skipped, []);
|
|
111
|
+
assert.equal(body.readings.ok, true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await test('"take next" claims the recommended page in one call', async () => {
|
|
115
|
+
const { body: next } = await getNext(ALICE);
|
|
116
|
+
const before = db.tasks.length;
|
|
117
|
+
const take = await call(next.recommended.take.method, next.recommended.take.path, ALICE, {});
|
|
118
|
+
assert.equal(take.status, 201, JSON.stringify(take.body));
|
|
119
|
+
assert.equal(take.body.outcome, 'created');
|
|
120
|
+
assert.equal(take.body.page_id, next.recommended.page_id);
|
|
121
|
+
assert.equal(take.body.round.source_ref, 'page-tweak/landing:projects/r1');
|
|
122
|
+
assert.equal(db.tasks.length, before + 1);
|
|
123
|
+
const claim = db.claims.find((c) => c.task_id === take.body.round.task_id && c.released_at == null);
|
|
124
|
+
assert.deepEqual([claim.builder_id, claim.worktree_name, claim.creator_session_id], [ALICE, null, null], 'a web claim');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await test('the held page is Resume for its holder, and skipped (holder named) for everyone else', async () => {
|
|
128
|
+
const mine = (await getNext(ALICE)).body;
|
|
129
|
+
assert.equal(mine.resume.page_id, 'landing:projects');
|
|
130
|
+
assert.ok(!order(mine).includes('landing:projects'));
|
|
131
|
+
assert.equal(mine.recommended.page_id, 'status:explore');
|
|
132
|
+
|
|
133
|
+
const bobs = (await getNext(BOB)).body;
|
|
134
|
+
assert.equal(bobs.resume, null);
|
|
135
|
+
assert.ok(!order(bobs).includes('landing:projects'), 'never a page someone else holds, however many asked');
|
|
136
|
+
assert.deepEqual(bobs.skipped, [{ page_id: 'landing:projects', reason: 'page_held', state: 'writing', holder: { id: ALICE, login: 'alice' } }]);
|
|
137
|
+
assert.equal(bobs.recommended.page_id, 'status:explore');
|
|
138
|
+
assert.equal(bobs.alternatives[0].why, '1 line changed since the last pass', 'bob did not make that pass');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await test('take next for a second artist gets the next page, not the held one', async () => {
|
|
142
|
+
const { body: next } = await getNext(BOB);
|
|
143
|
+
const take = await call('POST', next.recommended.take.path, BOB, {});
|
|
144
|
+
assert.equal(take.status, 201, JSON.stringify(take.body));
|
|
145
|
+
assert.equal(take.body.page_id, 'status:explore');
|
|
146
|
+
const after = (await getNext(CAROL)).body;
|
|
147
|
+
assert.deepEqual(after.skipped.map((s) => s.page_id).sort(), ['landing:projects', 'status:explore']);
|
|
148
|
+
assert.deepEqual(order(after), ['builders:studio', 'landing:index']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await test('a submitted round is in flight: skipped, and the claim agrees', async () => {
|
|
152
|
+
const round = db.tasks.find((t) => t.source_ref === 'page-tweak/status:explore/r1');
|
|
153
|
+
db.claims.find((c) => c.task_id === round.id && c.released_at == null).released_at = new Date().toISOString();
|
|
154
|
+
round.status = 'ready';
|
|
155
|
+
round.description += '\n\n' + pages.composeBatchBlock({ page_id: 'status:explore', reading_hash: 'h', submitted_at: 'x', lines: [{ key: 'L0001', section: 'hero', before: 'Explore', after: 'Look around', placement: 'placed', file: 'x', line: 1, string_id: 'a' }] });
|
|
156
|
+
const next = (await getNext(ALICE)).body;
|
|
157
|
+
assert.deepEqual(next.skipped, [{ page_id: 'status:explore', reason: 'page_in_flight', state: 'submitted' }]);
|
|
158
|
+
const refused = await call('POST', '/copy-desk/pages/status:explore/claim', ALICE, {});
|
|
159
|
+
assert.equal(errCode(refused.body), 'page_in_flight');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await test('no page inventory: a named 503, never a crash', async () => {
|
|
163
|
+
pageData._setRoot(fs.mkdtempSync(path.join(os.tmpdir(), 'page-next-empty-')));
|
|
164
|
+
const r = await getNext(ALICE);
|
|
165
|
+
assert.equal(r.status, 503);
|
|
166
|
+
assert.equal(errCode(r.body), 'page_inventory_missing');
|
|
167
|
+
});
|
|
168
|
+
} finally {
|
|
169
|
+
server.close();
|
|
170
|
+
pageData._setRoot(null);
|
|
171
|
+
}
|
|
172
|
+
summary();
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// tests/copy_desk_page_submit.mjs — save a page draft, submit it, and split the
|
|
2
|
+
// unplaced lines out, the WHOLE path (task 1004319 / BV2.TW08, ADR 0341 D4, D7).
|
|
3
|
+
//
|
|
4
|
+
// The done-when: draft save, submit and the unplaced split work end to end.
|
|
5
|
+
//
|
|
6
|
+
// Driven through the real copy-desk routes, the real lifecycle port (the page
|
|
7
|
+
// claim, updateHeldTaskDescription, submitPageTweak, and the real createTask
|
|
8
|
+
// under it) and the real page reads, against the shared stateful fake ledger
|
|
9
|
+
// (tests/fixtures/page-ledger.mjs), whose page lock and task row lock are real
|
|
10
|
+
// mutexes. The reading is a file on disk, read through the real page-data.js.
|
|
11
|
+
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { makeRunner } from './helpers.mjs';
|
|
18
|
+
import { installPageLedger } from './fixtures/page-ledger.mjs';
|
|
19
|
+
|
|
20
|
+
process.env.NODE_ENV = 'test';
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
|
|
23
|
+
const pages = require('../modules/copy-desk/pages.js');
|
|
24
|
+
const api = require('../src/module-api.js');
|
|
25
|
+
|
|
26
|
+
const { db, seen } = installPageLedger(api, {
|
|
27
|
+
builders: [
|
|
28
|
+
{ id: '7', github_login: 'alice', display_name: 'Alice', preferred_disciplines: ['artist'] },
|
|
29
|
+
{ id: '8', github_login: 'bob', display_name: 'Bob', preferred_disciplines: ['artist'] },
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
const ALICE = '7';
|
|
33
|
+
const BOB = '8';
|
|
34
|
+
|
|
35
|
+
api.requireBuilder = (req, res, next) => {
|
|
36
|
+
req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'metic' };
|
|
37
|
+
next();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const seams = require('../src/module-seams.js');
|
|
41
|
+
const lifecyclePort = require('../modules/lifecycle/lifecycle.js');
|
|
42
|
+
if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', lifecyclePort);
|
|
43
|
+
|
|
44
|
+
const pageData = require('../modules/copy-desk/page-data.js');
|
|
45
|
+
const express = require('express');
|
|
46
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
47
|
+
const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
|
|
48
|
+
|
|
49
|
+
const app = express();
|
|
50
|
+
app.use(express.json());
|
|
51
|
+
app.use(attachFail);
|
|
52
|
+
app.use(buildRouter());
|
|
53
|
+
const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
|
|
54
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
55
|
+
|
|
56
|
+
async function call(method, p, builder, body = {}) {
|
|
57
|
+
const r = await fetch(base + p, { method, headers: { 'content-type': 'application/json', 'x-builder': builder }, body: method === 'GET' ? undefined : JSON.stringify(body) });
|
|
58
|
+
return { status: r.status, body: await r.json() };
|
|
59
|
+
}
|
|
60
|
+
const errCode = (b) => (b && b.error && b.error.code) || null;
|
|
61
|
+
const errDetail = (b) => (b && b.error && b.error.details) || {};
|
|
62
|
+
|
|
63
|
+
// The studio reading, in page-reader.js's shape: a shared line with its
|
|
64
|
+
// registry row, a shared line without one, placed lines, one with {…} holes,
|
|
65
|
+
// and an unplaced line.
|
|
66
|
+
const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
|
|
67
|
+
const STUDIO_LINES = [
|
|
68
|
+
L('L0001', 'top bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
|
|
69
|
+
L('L0002', 'top bar', 'Ctrl K', 'shared'),
|
|
70
|
+
L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
|
|
71
|
+
L('L0004', 'greeting', '{…} pages tweaked', 'placed', { file: 'modules/hall-ui/public/studio.js', line: 88, string_id: 'st0004' }),
|
|
72
|
+
L('L0005', 'greeting', 'Your desk is quiet today', 'unplaced'),
|
|
73
|
+
];
|
|
74
|
+
const INDEX_LINES = [
|
|
75
|
+
L('L0001', 'hero', 'Build here', 'placed', { file: 'modules/public-landing/public/index.html', line: 3, string_id: 'ix0001' }),
|
|
76
|
+
L('L0002', 'hero', 'Start a project', 'placed', { file: 'modules/public-landing/public/index.html', line: 4, string_id: 'ix0002' }),
|
|
77
|
+
];
|
|
78
|
+
const readingsWith = (studio, index) => ({
|
|
79
|
+
schema: 1,
|
|
80
|
+
pages: [
|
|
81
|
+
{ id: 'builders:studio', reading_hash: studio.hash, lines: studio.lines },
|
|
82
|
+
{ id: 'landing:index', reading_hash: index.hash, lines: index.lines },
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-submit-'));
|
|
87
|
+
fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
|
|
88
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-inventory.json'), JSON.stringify(F.INVENTORY));
|
|
89
|
+
function writeReadings(r) {
|
|
90
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-readings.json'), JSON.stringify(r));
|
|
91
|
+
pageData._setRoot(tmp); // drop the (mtime, size) cache
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { test, summary } = makeRunner();
|
|
95
|
+
const STUDIO = '/copy-desk/pages/builders:studio';
|
|
96
|
+
const INDEX = '/copy-desk/pages/landing:index';
|
|
97
|
+
const studioTask = () => db.tasks.find((t) => t.source_ref === 'page-tweak/builders:studio/r1');
|
|
98
|
+
const DRAFT = {
|
|
99
|
+
reading_hash: 'studio-h1',
|
|
100
|
+
lines: [
|
|
101
|
+
{ key: 'L0003', after: 'Welcome home' },
|
|
102
|
+
{ key: 'L0004', after: '{…} pages rewritten so far' },
|
|
103
|
+
{ key: 'L0005', after: 'Nothing is waiting for you' },
|
|
104
|
+
{ key: 'L0002', after: 'Control K' },
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
writeReadings(readingsWith({ hash: 'studio-h1', lines: STUDIO_LINES }, { hash: 'index-h1', lines: INDEX_LINES }));
|
|
110
|
+
|
|
111
|
+
await test('no round yet: a draft save is no_open_round, and nothing is written', async () => {
|
|
112
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, DRAFT);
|
|
113
|
+
assert.equal(r.status, 409);
|
|
114
|
+
assert.equal(errCode(r.body), 'no_open_round');
|
|
115
|
+
assert.equal(db.tasks.length, 0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await test('the holder saves a draft: it rests on the round task, resolved from the reading', async () => {
|
|
119
|
+
assert.equal((await call('POST', `${STUDIO}/claim`, ALICE)).status, 201);
|
|
120
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, DRAFT);
|
|
121
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
122
|
+
assert.equal(r.body.outcome, 'saved');
|
|
123
|
+
assert.deepEqual(r.body.counter, { changed: 4, total: 5 });
|
|
124
|
+
const parsed = pages.parseDraftBlock(studioTask().description);
|
|
125
|
+
assert.equal(parsed.ok, true);
|
|
126
|
+
assert.deepEqual(parsed.draft.lines.map((l) => [l.key, l.before, l.after, l.placement]), [
|
|
127
|
+
['L0002', 'Ctrl K', 'Control K', 'shared'],
|
|
128
|
+
['L0003', 'Welcome back', 'Welcome home', 'placed'],
|
|
129
|
+
['L0004', '{…} pages tweaked', '{…} pages rewritten so far', 'placed'],
|
|
130
|
+
['L0005', 'Your desk is quiet today', 'Nothing is waiting for you', 'unplaced'],
|
|
131
|
+
]);
|
|
132
|
+
assert.match(studioTask().description, /^Page tweak round 1/, 'the round body stays; the block is added below it');
|
|
133
|
+
assert.equal(studioTask().status, 'active');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await test('the same autosave again writes nothing: unchanged, and the task is not touched', async () => {
|
|
137
|
+
const writes = db.writes;
|
|
138
|
+
const stamp = studioTask().updated_at;
|
|
139
|
+
const before = seen.length;
|
|
140
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, DRAFT);
|
|
141
|
+
assert.equal(r.status, 200);
|
|
142
|
+
assert.equal(r.body.outcome, 'unchanged');
|
|
143
|
+
assert.equal(db.writes, writes);
|
|
144
|
+
assert.equal(studioTask().updated_at, stamp);
|
|
145
|
+
assert.equal(seen.slice(before).some((q) => /^UPDATE tasks/.test(q)), false);
|
|
146
|
+
// The save reads this page's rounds only, never the whole instance's.
|
|
147
|
+
assert.equal(db.pageScopes[db.pageScopes.length - 1], 'page-tweak/builders:studio/');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await test('a whole-draft replace: a line left out of the next save leaves the draft', async () => {
|
|
151
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, { ...DRAFT, lines: DRAFT.lines.slice(0, 3) });
|
|
152
|
+
assert.equal(r.body.outcome, 'saved');
|
|
153
|
+
assert.deepEqual(pages.parseDraftBlock(studioTask().description).draft.lines.map((l) => l.key), ['L0003', 'L0004', 'L0005']);
|
|
154
|
+
const back = await call('PUT', `${STUDIO}/draft`, ALICE, DRAFT);
|
|
155
|
+
assert.equal(back.body.outcome, 'saved');
|
|
156
|
+
assert.equal((studioTask().description.match(/```page-tweak-draft/g) || []).length, 1, 'replaced in place, never a second block');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await test('another artist cannot save it: page_not_held, naming the holder', async () => {
|
|
160
|
+
const r = await call('PUT', `${STUDIO}/draft`, BOB, DRAFT);
|
|
161
|
+
assert.equal(r.status, 409);
|
|
162
|
+
assert.equal(errCode(r.body), 'page_not_held');
|
|
163
|
+
assert.deepEqual(errDetail(r.body).holder, { id: ALICE, login: 'alice', name: 'Alice' });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await test('a draft built on an older reading is refused reading_moved, and nothing is written', async () => {
|
|
167
|
+
const writes = db.writes;
|
|
168
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, { ...DRAFT, reading_hash: 'studio-h0' });
|
|
169
|
+
assert.equal(r.status, 409);
|
|
170
|
+
assert.equal(errCode(r.body), 'reading_moved');
|
|
171
|
+
assert.equal(errDetail(r.body).reading_hash, 'studio-h1');
|
|
172
|
+
assert.equal(db.writes, writes);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await test('the body is strict: a target field at the top level is refused', async () => {
|
|
176
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, { ...DRAFT, file: 'modules/hall-ui/public/studio.html' });
|
|
177
|
+
assert.equal(errCode(r.body), 'validation_failed');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await test('another artist cannot submit it', async () => {
|
|
181
|
+
const r = await call('POST', `${STUDIO}/submit`, BOB);
|
|
182
|
+
assert.equal(r.status, 409);
|
|
183
|
+
assert.equal(errCode(r.body), 'page_not_held');
|
|
184
|
+
assert.equal(pages.blockPresence(studioTask().description).batch, false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await test('submit freezes the batch, files an engineer task per unplaced line, and releases the claim', async () => {
|
|
188
|
+
const r = await call('POST', `${STUDIO}/submit`, ALICE);
|
|
189
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
190
|
+
assert.equal(r.body.outcome, 'submitted');
|
|
191
|
+
assert.deepEqual(r.body.round, { task_id: studioTask().id, state: 'submitted', status: 'ready' });
|
|
192
|
+
assert.equal(r.body.batch.line_count, 2);
|
|
193
|
+
assert.deepEqual(r.body.next, { path: '/copy-desk/next' });
|
|
194
|
+
|
|
195
|
+
// The batch: placed lines with their registry rows, from the reading.
|
|
196
|
+
const b = pages.parseBatchBlock(studioTask().description);
|
|
197
|
+
assert.equal(b.ok, true);
|
|
198
|
+
assert.deepEqual(b.batch.lines.map((l) => [l.key, l.file, l.line, l.string_id, l.before, l.after]), [
|
|
199
|
+
['L0003', 'modules/hall-ui/public/studio.html', 12, 'st0003', 'Welcome back', 'Welcome home'],
|
|
200
|
+
['L0004', 'modules/hall-ui/public/studio.js', 88, 'st0004', '{…} pages tweaked', '{…} pages rewritten so far'],
|
|
201
|
+
]);
|
|
202
|
+
assert.equal(b.batch.reading_hash, 'studio-h1');
|
|
203
|
+
|
|
204
|
+
// The unplaced split: the unplaced line, and the shared line with no row.
|
|
205
|
+
const eng = db.tasks.filter((t) => t.source === 'page-tweak-unplaced');
|
|
206
|
+
assert.deepEqual(eng.map((t) => t.source_ref).sort(), [
|
|
207
|
+
`page-tweak-unplaced/${studioTask().id}/L0002`,
|
|
208
|
+
`page-tweak-unplaced/${studioTask().id}/L0005`,
|
|
209
|
+
]);
|
|
210
|
+
for (const t of eng) {
|
|
211
|
+
assert.deepEqual([t.status, t.discipline, t.kind, t.version_id, t.goal_id, t.created_by], ['backlog', 'engineer', 'feature', 'V9', '77', ALICE]);
|
|
212
|
+
assert.match(t.description, /builders:studio/);
|
|
213
|
+
}
|
|
214
|
+
const quiet = eng.find((t) => t.source_ref.endsWith('/L0005'));
|
|
215
|
+
assert.match(quiet.description, /"Your desk is quiet today"/);
|
|
216
|
+
assert.match(quiet.description, /"Nothing is waiting for you"/);
|
|
217
|
+
assert.deepEqual([...b.batch.unplaced_tasks].sort(), eng.map((t) => t.id).sort(), 'the batch names the engineer tasks');
|
|
218
|
+
assert.deepEqual(r.body.unplaced_tasks.map((u) => u.created), [true, true]);
|
|
219
|
+
|
|
220
|
+
// The claim is released and the round is ready: the /tweak queue.
|
|
221
|
+
assert.equal(studioTask().status, 'ready');
|
|
222
|
+
const claim = db.claims.find((c) => c.task_id === studioTask().id);
|
|
223
|
+
assert.ok(claim.released_at, 'the web claim is released');
|
|
224
|
+
assert.equal(claim.outcome, 'partial');
|
|
225
|
+
// The draft stays beside the batch (ADR 0341 D7: it may be amended later).
|
|
226
|
+
assert.equal(pages.parseDraftBlock(studioTask().description).ok, true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await test('the page reads submitted, and nobody can claim it back into writing', async () => {
|
|
230
|
+
const page = await call('GET', STUDIO, BOB);
|
|
231
|
+
assert.equal(page.status, 200);
|
|
232
|
+
assert.equal(page.body.open_round.state, 'submitted');
|
|
233
|
+
const claim = await call('POST', `${STUDIO}/claim`, BOB);
|
|
234
|
+
assert.equal(errCode(claim.body), 'page_in_flight');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await test('a repeated submit is already_submitted: no second batch, no second engineer task', async () => {
|
|
238
|
+
const tasks = db.tasks.length;
|
|
239
|
+
const r = await call('POST', `${STUDIO}/submit`, ALICE);
|
|
240
|
+
assert.equal(r.status, 200);
|
|
241
|
+
assert.equal(r.body.outcome, 'already_submitted');
|
|
242
|
+
assert.equal(r.body.unplaced_tasks.length, 2);
|
|
243
|
+
assert.equal(db.tasks.length, tasks);
|
|
244
|
+
assert.equal((studioTask().description.match(/```page-tweak\n/g) || []).length, 1);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await test('after submit the draft is frozen: a save is page_in_flight', async () => {
|
|
248
|
+
const r = await call('PUT', `${STUDIO}/draft`, ALICE, DRAFT);
|
|
249
|
+
assert.equal(r.status, 409);
|
|
250
|
+
assert.equal(errCode(r.body), 'page_in_flight');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await test('a line whose reading moved is refused target_gone at submit; the round stays with its artist', async () => {
|
|
254
|
+
assert.equal((await call('POST', `${INDEX}/claim`, BOB)).status, 201);
|
|
255
|
+
const saved = await call('PUT', `${INDEX}/draft`, BOB, { reading_hash: 'index-h1', lines: [{ key: 'L0001', after: 'Build it here' }, { key: 'L0002', after: 'Start yours' }] });
|
|
256
|
+
assert.equal(saved.body.outcome, 'saved');
|
|
257
|
+
// A ship rewords the hero line: the page no longer shows "Build here".
|
|
258
|
+
writeReadings(readingsWith({ hash: 'studio-h1', lines: STUDIO_LINES }, {
|
|
259
|
+
hash: 'index-h2',
|
|
260
|
+
lines: [L('L0001', 'hero', 'Build anything here', 'placed', { file: 'x', line: 3, string_id: 'ix0009' }), INDEX_LINES[1]],
|
|
261
|
+
}));
|
|
262
|
+
const writes = db.writes;
|
|
263
|
+
const r = await call('POST', `${INDEX}/submit`, BOB);
|
|
264
|
+
assert.equal(r.status, 409);
|
|
265
|
+
assert.equal(errCode(r.body), 'lines_refused');
|
|
266
|
+
assert.deepEqual(errDetail(r.body).refused, [{ key: 'L0001', section: 'hero', before: 'Build here', code: 'target_gone' }]);
|
|
267
|
+
const t = db.tasks.find((x) => x.source_ref === 'page-tweak/landing:index/r1');
|
|
268
|
+
assert.equal(t.status, 'active');
|
|
269
|
+
assert.equal(pages.blockPresence(t.description).batch, false);
|
|
270
|
+
assert.equal(db.writes, writes);
|
|
271
|
+
assert.ok(db.claims.find((c) => c.task_id === t.id && c.released_at == null), 'the claim is still held');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await test('two submits at once: one freezes, the other is the same double-click, and one engineer task per line', async () => {
|
|
275
|
+
// Bob drops the gone line on the current reading, adds an unplaced-free
|
|
276
|
+
// rewrite, and double-submits.
|
|
277
|
+
const t = db.tasks.find((x) => x.source_ref === 'page-tweak/landing:index/r1');
|
|
278
|
+
const saved = await call('PUT', `${INDEX}/draft`, BOB, { reading_hash: 'index-h2', lines: [{ key: 'L0002', after: 'Start yours' }] });
|
|
279
|
+
assert.equal(saved.body.outcome, 'saved');
|
|
280
|
+
const [a, b] = await Promise.all([call('POST', `${INDEX}/submit`, BOB), call('POST', `${INDEX}/submit`, BOB)]);
|
|
281
|
+
assert.deepEqual([a.body.outcome, b.body.outcome].sort(), ['already_submitted', 'submitted'], JSON.stringify([a.body, b.body]));
|
|
282
|
+
assert.equal((t.description.match(/```page-tweak\n/g) || []).length, 1);
|
|
283
|
+
assert.equal(t.status, 'ready');
|
|
284
|
+
});
|
|
285
|
+
} finally {
|
|
286
|
+
server.close();
|
|
287
|
+
pageData._setRoot(null);
|
|
288
|
+
}
|
|
289
|
+
summary();
|