@bongos/core 1.21.173 → 1.21.175
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 +65 -40
- package/docs/copy-inventory.md +56 -62
- package/docs/copy-registry.json +91 -156
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +5 -0
- package/docs/page-readings.json +64 -61
- package/modules/copy-desk/routes/copy-desk.js +4 -4
- package/modules/hall-ui/public/blockers.html +2 -0
- package/modules/hall-ui/public/blockers.js +13 -0
- package/modules/hall-ui/public/board-room.css +5 -0
- package/modules/hall-ui/public/board-room.html +1 -0
- package/modules/hall-ui/public/board-room.js +2 -2
- package/modules/hall-ui/public/city-decide.js +36 -2
- package/modules/hall-ui/public/city.css +8 -2
- package/modules/hall-ui/public/city.html +5 -5
- package/modules/hall-ui/public/goal-inbox.js +69 -86
- package/modules/hall-ui/public/hall-ask.css +57 -0
- package/modules/hall-ui/public/hall-ask.js +50 -0
- package/modules/hall-ui/public/index.html +3 -0
- package/modules/hall-ui/records/hall-ask-card.md +24 -0
- package/modules/lifecycle/artist-review-goal.js +103 -0
- package/modules/lifecycle/db-tasks.js +17 -1
- package/modules/lifecycle/db-versions.js +5 -1
- package/modules/lifecycle/page-tweak-claim.js +3 -3
- package/modules/lifecycle/page-tweak-write.js +12 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/src/module-api.js +1 -1
- package/tests/artist_review_goal.mjs +119 -0
- package/tests/blockers_hall.mjs +3 -1
- package/tests/copy_desk_page_claim.mjs +6 -1
- package/tests/copy_desk_page_submit.mjs +1 -1
- package/tests/fixtures/page-ledger.mjs +13 -0
- package/tests/hall_city_rewording.mjs +11 -0
- package/tests/home_attention_cards.mjs +2 -0
- package/tests/rot_inbox.mjs +10 -7
- package/tests/task_goal_required.mjs +12 -2
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// tests/artist_review_goal.mjs
|
|
2
|
+
//
|
|
3
|
+
// Every version's ARTIST REVIEW goal (task 1004819): the find-or-create behind
|
|
4
|
+
// createTask's `artistReview` switch and createVersion. DB-free: a recording fake
|
|
5
|
+
// client stands in for Postgres, and the statement order is the contract — the
|
|
6
|
+
// version gate before anything else, the advisory lock before the read, and the
|
|
7
|
+
// two standing criteria written only when the goal is new.
|
|
8
|
+
//
|
|
9
|
+
// Run: node tests/artist_review_goal.mjs
|
|
10
|
+
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import { makeRunner } from './helpers.mjs';
|
|
14
|
+
|
|
15
|
+
process.env.NODE_ENV = 'test';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const { ensureArtistReviewGoal, artistReviewGoalTitle, ARTIST_REVIEW_CRITERIA } = require('../modules/lifecycle/artist-review-goal.js');
|
|
19
|
+
const { createTask } = require('../modules/lifecycle/db.js');
|
|
20
|
+
|
|
21
|
+
const { test, summary } = makeRunner();
|
|
22
|
+
const norm = (s) => String(s).replace(/\s+/g, ' ').trim();
|
|
23
|
+
|
|
24
|
+
// One version, at most one goal: the fake answers like the tables would.
|
|
25
|
+
function fakeClient({ status = 'building', goal = null } = {}) {
|
|
26
|
+
const queries = [];
|
|
27
|
+
const state = { goal, criteria: [], tasks: [] };
|
|
28
|
+
const client = {
|
|
29
|
+
queries, state,
|
|
30
|
+
async query(sql, params = []) {
|
|
31
|
+
const q = norm(sql);
|
|
32
|
+
queries.push({ sql: q, params });
|
|
33
|
+
if (/^(BEGIN|COMMIT|ROLLBACK)$/.test(q)) return { rows: [] };
|
|
34
|
+
if (/^SELECT status FROM versions WHERE id = \$1/.test(q)) return { rows: status ? [{ status }] : [] };
|
|
35
|
+
if (/pg_advisory_xact_lock/.test(q)) return { rows: [] };
|
|
36
|
+
if (/^SELECT id, version_id, title FROM goals WHERE version_id = \$1 AND title = \$2/.test(q)) {
|
|
37
|
+
return { rows: state.goal && state.goal.title === params[1] ? [state.goal] : [] };
|
|
38
|
+
}
|
|
39
|
+
if (/^INSERT INTO goals/.test(q)) { state.goal = { id: 91, version_id: params[0], title: params[1] }; return { rows: [state.goal] }; }
|
|
40
|
+
if (/^INSERT INTO done_when_criteria/.test(q)) { state.criteria.push({ id: params[1], goal: params[3] }); return { rows: [] }; }
|
|
41
|
+
if (/^SELECT scope_modules FROM goals WHERE id = \$1/.test(q)) return { rows: [{ scope_modules: [] }] };
|
|
42
|
+
if (/^INSERT INTO tasks/.test(q)) { const t = { id: 1, goal_id: params[22] }; state.tasks.push(t); return { rows: [t] }; }
|
|
43
|
+
return { rows: [] };
|
|
44
|
+
},
|
|
45
|
+
release() {},
|
|
46
|
+
};
|
|
47
|
+
return client;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await test('the title is the version plus " — artist review" (em dash)', () => {
|
|
51
|
+
assert.equal(artistReviewGoalTitle('BONGOS-V3'), 'BONGOS-V3 — artist review');
|
|
52
|
+
assert.deepEqual(ARTIST_REVIEW_CRITERIA.map((c) => c.criterion_id), ['artist-every-page-reviewed', 'artist-pages-simple-to-use']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await test('a new goal is made with its two criteria, after the version gate and the lock', async () => {
|
|
56
|
+
const c = fakeClient();
|
|
57
|
+
const g = await ensureArtistReviewGoal('BONGOS-V3', { client: c });
|
|
58
|
+
assert.equal(g.title, 'BONGOS-V3 — artist review');
|
|
59
|
+
const order = c.queries.map((q) => q.sql.split(' ').slice(0, 3).join(' '));
|
|
60
|
+
const at = (re) => c.queries.findIndex((q) => re.test(q.sql));
|
|
61
|
+
assert.ok(at(/FROM versions/) < at(/pg_advisory_xact_lock/), 'the version is checked first');
|
|
62
|
+
assert.ok(at(/pg_advisory_xact_lock/) < at(/FROM goals WHERE version_id/), 'the lock is taken before the read');
|
|
63
|
+
assert.equal(c.queries.find((q) => /pg_advisory_xact_lock/.test(q.sql)).params[0], 'BONGOS-V3 — artist review', 'locked on the title');
|
|
64
|
+
assert.deepEqual(c.state.criteria, [{ id: 'artist-every-page-reviewed', goal: 91 }, { id: 'artist-pages-simple-to-use', goal: 91 }]);
|
|
65
|
+
assert.ok(/ON CONFLICT \(version_id, criterion_id\) DO NOTHING/.test(c.queries.find((q) => /INSERT INTO done_when_criteria/.test(q.sql)).sql),
|
|
66
|
+
'a criterion already on the version is left alone', order);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await test('an existing goal is reused, never duplicated, and still carries the two criteria', async () => {
|
|
70
|
+
// A goal of this title made by hand before the first studio write is found,
|
|
71
|
+
// and its criteria are seeded (ON CONFLICT DO NOTHING keeps any already there).
|
|
72
|
+
const c = fakeClient({ goal: { id: 1000134, version_id: 'BONGOS-V2', title: 'BONGOS-V2 — artist review' } });
|
|
73
|
+
const g = await ensureArtistReviewGoal('BONGOS-V2', { client: c });
|
|
74
|
+
assert.equal(g.id, 1000134);
|
|
75
|
+
assert.equal(c.queries.some((q) => /^INSERT INTO goals/.test(q.sql)), false, 'no second goal');
|
|
76
|
+
assert.deepEqual(c.state.criteria, [{ id: 'artist-every-page-reviewed', goal: 1000134 }, { id: 'artist-pages-simple-to-use', goal: 1000134 }]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await test('a closed or missing version gets no goal, and nothing is locked or written', async () => {
|
|
80
|
+
for (const status of ['shipped', null]) {
|
|
81
|
+
const c = fakeClient({ status });
|
|
82
|
+
assert.equal(await ensureArtistReviewGoal('V0', { client: c }), null);
|
|
83
|
+
assert.equal(c.queries.length, 1, 'only the version read ran');
|
|
84
|
+
}
|
|
85
|
+
const planning = fakeClient({ status: 'planning' });
|
|
86
|
+
assert.ok(await ensureArtistReviewGoal('V4', { client: planning }), 'a planning version takes one, so a new version is born with it');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await test('with no caller transaction it opens its own, so the lock means something', async () => {
|
|
90
|
+
const c = fakeClient();
|
|
91
|
+
const pool = { connect: async () => c, query: (...a) => c.query(...a) };
|
|
92
|
+
await ensureArtistReviewGoal('BONGOS-V3', { pool });
|
|
93
|
+
const sqls = c.queries.map((q) => q.sql);
|
|
94
|
+
assert.equal(sqls[0], 'BEGIN');
|
|
95
|
+
assert.equal(sqls.at(-1), 'COMMIT');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await test('createTask with artistReview lands in the goal; without one the catch-all still answers', async () => {
|
|
99
|
+
const c = fakeClient();
|
|
100
|
+
await createTask({ versionId: 'BONGOS-V3', title: 'Tweak: Home (builders:home)', discipline: 'artist', artistReview: true }, { client: c });
|
|
101
|
+
assert.equal(c.state.tasks[0].goal_id, 91, 'the studio task names the artist review goal');
|
|
102
|
+
await assert.rejects(
|
|
103
|
+
() => createTask({ versionId: 'BONGOS-V3', title: 'plain', discipline: 'engineer' }, { client: fakeClient() }),
|
|
104
|
+
/goalId is required/, 'an ordinary goal-less create still throws');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await test('a create with no caller transaction (a copy proposal) puts goal and task in ONE transaction', async () => {
|
|
108
|
+
const c = fakeClient();
|
|
109
|
+
const pool = { connect: async () => c, query: () => { throw new Error('nothing may run outside the transaction'); } };
|
|
110
|
+
await createTask({ versionId: 'BONGOS-V3', title: 'Reword the Home heading', discipline: 'artist', artistReview: true }, { pool });
|
|
111
|
+
const sqls = c.queries.map((q) => q.sql);
|
|
112
|
+
const begins = sqls.filter((q) => q === 'BEGIN').length;
|
|
113
|
+
assert.equal(begins, 1, 'exactly one transaction');
|
|
114
|
+
assert.ok(sqls.indexOf('BEGIN') < sqls.findIndex((q) => /^INSERT INTO goals/.test(q)), 'the goal is made inside it');
|
|
115
|
+
assert.ok(sqls.findIndex((q) => /^INSERT INTO tasks/.test(q)) < sqls.lastIndexOf('COMMIT'), 'and the task too, before the one COMMIT');
|
|
116
|
+
assert.equal(c.state.tasks[0].goal_id, 91);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
summary();
|
package/tests/blockers_hall.mjs
CHANGED
|
@@ -54,7 +54,9 @@ test('goal-inbox.js renders blocker rows through the SHARED lib, not a re-implem
|
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
test('the blocker row carries .ginbox__item, so the card\'s count-pill and visibility include it', () => {
|
|
57
|
-
|
|
57
|
+
// task 1004820: the row is the city's ask card, drawn by OTBAsk with the item class
|
|
58
|
+
assert.match(GOAL_INBOX_SRC, /function blockerRowHtml[\s\S]*?OTBAsk\.html\(\{[\s\S]*?cls: 'ginbox__item'/);
|
|
59
|
+
assert.match(GOAL_INBOX_SRC, /function blockerRowHtml[\s\S]*?label: 'Resolve it'/, 'the home blocker row is actionable');
|
|
58
60
|
});
|
|
59
61
|
|
|
60
62
|
test('the mount() boot sequence actually calls loadBlockers(), in its place in the order', () => {
|
|
@@ -86,8 +86,13 @@ try {
|
|
|
86
86
|
assert.equal(db.tasks.length, 1);
|
|
87
87
|
assert.deepEqual(
|
|
88
88
|
[t.source, t.source_ref, t.title, t.kind, t.discipline, t.credits_reward, t.version_id, t.goal_id, t.status],
|
|
89
|
-
['page-tweak', 'page-tweak/builders:studio/r1', 'Tweak: Studio (builders:studio)', 'feature', 'artist', 30, 'V9', '
|
|
89
|
+
['page-tweak', 'page-tweak/builders:studio/r1', 'Tweak: Studio (builders:studio)', 'feature', 'artist', 30, 'V9', '88', 'active']
|
|
90
90
|
);
|
|
91
|
+
// task 1004819: the round lands in the version's artist review goal, made on
|
|
92
|
+
// first use with its two standing criteria — never the catch-all.
|
|
93
|
+
assert.equal(db.artistGoal.title, 'V9 — artist review');
|
|
94
|
+
assert.deepEqual(db.criteria.map((x) => [x.criterion_id, x.goal_id]),
|
|
95
|
+
[['artist-every-page-reviewed', '88'], ['artist-pages-simple-to-use', '88']]);
|
|
91
96
|
assert.deepEqual(t.touches, F.STUDIO_FILES, 'touches are the inventory page files');
|
|
92
97
|
const c = db.claims[0];
|
|
93
98
|
assert.deepEqual([c.builder_id, c.worktree_name, c.creator_session_id], [ALICE, null, null], 'a web claim: no worktree, no session');
|
|
@@ -208,7 +208,7 @@ try {
|
|
|
208
208
|
`page-tweak-unplaced/${studioTask().id}/L0005`,
|
|
209
209
|
]);
|
|
210
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', '
|
|
211
|
+
assert.deepEqual([t.status, t.discipline, t.kind, t.version_id, t.goal_id, t.created_by], ['backlog', 'engineer', 'feature', 'V9', '88', ALICE], 'an unplaced line lands in the artist review goal (task 1004819)');
|
|
212
212
|
assert.match(t.description, /builders:studio/);
|
|
213
213
|
}
|
|
214
214
|
const quiet = eng.find((t) => t.source_ref.endsWith('/L0005'));
|
|
@@ -36,6 +36,7 @@ export function installPageLedger(api, { builders }) {
|
|
|
36
36
|
// first commits or rolls back.
|
|
37
37
|
const locks = new Map();
|
|
38
38
|
async function lock(key, owner) {
|
|
39
|
+
if (locks.has(key) && locks.get(key).owner === owner) return; // re-entrant, as Postgres is for one session
|
|
39
40
|
while (locks.has(key)) await locks.get(key).done;
|
|
40
41
|
let release;
|
|
41
42
|
const done = new Promise((r) => { release = r; });
|
|
@@ -109,6 +110,18 @@ export function installPageLedger(api, { builders }) {
|
|
|
109
110
|
if (/FROM goals g JOIN versions v ON v\.id = g\.version_id WHERE g\.version_id = \$1 AND g\.title = \$2/.test(q)) {
|
|
110
111
|
return { rows: [{ id: '77', version_status: 'building' }] };
|
|
111
112
|
}
|
|
113
|
+
// task 1004819: studio work lands in the version's artist review goal, found
|
|
114
|
+
// (or made, with its two criteria) on the same transaction client.
|
|
115
|
+
if (/^SELECT status FROM versions WHERE id = \$1/.test(q)) return { rows: [{ status: 'building' }] };
|
|
116
|
+
if (/^SELECT id, version_id, title FROM goals WHERE version_id = \$1 AND title = \$2/.test(q)) {
|
|
117
|
+
assert.ok(owner, 'the artist review goal must be found on the transaction client');
|
|
118
|
+
return { rows: db.artistGoal ? [{ ...db.artistGoal }] : [] };
|
|
119
|
+
}
|
|
120
|
+
if (/^INSERT INTO goals \(version_id, title, description, scope_modules, status, created_by, sort_order\)/.test(q)) {
|
|
121
|
+
db.artistGoal = { id: '88', version_id: p[0], title: p[1] };
|
|
122
|
+
return { rows: [{ ...db.artistGoal }] };
|
|
123
|
+
}
|
|
124
|
+
if (/^INSERT INTO done_when_criteria/.test(q)) { (db.criteria = db.criteria || []).push({ version_id: p[0], criterion_id: p[1], goal_id: p[3] }); return { rows: [] }; }
|
|
112
125
|
if (/^SELECT scope_modules FROM goals WHERE id = \$1/.test(q)) return { rows: [{ scope_modules: [] }] };
|
|
113
126
|
if (/^INSERT INTO tasks \(version_id, title, description, status, touches,/.test(q)) {
|
|
114
127
|
assert.ok(owner, 'the round must be created inside the claim transaction');
|
|
@@ -49,3 +49,14 @@ test('the "?" asks before it sends: the send lives behind a confirm button', ()
|
|
|
49
49
|
assert.match(fn, /Send this to Claude to rewrite\?/);
|
|
50
50
|
assert.match(fn, /Nothing is decided or changed/);
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
test('a card never cuts what it says: the whole text is one tap away, and only when there is more', () => {
|
|
54
|
+
const G = boot();
|
|
55
|
+
const m = { id: 'override_requests:1', title: 'Override: a long title', detail: 'The whole story, word for word.' };
|
|
56
|
+
assert.equal(G.fullText(m, { question: 'Release it?' }), 'Override: a long title\n\nThe whole story, word for word.');
|
|
57
|
+
assert.equal(G.fullText(m, { question: 'Override: a long title' }), 'The whole story, word for word.', 'the question is not repeated');
|
|
58
|
+
assert.equal(G.fullText({ id: 'x:1', title: 'Same' }, { question: 'Same' }), '', 'nothing more to show, no opener');
|
|
59
|
+
assert.equal(G.fullText(null, { question: 'Q?' }), '');
|
|
60
|
+
const src = fs.readFileSync(path.join(PUB, 'city-decide.js'), 'utf8');
|
|
61
|
+
assert.match(src, /str\(o\.input && o\.input\.label\) \|\| str\(o\.label\)/, 'an answer uses its full words, not the desk chip cut to fit');
|
|
62
|
+
});
|
|
@@ -16,6 +16,7 @@ const require = createRequire(import.meta.url);
|
|
|
16
16
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
17
|
const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
|
|
18
18
|
const GOAL_INBOX = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'goal-inbox.js'), 'utf8');
|
|
19
|
+
const HALL_ASK = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'hall-ask.js'), 'utf8');
|
|
19
20
|
|
|
20
21
|
let browser = null;
|
|
21
22
|
try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
|
|
@@ -48,6 +49,7 @@ async function bootInbox() {
|
|
|
48
49
|
const page = await ctx.newPage();
|
|
49
50
|
await page.setContent(PAGE);
|
|
50
51
|
await page.addScriptTag({ content: STUBS });
|
|
52
|
+
await page.addScriptTag({ content: HALL_ASK }); // task 1004820: the rows are OTBAsk cards
|
|
51
53
|
await page.addScriptTag({ content: GOAL_INBOX });
|
|
52
54
|
await page.evaluate(() => window.__widget.mount(document.getElementById('attention-scroll')));
|
|
53
55
|
await page.waitForFunction(() => document.querySelectorAll('.attn-card').length === 2);
|
package/tests/rot_inbox.mjs
CHANGED
|
@@ -593,14 +593,14 @@ test('the slice lands in the EXISTING attention card — never a new Home card',
|
|
|
593
593
|
});
|
|
594
594
|
|
|
595
595
|
test('rot rows carry .ginbox__item, so the card\'s count-pill and visibility include them', () => {
|
|
596
|
-
assert.match(GOAL_INBOX_SRC, /function rotRowHtml[\s\S]*?
|
|
596
|
+
assert.match(GOAL_INBOX_SRC, /function rotRowHtml[\s\S]*?cls: 'ginbox__item'/,
|
|
597
597
|
'a rot row that does not carry the shared item class would be invisible to OTBAttention.refresh()');
|
|
598
598
|
});
|
|
599
599
|
|
|
600
600
|
test('the client renders SERVER-declared verbs rather than a hardcoded button list', () => {
|
|
601
601
|
assert.match(GOAL_INBOX_SRC, /\(Array\.isArray\(r\.verbs\) \? r\.verbs : \[\]\)\.map/,
|
|
602
602
|
'the buttons must come from the row\'s verbs array — a hardcoded list is a second copy of the tier→verb mapping');
|
|
603
|
-
assert.match(GOAL_INBOX_SRC, /data-rot-path
|
|
603
|
+
assert.match(GOAL_INBOX_SRC, /'data-rot-path':/, 'each button carries the server-declared path');
|
|
604
604
|
});
|
|
605
605
|
|
|
606
606
|
test('row builders emit the CSS classes style.css actually defines, not ginbox__main/ginbox__acts (task 1003284)', () => {
|
|
@@ -610,10 +610,10 @@ test('row builders emit the CSS classes style.css actually defines, not ginbox__
|
|
|
610
610
|
'.ginbox__main is undefined in style.css — with it the row title and sub-line run together inline instead of stacking');
|
|
611
611
|
assert.doesNotMatch(GOAL_INBOX_SRC, /ginbox__acts\b/,
|
|
612
612
|
'.ginbox__acts is undefined in style.css — with it the action button loses its flex sizing');
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
613
|
+
// task 1004820: the nod and rot rows are the city's ask card, whose classes hall-ask.css defines
|
|
614
|
+
for (const fn of ['nodRowHtml', 'rotRowHtml']) {
|
|
615
|
+
assert.match(GOAL_INBOX_SRC, new RegExp(`function ${fn}[\\s\\S]*?OTBAsk\\.html\\(`), `${fn} draws the ask card`);
|
|
616
|
+
}
|
|
617
617
|
});
|
|
618
618
|
|
|
619
619
|
test('the nod, removal and rot lists use the defined .ginbox list class, not ginbox__list (task 1003982)', () => {
|
|
@@ -635,6 +635,9 @@ test('rotRowHtml escapes the row href like every sibling field (task 1003631)',
|
|
|
635
635
|
const start = GOAL_INBOX_SRC.indexOf('function rotRowHtml');
|
|
636
636
|
const body = GOAL_INBOX_SRC.slice(start, GOAL_INBOX_SRC.indexOf('\n }\n', start));
|
|
637
637
|
assert.ok(start > 0, 'rotRowHtml exists');
|
|
638
|
-
|
|
638
|
+
// task 1004820: the href is handed to OTBAsk, which escapes every attribute
|
|
639
|
+
assert.match(body, /qHref: href/, 'href is handed to OTBAsk');
|
|
640
|
+
const ask = require('../modules/hall-ui/public/hall-ask.js');
|
|
641
|
+
assert.match(ask.html({ q: 'x', qHref: '/t?a="<b>' }), /href="\/t\?a="<b>"/, 'OTBAsk escapes the href');
|
|
639
642
|
assert.doesNotMatch(body, /href="\$\{href\}"/, 'no raw href interpolation');
|
|
640
643
|
});
|
|
@@ -113,12 +113,11 @@ test('POST /tasks does NOT pass the escape hatch — this door always has a goal
|
|
|
113
113
|
|
|
114
114
|
const OPT_INS = [
|
|
115
115
|
['modules/onboarding/newcomer-restock.js', 'the beginner queue, stamped from templates'],
|
|
116
|
-
['modules/copy-desk/routes/copy-desk.js', 'copy-change proposals'],
|
|
117
116
|
['modules/lifecycle/kickoff-seed.js', "the founder's first board, on an instance with no goals at all"],
|
|
118
117
|
['modules/lifecycle/cascade.js', 'a follow-up off a parent task that is itself goal-less'],
|
|
119
118
|
];
|
|
120
119
|
|
|
121
|
-
test('exactly
|
|
120
|
+
test('exactly three vectors opt in, and each says why', () => {
|
|
122
121
|
for (const [file, why] of OPT_INS) {
|
|
123
122
|
const body = src(file);
|
|
124
123
|
assert.match(body, /allowCatchAll/, `${file} should opt in (${why})`);
|
|
@@ -127,6 +126,17 @@ test('exactly four vectors opt in, and each says why', () => {
|
|
|
127
126
|
}
|
|
128
127
|
});
|
|
129
128
|
|
|
129
|
+
// task 1004819: studio work names its home instead of the catch-all. Each of the
|
|
130
|
+
// three studio create vectors passes artistReview, which lands it in the
|
|
131
|
+
// version's artist review goal; none of them opts into the catch-all any more.
|
|
132
|
+
test('the three studio vectors land in the artist review goal, not the catch-all', () => {
|
|
133
|
+
for (const file of ['modules/copy-desk/routes/copy-desk.js', 'modules/lifecycle/page-tweak-claim.js', 'modules/lifecycle/page-tweak-write.js']) {
|
|
134
|
+
const body = src(file);
|
|
135
|
+
assert.match(body, /artistReview: true/, `${file} should name the artist review goal`);
|
|
136
|
+
assert.equal(/allowCatchAll/.test(body), false, `${file} must not opt into the catch-all`);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
130
140
|
test('cascade opts in ONLY when inheritance actually failed', () => {
|
|
131
141
|
// The others opt in unconditionally because they have no goal to name at all.
|
|
132
142
|
// A cascade normally inherits its parent's goal and must keep doing so — the
|