@bongos/core 1.23.13 → 1.23.15
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 +90 -55
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +22 -1
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +1000 -946
- package/docs/copy-registry.json +2009 -1465
- package/docs/module-api-changelog.md +4 -2
- package/docs/page-inventory.json +10 -4
- package/docs/page-readings.json +593 -573
- package/modules/copy-desk/page-status.js +2 -2
- package/modules/copy-desk/tests/copy_page_status.mjs +1 -1
- package/modules/hall-ui/public/approval-queue-lib.js +1 -1
- package/modules/hall-ui/public/approval-queue.css +26 -12
- package/modules/hall-ui/public/approval-queue.js +40 -16
- package/modules/hall-ui/public/studio-lib.js +5 -3
- package/modules/hall-ui/public/studio.html +8 -8
- package/modules/hall-ui/public/studio.js +25 -5
- package/modules/hall-ui/public/studio.states.json +54 -4
- package/modules/hall-ui/public/welcome-boot.js +62 -12
- package/modules/platform-identity/builder-of-the-week.js +180 -0
- package/modules/platform-identity/migrations/platform_identity_032_weekly_features.sql +53 -0
- package/modules/platform-identity/routes/community-leaderboard.js +25 -1
- package/modules/public-landing/public/account.html +2 -1
- package/modules/public-landing/public/community-week-none.states.json +16 -0
- package/modules/public-landing/public/community.html +142 -13
- package/modules/public-landing/public/community.states.json +6 -4
- package/modules/public-landing/public/connections.html +2 -1
- package/modules/public-landing/public/contact.html +2 -1
- package/modules/public-landing/public/guild.html +2 -1
- package/modules/public-landing/public/index.html +2 -1
- package/modules/public-landing/public/invitations.html +2 -1
- package/modules/public-landing/public/privacy.html +2 -1
- package/modules/public-landing/public/projects.html +2 -1
- package/modules/public-landing/public/terms.html +2 -1
- package/modules/ui-design/kit/fixtures/community-week-none.json +6 -0
- package/modules/ui-design/kit/fixtures/community-week.json +11 -0
- package/modules/ui-design/kit/serve.js +8 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +15 -1
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +5 -5
- package/tests/builder_of_the_week.mjs +266 -0
- package/tests/builder_of_the_week_db.mjs +166 -0
- package/tests/hall_approval_queue.mjs +33 -9
- package/tests/hall_studio_home.mjs +74 -7
- package/tests/hub_account_page.mjs +2 -2
- package/tests/hub_community_page.mjs +136 -25
- package/tests/landing_legal_pages.mjs +2 -2
- package/tests/privacy_matrix.mjs +53 -8
- package/tests/welcome_boot_browser.mjs +45 -3
package/src/platform-server.js
CHANGED
|
@@ -338,11 +338,11 @@ function buildPlatformApp() {
|
|
|
338
338
|
res.type('html').send(html);
|
|
339
339
|
});
|
|
340
340
|
// THE COMMUNITY PAGES (task 1003363, ADR 0217 D1, ADR 0337 D1/D3): who is building
|
|
341
|
-
// across every project — the leaderboard, the builder
|
|
342
|
-
// viewer) and the recruiter list GET /scouting's own
|
|
343
|
-
//
|
|
344
|
-
// every read it makes is a platform-identity route.
|
|
345
|
-
app.get(['/community', '/community/find', '/community/recruiters'], (req, res, next) => {
|
|
341
|
+
// across every project — the leaderboard, the builder of the week (task 1002981), the
|
|
342
|
+
// builder search (the same for every viewer) and the recruiter list GET /scouting's own
|
|
343
|
+
// door admits. One file serves all four; its script shows the one the path names.
|
|
344
|
+
// Hub-only for the /account reason: every read it makes is a platform-identity route.
|
|
345
|
+
app.get(['/community', '/community/week', '/community/find', '/community/recruiters'], (req, res, next) => {
|
|
346
346
|
if (!isApexHost(req) || !isModuleEnabled('platform-identity')) return next();
|
|
347
347
|
const html = landingPage('community.html');
|
|
348
348
|
if (!html) return next();
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// tests/builder_of_the_week.mjs — Builder of the Week (community spec D5; task 1002981).
|
|
2
|
+
//
|
|
3
|
+
// DB-free. A fake pool answers the module's four statements: the existence probe on
|
|
4
|
+
// platform_identity_weekly_features, the winner computation, the INSERT that records
|
|
5
|
+
// the week, and the read of the crown and the weeks before it. Which snapshot becomes
|
|
6
|
+
// the week's start and end, and that the predicate really filters, need a real
|
|
7
|
+
// Postgres: tests/builder_of_the_week_db.mjs.
|
|
8
|
+
//
|
|
9
|
+
// What this pins:
|
|
10
|
+
// 1. the week: the last FULL Monday-to-Monday week (UTC) before now, at every
|
|
11
|
+
// boundary of the week.
|
|
12
|
+
// 2. the statements: both account reads carry the leaderboard's NUMBERS predicate
|
|
13
|
+
// (rollupVisibleSql) and not the recruiting one; the winner needs a shipped work
|
|
14
|
+
// and ties go to works, then karma, then handle; the write never overwrites.
|
|
15
|
+
// 3. crown at render: a recorded week is never recomputed; an unrecorded one is
|
|
16
|
+
// computed once and written once, however many reads arrive together; a week
|
|
17
|
+
// nobody won is written with no winner; a failed crowning is retried next read.
|
|
18
|
+
// 4. the read: the crown only for this week's row, the past weeks newest first, at
|
|
19
|
+
// most ARCHIVE_WEEKS of them, and no github_id anywhere.
|
|
20
|
+
// 5. the route: no-store, the same body with or without credentials, the 500
|
|
21
|
+
// envelope, and its own rate-limit scope.
|
|
22
|
+
//
|
|
23
|
+
// Run: node tests/builder_of_the_week.mjs
|
|
24
|
+
|
|
25
|
+
import { strict as assert } from 'node:assert';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import http from 'node:http';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const express = require('express');
|
|
31
|
+
|
|
32
|
+
let passed = 0;
|
|
33
|
+
let failed = 0;
|
|
34
|
+
async function t(name, fn) {
|
|
35
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
36
|
+
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { rollupVisibleSql, scoutingListableSql } = require('../modules/platform-identity/account-visibility.js');
|
|
40
|
+
|
|
41
|
+
// ---- fake engine ------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const PROBE = /^SELECT 1 FROM platform_identity_weekly_features WHERE week_start = \$1::date$/;
|
|
44
|
+
const isWinner = (sql) => /platform_identity_activity_snapshots/.test(sql);
|
|
45
|
+
const isInsert = (sql) => /^INSERT INTO platform_identity_weekly_features/.test(sql);
|
|
46
|
+
const isRead = (sql) => /FROM platform_identity_weekly_features f\s+JOIN platform_identity_accounts pa/.test(sql);
|
|
47
|
+
|
|
48
|
+
const engine = {
|
|
49
|
+
calls: [],
|
|
50
|
+
recorded: new Set(), // weeks the probe finds
|
|
51
|
+
winner: null, // what the winner statement returns
|
|
52
|
+
shown: [], // what the read returns (already filtered, newest first)
|
|
53
|
+
throwOn: null, // a predicate over the SQL that makes a call fail once
|
|
54
|
+
slowWinner: false,
|
|
55
|
+
query: async (text, params = []) => {
|
|
56
|
+
const sql = String(text).replace(/\s+/g, ' ').trim();
|
|
57
|
+
engine.calls.push({ sql, params });
|
|
58
|
+
if (engine.throwOn && engine.throwOn(sql)) { engine.throwOn = null; throw new Error('boom'); }
|
|
59
|
+
if (PROBE.test(sql)) return { rows: engine.recorded.has(params[0]) ? [{ '?column?': 1 }] : [] };
|
|
60
|
+
if (isWinner(sql)) {
|
|
61
|
+
if (engine.slowWinner) await new Promise((r) => setTimeout(r, 10));
|
|
62
|
+
return { rows: engine.winner ? [engine.winner] : [] };
|
|
63
|
+
}
|
|
64
|
+
if (isInsert(sql)) { engine.recorded.add(params[0]); return { rows: [], rowCount: 1 }; }
|
|
65
|
+
if (isRead(sql)) return { rows: engine.shown.slice(0, params[1]) };
|
|
66
|
+
throw new Error(`fake pool: unexpected statement ${sql.slice(0, 80)}`);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
function reset() {
|
|
70
|
+
engine.calls = []; engine.recorded = new Set(); engine.winner = null; engine.shown = [];
|
|
71
|
+
engine.throwOn = null; engine.slowWinner = false;
|
|
72
|
+
botw.clearCrownMemo();
|
|
73
|
+
}
|
|
74
|
+
const callsOf = (pred) => engine.calls.filter((c) => pred(c.sql));
|
|
75
|
+
|
|
76
|
+
// Pin the module doorway (ADR 0083) BEFORE the modules load.
|
|
77
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
78
|
+
require.cache[moduleApiPath] = {
|
|
79
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
80
|
+
exports: {
|
|
81
|
+
pool: engine,
|
|
82
|
+
corsPublicGet: () => (_req, res, next) => { res.set('Access-Control-Allow-Origin', '*'); next(); },
|
|
83
|
+
logger: () => ({ error: () => {}, info: () => {}, warn: () => {} }),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const botw = require('../modules/platform-identity/builder-of-the-week.js');
|
|
88
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
89
|
+
const buildRoutes = require('../modules/platform-identity/routes/community-leaderboard.js');
|
|
90
|
+
|
|
91
|
+
const NOW = new Date('2026-10-28T09:30:00.000Z'); // a Wednesday
|
|
92
|
+
const shownRow = (week_start, handle, credits, ships) => ({
|
|
93
|
+
week_start, handle, github_login: handle, display_name: handle.toUpperCase(), avatar_url: null,
|
|
94
|
+
credits_delta: String(credits), ships_delta: String(ships),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---- 1. the week ------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
await t('the crowned week is the last full Monday-to-Monday week (UTC) before now, at every edge', () => {
|
|
100
|
+
const week = (iso) => { const w = botw.crownedWeek(new Date(iso)); return [w.start.toISOString(), w.end.toISOString()]; };
|
|
101
|
+
const last = ['2026-10-19T00:00:00.000Z', '2026-10-26T00:00:00.000Z'];
|
|
102
|
+
assert.deepEqual(week('2026-10-28T09:30:00.000Z'), last, 'a Wednesday: the week before this one');
|
|
103
|
+
assert.deepEqual(week('2026-10-26T00:00:00.000Z'), last, 'the very first instant of Monday: the week that just ended');
|
|
104
|
+
assert.deepEqual(week('2026-11-01T23:59:59.999Z'), last, 'the last instant of Sunday: still the same week');
|
|
105
|
+
assert.deepEqual(week('2026-10-25T23:59:59.999Z'), ['2026-10-12T00:00:00.000Z', '2026-10-19T00:00:00.000Z'], 'Sunday night: the week ending tonight is not over yet');
|
|
106
|
+
assert.deepEqual(week('2027-01-01T12:00:00.000Z'), ['2026-12-21T00:00:00.000Z', '2026-12-28T00:00:00.000Z'], 'across a year');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ---- 2. the statements --------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
await t('the winner: eligible builders only, a shipped work required, ties by works then karma then handle', async () => {
|
|
112
|
+
reset();
|
|
113
|
+
await botw.readBuilderOfTheWeek({ now: NOW, pool: engine });
|
|
114
|
+
const [w] = callsOf(isWinner);
|
|
115
|
+
assert.ok(w.sql.includes(rollupVisibleSql('pa').replace(/\s+/g, ' ').trim()), 'the leaderboard’s numbers predicate (hide, private and provisional are out)');
|
|
116
|
+
assert.ok(!w.sql.includes(scoutingListableSql('pa').replace(/\s+/g, ' ').trim()), 'the recruiting opt-out does not apply (spec D3)');
|
|
117
|
+
assert.match(w.sql, /HAVING sum\(tasks_shipped\) >= 1/, 'at least one work shipped in the week');
|
|
118
|
+
assert.match(w.sql, /ORDER BY sum\(credits\) DESC, sum\(tasks_shipped\) DESC, sum\(karma\) DESC, handle COLLATE "C", github_login COLLATE "C" LIMIT 1/);
|
|
119
|
+
assert.match(w.sql, /s\.reported_at <= \$2::timestamptz/, 'the week’s end: the last snapshot at-or-before it');
|
|
120
|
+
assert.match(w.sql, /s\.reported_at <= \$1::timestamptz/, 'the week’s start: the last snapshot at-or-before it');
|
|
121
|
+
assert.match(w.sql, /WITH act AS \( SELECT a\.client_id, a\.github_id, a\.first_seen_at, pa\.handle, pa\.github_login FROM/, 'never the live totals: a past week is read from history');
|
|
122
|
+
assert.match(w.sql, /\) b ON true WHERE b\.credits IS NOT NULL OR act\.first_seen_at >= \$1::timestamptz \)/, 'no history before the week: counted only if first seen in it (no lifetime in one week)');
|
|
123
|
+
assert.deepEqual(w.params.map((d) => d.toISOString()), ['2026-10-19T00:00:00.000Z', '2026-10-26T00:00:00.000Z']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await t('the record: written once, never overwritten', async () => {
|
|
127
|
+
reset();
|
|
128
|
+
engine.winner = { github_id: '42', credits: '900', tasks_shipped: '3' };
|
|
129
|
+
await botw.readBuilderOfTheWeek({ now: NOW, pool: engine });
|
|
130
|
+
const [ins] = callsOf(isInsert);
|
|
131
|
+
assert.match(ins.sql, /ON CONFLICT \(week_start\) DO NOTHING$/, 'the first decision stands');
|
|
132
|
+
assert.doesNotMatch(ins.sql, /DO UPDATE/);
|
|
133
|
+
assert.deepEqual(ins.params, ['2026-10-19', '42', '900', '3']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await t('the read: the same predicate, at read time, and no github_id in the projection', async () => {
|
|
137
|
+
reset();
|
|
138
|
+
engine.recorded.add('2026-10-19');
|
|
139
|
+
await botw.readBuilderOfTheWeek({ now: NOW, pool: engine });
|
|
140
|
+
const [r] = callsOf(isRead);
|
|
141
|
+
assert.ok(r.sql.includes(rollupVisibleSql('pa').replace(/\s+/g, ' ').trim()), 'a winner who hid since is not drawn (spec D8)');
|
|
142
|
+
const cols = r.sql.match(/^SELECT (.+?) FROM /)[1];
|
|
143
|
+
assert.doesNotMatch(cols, /github_id/, 'github_id never crosses');
|
|
144
|
+
assert.deepEqual(r.params, ['2026-10-19', botw.ARCHIVE_WEEKS + 1]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---- 3. crown at render ---------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
await t('a recorded week is never recomputed', async () => {
|
|
150
|
+
reset();
|
|
151
|
+
engine.recorded.add('2026-10-19');
|
|
152
|
+
await botw.readBuilderOfTheWeek({ now: NOW, pool: engine });
|
|
153
|
+
assert.equal(callsOf(isWinner).length, 0);
|
|
154
|
+
assert.equal(callsOf(isInsert).length, 0);
|
|
155
|
+
await botw.readBuilderOfTheWeek({ now: NOW, pool: engine });
|
|
156
|
+
assert.equal(callsOf((s) => PROBE.test(s)).length, 1, 'and once known, not even asked about again in this process');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await t('an unrecorded week is computed and written once, however many reads arrive together', async () => {
|
|
160
|
+
reset();
|
|
161
|
+
engine.slowWinner = true;
|
|
162
|
+
engine.winner = { github_id: '7', credits: '50', tasks_shipped: '1' };
|
|
163
|
+
await Promise.all([1, 2, 3, 4].map(() => botw.readBuilderOfTheWeek({ now: NOW, pool: engine })));
|
|
164
|
+
assert.equal(callsOf(isWinner).length, 1);
|
|
165
|
+
assert.equal(callsOf(isInsert).length, 1);
|
|
166
|
+
assert.equal(callsOf(isRead).length, 4, 'every read still reads the crown');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await t('a week nobody won is written with no winner, so it stays empty', async () => {
|
|
170
|
+
reset();
|
|
171
|
+
const out = await botw.readBuilderOfTheWeek({ now: NOW, pool: engine });
|
|
172
|
+
const [ins] = callsOf(isInsert);
|
|
173
|
+
assert.deepEqual(ins.params, ['2026-10-19', null, 0, 0]);
|
|
174
|
+
assert.equal(out.winner, null);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await t('a failed crowning is not remembered: the next read tries again', async () => {
|
|
178
|
+
reset();
|
|
179
|
+
engine.throwOn = isWinner;
|
|
180
|
+
await assert.rejects(() => botw.readBuilderOfTheWeek({ now: NOW, pool: engine }), /boom/);
|
|
181
|
+
assert.equal(callsOf(isInsert).length, 0, 'nothing written on a failure');
|
|
182
|
+
await botw.readBuilderOfTheWeek({ now: NOW, pool: engine });
|
|
183
|
+
assert.equal(callsOf(isInsert).length, 1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await t('a new week is crowned on its own first read', async () => {
|
|
187
|
+
reset();
|
|
188
|
+
engine.recorded.add('2026-10-19');
|
|
189
|
+
await botw.readBuilderOfTheWeek({ now: NOW, pool: engine });
|
|
190
|
+
await botw.readBuilderOfTheWeek({ now: new Date('2026-11-02T00:00:01.000Z'), pool: engine });
|
|
191
|
+
assert.deepEqual(callsOf(isInsert).map((c) => c.params[0]), ['2026-10-26']);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ---- 4. the read ----------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
await t('the crown is this week’s row only; the past weeks are newest first, capped', async () => {
|
|
197
|
+
reset();
|
|
198
|
+
engine.recorded.add('2026-10-19');
|
|
199
|
+
engine.shown = [shownRow('2026-10-19', 'ana', 1240, 4), shownRow('2026-10-12', 'bo', 300, 1)];
|
|
200
|
+
const out = await botw.readBuilderOfTheWeek({ now: NOW, pool: engine });
|
|
201
|
+
assert.deepEqual(out.week, { start: '2026-10-19', end: '2026-10-26' });
|
|
202
|
+
assert.deepEqual(out.winner, { week_start: '2026-10-19', handle: 'ana', github_login: 'ana', display_name: 'ANA', avatar_url: null, credits: 1240, tasks_shipped: 4 });
|
|
203
|
+
assert.deepEqual(out.past.map((p) => [p.week_start, p.handle, p.credits]), [['2026-10-12', 'bo', 300]]);
|
|
204
|
+
// this week's winner hid since (or nobody won): no crown, and last week does not move up into it
|
|
205
|
+
engine.shown = [shownRow('2026-10-12', 'bo', 300, 1)];
|
|
206
|
+
const hidden = await botw.readBuilderOfTheWeek({ now: NOW, pool: engine });
|
|
207
|
+
assert.equal(hidden.winner, null);
|
|
208
|
+
assert.deepEqual(hidden.past.map((p) => p.week_start), ['2026-10-12']);
|
|
209
|
+
engine.shown = Array.from({ length: 20 }, (_, i) => shownRow(`2026-0${(i % 9) + 1}-01`, `b${i}`, 10, 1));
|
|
210
|
+
assert.equal((await botw.readBuilderOfTheWeek({ now: NOW, pool: engine })).past.length, botw.ARCHIVE_WEEKS);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ---- 5. the route -----------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
function serve() {
|
|
216
|
+
const app = express();
|
|
217
|
+
app.use(attachFail);
|
|
218
|
+
app.use('/api/bongos', buildRoutes());
|
|
219
|
+
const server = app.listen(0, '127.0.0.1');
|
|
220
|
+
return new Promise((resolve) => server.once('listening', () => resolve(server)));
|
|
221
|
+
}
|
|
222
|
+
function get(server, path_, headers = {}) {
|
|
223
|
+
return new Promise((resolve, reject) => {
|
|
224
|
+
http.get({ host: '127.0.0.1', port: server.address().port, path: path_, headers }, (res) => {
|
|
225
|
+
let body = '';
|
|
226
|
+
res.on('data', (c) => (body += c));
|
|
227
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body, json: body ? JSON.parse(body) : null }));
|
|
228
|
+
}).on('error', reject);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const server = await serve();
|
|
233
|
+
const URL_ = '/api/bongos/community/builder-of-the-week';
|
|
234
|
+
|
|
235
|
+
await t('GET /community/builder-of-the-week: the week, the crown and the past weeks; no-store; the same for every viewer', async () => {
|
|
236
|
+
reset();
|
|
237
|
+
engine.shown = [shownRow(botw.crownedWeek().start.toISOString().slice(0, 10), 'ana', 1240, 4)];
|
|
238
|
+
const anon = await get(server, URL_);
|
|
239
|
+
assert.equal(anon.status, 200);
|
|
240
|
+
assert.equal(anon.headers['cache-control'], 'no-store');
|
|
241
|
+
assert.equal(anon.headers['access-control-allow-origin'], '*');
|
|
242
|
+
assert.deepEqual(Object.keys(anon.json).sort(), ['past', 'week', 'winner']);
|
|
243
|
+
assert.equal(anon.json.winner.handle, 'ana');
|
|
244
|
+
assert.doesNotMatch(anon.body, /github_id/);
|
|
245
|
+
const signedIn = await get(server, URL_, { Cookie: 'bongos_session=x', Authorization: 'Bearer y' });
|
|
246
|
+
assert.equal(signedIn.body, anon.body, 'the route reads no session');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await t('a failure is the 500 envelope, never a stack', async () => {
|
|
250
|
+
reset();
|
|
251
|
+
engine.throwOn = (sql) => PROBE.test(sql);
|
|
252
|
+
const r = await get(server, URL_);
|
|
253
|
+
assert.equal(r.status, 500);
|
|
254
|
+
assert.equal(r.json.error.code, 'builder_of_the_week_failed');
|
|
255
|
+
assert.doesNotMatch(r.body, /boom/);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await t('its own rate-limit budget, not the leaderboard’s', () => {
|
|
259
|
+
const { _internals } = buildRoutes;
|
|
260
|
+
assert.equal(_internals.WEEK_READ_RATE_SCOPE, 'builder-of-the-week-read');
|
|
261
|
+
assert.notEqual(_internals.WEEK_READ_RATE_SCOPE, _internals.READ_RATE_SCOPE);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
server.close();
|
|
265
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
266
|
+
if (failed) process.exit(1);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// tests/builder_of_the_week_db.mjs — task 1002981 (community spec D5; real DB).
|
|
2
|
+
//
|
|
3
|
+
// Proves Builder of the Week against real SQL: which snapshots open and close the
|
|
4
|
+
// week, the shipped-work floor, the tie order, the leaderboard's eligibility, that
|
|
5
|
+
// the first decision is pinned (a later change, a hide, a race) and that a week
|
|
6
|
+
// nobody won is recorded empty. A fake pool can only prove the statements' shape
|
|
7
|
+
// (tests/builder_of_the_week.mjs).
|
|
8
|
+
//
|
|
9
|
+
// The crown is global (every eligible builder on the hub), so every fixture week is
|
|
10
|
+
// in 2099: a shared database's real builders have no snapshot there, their week's
|
|
11
|
+
// delta is zero, and the shipped-work floor keeps them out.
|
|
12
|
+
//
|
|
13
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
14
|
+
// INTEGRATION set (scripts/bongos/run-unit-tests.js) so the DB-free lane never runs it.
|
|
15
|
+
//
|
|
16
|
+
// Run: DATABASE_URL=... node tests/builder_of_the_week_db.mjs
|
|
17
|
+
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { dirname, join } from 'node:path';
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const { Pool } = require('pg');
|
|
25
|
+
const { readBuilderOfTheWeek, crownedWeek, clearCrownMemo } = require('../modules/platform-identity/builder-of-the-week.js');
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
29
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
30
|
+
|
|
31
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
32
|
+
try {
|
|
33
|
+
await pool.query('SELECT 1');
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.log(`builder_of_the_week_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
36
|
+
await pool.end().catch(() => {});
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const A = 90298101, B = 90298102, C = 90298103, D = 90298104;
|
|
41
|
+
const HIDDEN = 90298105, PRIVATE = 90298106, E = 90298107;
|
|
42
|
+
const IDS = [A, B, C, D, E, HIDDEN, PRIVATE];
|
|
43
|
+
const P1 = 't1002981-p1', P2 = 't1002981-p2';
|
|
44
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
45
|
+
const NOW = new Date('2099-03-12T10:00:00.000Z');
|
|
46
|
+
const { start, end } = crownedWeek(NOW);
|
|
47
|
+
const at = (base, ms) => new Date(base.getTime() + ms);
|
|
48
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
49
|
+
|
|
50
|
+
async function cleanup() {
|
|
51
|
+
await q(`DELETE FROM platform_identity_weekly_features WHERE week_start >= '2099-01-01'`).catch(() => {});
|
|
52
|
+
await q(`DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
53
|
+
await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
54
|
+
await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
55
|
+
}
|
|
56
|
+
async function account(id, handle, { visibility = 'public', hide = false } = {}) {
|
|
57
|
+
await q(
|
|
58
|
+
`INSERT INTO platform_identity_accounts
|
|
59
|
+
(github_id, github_login, display_name, handle, profile_state, account_visibility, hide_stats, terms_accepted_at)
|
|
60
|
+
VALUES ($1, $2, $2, $2, 'active', $3, $4, now())`,
|
|
61
|
+
[id, handle, visibility, hide],
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
// The live row (its numbers do not matter to a past week; first seen long before the
|
|
65
|
+
// week unless said) and a history of snapshots.
|
|
66
|
+
async function builder(id, client, history, firstSeen = at(start, -365 * DAY)) {
|
|
67
|
+
await q(`INSERT INTO platform_identity_builder_project_activity (client_id, github_id, credits, tasks_shipped, karma, first_seen_at) VALUES ($1, $2, 0, 0, 0, $3)`, [client, id, firstSeen]);
|
|
68
|
+
for (const [when, credits, ships, karma] of history) {
|
|
69
|
+
await q(
|
|
70
|
+
`INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at) VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
71
|
+
[client, id, credits, ships, karma, when],
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const recorded = async (week) => (await q(
|
|
76
|
+
`SELECT github_id::text AS github_id, credits_delta::int AS credits, ships_delta::int AS ships FROM platform_identity_weekly_features WHERE week_start = $1::date`,
|
|
77
|
+
[week],
|
|
78
|
+
)).rows;
|
|
79
|
+
const read = (now = NOW) => { clearCrownMemo(); return readBuilderOfTheWeek({ now, pool }); };
|
|
80
|
+
|
|
81
|
+
let pass = 0;
|
|
82
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
83
|
+
const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
for (const f of [
|
|
87
|
+
'platform_identity_001_tables.sql', 'platform_identity_005_builder_activity.sql',
|
|
88
|
+
'platform_identity_007_profile_consent.sql', 'platform_identity_008_handle.sql',
|
|
89
|
+
'platform_identity_012_account_privacy.sql', 'platform_identity_019_terms_acceptance.sql',
|
|
90
|
+
'platform_identity_022_activity_snapshots.sql', 'platform_identity_032_weekly_features.sql',
|
|
91
|
+
]) await q(migrationSql(f));
|
|
92
|
+
await cleanup();
|
|
93
|
+
|
|
94
|
+
await account(A, 't1002981-a');
|
|
95
|
+
await account(B, 't1002981-b');
|
|
96
|
+
await account(C, 't1002981-c');
|
|
97
|
+
await account(D, 't1002981-d');
|
|
98
|
+
await account(E, 't1002981-e');
|
|
99
|
+
await account(HIDDEN, 't1002981-hidden', { hide: true });
|
|
100
|
+
await account(PRIVATE, 't1002981-private', { visibility: 'private' });
|
|
101
|
+
|
|
102
|
+
// A: 350 credits, 3 works, no karma — across two projects. P1 has a snapshot before
|
|
103
|
+
// the week (the baseline); P2 has none but was first seen in the week (counts from
|
|
104
|
+
// zero). A snapshot a second AFTER the week closes is not the week's.
|
|
105
|
+
await builder(A, P1, [[at(start, -3 * DAY), 100, 5, 4], [at(start, 2 * DAY), 400, 7, 4], [at(end, 1000), 99999, 99, 4]]);
|
|
106
|
+
await builder(A, P2, [[at(start, 3 * DAY), 50, 1, 0]], at(start, 3 * DAY));
|
|
107
|
+
// E: a lifetime brought into the week — no history before it, and seen before it (or
|
|
108
|
+
// before first_seen_at existed, NULL). Counting from zero would crown E; neither counts.
|
|
109
|
+
await builder(E, P1, [[at(start, DAY), 90000, 40, 0]], null);
|
|
110
|
+
await builder(E, P2, [[at(start, DAY), 90000, 40, 0]]);
|
|
111
|
+
// C: the same credits and works as A, two more karma — C beats A on karma.
|
|
112
|
+
await builder(C, P1, [[at(start, -DAY), 0, 0, 0], [at(start, DAY), 360, 3, 2]]);
|
|
113
|
+
// D: the same credits, the most karma, but one work fewer — works come before karma.
|
|
114
|
+
await builder(D, P1, [[at(start, -DAY), 0, 0, 0], [at(start, DAY), 350, 2, 9]]);
|
|
115
|
+
// B: the most credits, but no work shipped in the week (the floor).
|
|
116
|
+
await builder(B, P1, [[at(start, -DAY), 0, 4, 0], [at(start, DAY), 900, 4, 0]]);
|
|
117
|
+
// HIDDEN and PRIVATE out-earn everyone, and the leaderboard may not show them.
|
|
118
|
+
await builder(HIDDEN, P1, [[at(start, -DAY), 0, 0, 0], [at(start, DAY), 5000, 5, 0]]);
|
|
119
|
+
await builder(PRIVATE, P1, [[at(start, -DAY), 0, 0, 0], [at(start, DAY), 5000, 5, 0]]);
|
|
120
|
+
// A snapshot exactly at the start belongs to the baseline: C's week is 360 - 10, not 360.
|
|
121
|
+
await q(`INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at) VALUES ($1, $2, 10, 0, 0, $3)`, [P1, C, start]);
|
|
122
|
+
|
|
123
|
+
// 1 — the crown, and the row that records it
|
|
124
|
+
const first = await read();
|
|
125
|
+
same(first.week, { start: start.toISOString().slice(0, 10), end: end.toISOString().slice(0, 10) }, 'the week is the last full Monday-to-Monday week');
|
|
126
|
+
same(first.winner && [first.winner.handle, first.winner.credits, first.winner.tasks_shipped], ['t1002981-c', 350, 3], 'ties go to works shipped, then karma; no work, hidden, private and a lifetime brought into the week never win');
|
|
127
|
+
ok(!('github_id' in first.winner), 'github_id never crosses');
|
|
128
|
+
same(await recorded(first.week.start), [{ github_id: String(C), credits: 350, ships: 3 }], 'the week is recorded on the first read');
|
|
129
|
+
|
|
130
|
+
// 2 — pinned: a later report into the past week, or a builder who goes public, changes nothing
|
|
131
|
+
await q(`INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at) VALUES ($1, $2, 99999, 50, 0, $3)`, [P1, A, at(start, 4 * DAY)]);
|
|
132
|
+
await q(`UPDATE platform_identity_accounts SET hide_stats = false WHERE github_id = $1`, [HIDDEN]);
|
|
133
|
+
same((await read()).winner.handle, 't1002981-c', 'the first decision stands');
|
|
134
|
+
await q(`UPDATE platform_identity_accounts SET hide_stats = true WHERE github_id = $1`, [HIDDEN]);
|
|
135
|
+
|
|
136
|
+
// 3 — a winner who hides is not shown, and their week goes to nobody else
|
|
137
|
+
await q(`UPDATE platform_identity_accounts SET hide_stats = true WHERE github_id = $1`, [C]);
|
|
138
|
+
same((await read()).winner, null, 'a hidden winner is not drawn');
|
|
139
|
+
same(await recorded(first.week.start), [{ github_id: String(C), credits: 350, ships: 3 }], 'and the row is never re-crowned');
|
|
140
|
+
|
|
141
|
+
// 4 — a later week nobody shipped in (two on, past A's late snapshot) is recorded empty;
|
|
142
|
+
// the crowned week is in its past list once its winner is shown again
|
|
143
|
+
const next = at(NOW, 14 * DAY);
|
|
144
|
+
const empty = await read(next);
|
|
145
|
+
same(empty.winner, null, 'a week nobody won has no crown');
|
|
146
|
+
same(await recorded(empty.week.start), [{ github_id: null, credits: 0, ships: 0 }], 'and is recorded empty, so it stays empty');
|
|
147
|
+
same(empty.past, [], 'the week before is left out while its winner hides');
|
|
148
|
+
await q(`UPDATE platform_identity_accounts SET hide_stats = false WHERE github_id = $1`, [C]);
|
|
149
|
+
same((await read(next)).past.map((p) => [p.week_start, p.handle]), [[first.week.start, 't1002981-c']], 'shown again, it is back in the past weeks');
|
|
150
|
+
|
|
151
|
+
// 5 — two hub processes crowning the same week at once: one row, no error
|
|
152
|
+
const third = at(NOW, 21 * DAY);
|
|
153
|
+
const other = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
154
|
+
try {
|
|
155
|
+
clearCrownMemo();
|
|
156
|
+
const both = await Promise.all([readBuilderOfTheWeek({ now: third, pool }), readBuilderOfTheWeek({ now: third, pool: other })]);
|
|
157
|
+
same(both.map((b) => b.winner), [null, null], 'both answer');
|
|
158
|
+
same((await recorded(both[0].week.start)).length, 1, 'one row: ON CONFLICT DO NOTHING');
|
|
159
|
+
} finally { await other.end().catch(() => {}); }
|
|
160
|
+
|
|
161
|
+
console.log(`\nbuilder_of_the_week_db.mjs: ${pass} passed`);
|
|
162
|
+
} finally {
|
|
163
|
+
clearCrownMemo();
|
|
164
|
+
await cleanup();
|
|
165
|
+
await pool.end().catch(() => {});
|
|
166
|
+
}
|
|
@@ -188,14 +188,19 @@ await test('open: the queue\'s head, the applied page\'s render, and the words b
|
|
|
188
188
|
assert.ok(b.classes.has('aq-open'), 'the page behind stops scrolling');
|
|
189
189
|
assert.equal(text(b.q('.aq-eyebrow')), 'approval queue · 1 of 2');
|
|
190
190
|
assert.equal(text(b.q('.aq-title')), 'Home · landing · 1 line rewritten, applied · 1 not applied');
|
|
191
|
+
assert.equal(b.q('img'), null, 'the words lead: no picture until the artist asks for it');
|
|
192
|
+
assert.equal(b.requests.filter((r) => r.url.endsWith('/visuals')).length, 0, 'and none is fetched until then');
|
|
193
|
+
await b.click(b.q('[data-peek]'));
|
|
191
194
|
const img = b.q('img');
|
|
192
195
|
assert.equal(img.attrs.src, url(41, 'after-desktop-light'), 'the AFTER render, desktop, in the hall\'s own mode');
|
|
193
196
|
assert.equal(img.attrs.alt, 'Home, after, desktop, light');
|
|
194
197
|
assert.deepEqual(b.qa('[data-phase]').map((x) => [text(x), x.attrs['aria-pressed']]), [['before', 'false'], ['after', 'true']]);
|
|
198
|
+
await b.click(b.q('[data-peek]'));
|
|
199
|
+
assert.equal(b.q('img'), null, 'and it folds away again');
|
|
195
200
|
assert.equal(b.qa('mark').length, 1, 'one applied line carries the mark');
|
|
196
201
|
assert.equal(text(b.q('mark')), 'Agents write the code.');
|
|
197
202
|
assert.match(text(b.q('.aq-line--refused')), /not applied: the page no longer shows it/);
|
|
198
|
-
assert.equal(b.body.querySelectorAll('img').length,
|
|
203
|
+
assert.equal(b.body.querySelectorAll('img').length, 0, 'a hostile line is text, never markup');
|
|
199
204
|
assert.equal(b.win.pwned, undefined);
|
|
200
205
|
assert.equal(text(b.q('.aq-hint')), 'highlighted: the words you changed');
|
|
201
206
|
assert.deepEqual(b.counts, [2], 'the door hears how many wait');
|
|
@@ -205,9 +210,10 @@ await test('open: the queue\'s head, the applied page\'s render, and the words b
|
|
|
205
210
|
await test('the switches: before and after, desktop and phone, light and dark, each a render of the applied branch', async () => {
|
|
206
211
|
const b = bootPanel({ answers: { [APPROVALS]: queueFor(), [vis(41)]: slotsFor(41) } });
|
|
207
212
|
await b.panel.open(); await settle(); await settle();
|
|
213
|
+
await b.click(b.q('[data-peek]'));
|
|
208
214
|
await b.click(b.q('[data-phase="before"]'));
|
|
209
215
|
assert.equal(b.q('img').attrs.src, url(41, 'before-desktop-light'));
|
|
210
|
-
assert.equal(b.qa('mark').length,
|
|
216
|
+
assert.equal(b.qa('mark').length, 1, 'the words beside the picture do not change with it');
|
|
211
217
|
await b.click(b.q('[data-device="phone"]'));
|
|
212
218
|
assert.equal(b.q('img').attrs.src, url(41, 'before-phone-light'));
|
|
213
219
|
assert.match(b.q('img').attrs.class, /aq-render--phone/);
|
|
@@ -216,17 +222,19 @@ await test('the switches: before and after, desktop and phone, light and dark, e
|
|
|
216
222
|
assert.equal(b.requests.filter((r) => r.url.endsWith('/visuals')).length, 1, 'the renders are read once per round');
|
|
217
223
|
});
|
|
218
224
|
|
|
219
|
-
await test('a missing render says what it shows instead; no renders at all
|
|
225
|
+
await test('a missing render says what it shows instead; no renders at all never blocks the decision', async () => {
|
|
220
226
|
const b = bootPanel({ answers: { [APPROVALS]: queueFor(), [vis(41)]: slotsFor(41, ['after-desktop-dark']) } });
|
|
221
227
|
await b.panel.open(); await settle(); await settle();
|
|
228
|
+
await b.click(b.q('[data-peek]'));
|
|
222
229
|
assert.equal(b.q('img').attrs.src, url(41, 'after-desktop-dark'));
|
|
223
230
|
assert.equal(text(b.q('.aq-frame__swap')), 'No desktop light render; showing desktop dark.');
|
|
224
231
|
const none = bootPanel({ answers: { [APPROVALS]: queueFor(), [vis(41)]: slotsFor(41, []) } });
|
|
225
232
|
await none.panel.open(); await settle(); await settle();
|
|
233
|
+
assert.ok(!none.q('[data-approve]').disabled, 'the words are what is approved: no picture is needed');
|
|
234
|
+
await none.click(none.q('[data-peek]'));
|
|
226
235
|
assert.equal(none.q('img'), null);
|
|
227
236
|
assert.match(text(none.q('.aq-frame__note')), /No render is attached/);
|
|
228
|
-
assert.ok(none.q('[data-
|
|
229
|
-
assert.ok(!none.q('[data-open-note]').disabled, 'sending it back does not');
|
|
237
|
+
assert.ok(!none.q('[data-open-note]').disabled, 'sending it back is open too');
|
|
230
238
|
});
|
|
231
239
|
|
|
232
240
|
await test('approve and ship: one POST through the host\'s write, then the artboard\'s line and the next page', async () => {
|
|
@@ -297,6 +305,24 @@ await test('a builder who may not decide reads the queue with a sentence, not tw
|
|
|
297
305
|
assert.match(text(b.q('.aq-sealed')), /Only the artist who wrote this page, or another artist/);
|
|
298
306
|
});
|
|
299
307
|
|
|
308
|
+
await test('open(pageId) lands on that page card, says so when it is not in the queue, and can skip the second read', async () => {
|
|
309
|
+
const b = bootPanel({ answers: { [APPROVALS]: queueFor(), [vis(42)]: slotsFor(42) } });
|
|
310
|
+
await b.panel.open('landing:projects');
|
|
311
|
+
await settle(); await settle();
|
|
312
|
+
assert.equal(text(b.q('.aq-eyebrow')), 'approval queue · 2 of 2', 'the Projects card, not the head');
|
|
313
|
+
const miss = bootPanel({ answers: { [APPROVALS]: queueFor() } });
|
|
314
|
+
await miss.panel.open('landing:nowhere');
|
|
315
|
+
await settle(); await settle();
|
|
316
|
+
assert.equal(text(miss.q('.aq-eyebrow')), 'approval queue · 1 of 2');
|
|
317
|
+
assert.equal(text(miss.q('.aq-error')), 'That page is not waiting for your sign-off right now.');
|
|
318
|
+
const once = bootPanel({ answers: { [APPROVALS]: queueFor() } });
|
|
319
|
+
await once.panel.refresh();
|
|
320
|
+
await once.panel.open(undefined, { reload: false });
|
|
321
|
+
await settle(); await settle();
|
|
322
|
+
assert.equal(once.requests.filter((r) => r.url.endsWith('/copy-desk/approvals')).length, 1, 'refreshed once, opened without re-reading');
|
|
323
|
+
assert.equal(once.panel.notAppliedCount(), 0);
|
|
324
|
+
});
|
|
325
|
+
|
|
300
326
|
await test('empty, closed with Escape, and focus goes back where it was', async () => {
|
|
301
327
|
const b = bootPanel({ answers: { [APPROVALS]: queueFor({ tasks: [] }) } });
|
|
302
328
|
const opener = { focus() { boot.focused = opener; } };
|
|
@@ -391,9 +417,6 @@ await test('a line holding {{productName}} reads as the product name in a chip,
|
|
|
391
417
|
assert.ok(chips.every((c) => c.attrs['data-hole'] === 'productName' && text(c) === 'Cloud Bongos'));
|
|
392
418
|
assert.ok(b.q('mark').querySelector('.aq-hole'), 'the chip sits inside the changed words\' mark');
|
|
393
419
|
assert.ok(!/\{\{/.test(b.scrim.innerHTML || '') && !/\{\{/.test(text(b.scrim)), 'no raw {{token}} anywhere in the panel');
|
|
394
|
-
b.scrim.fire('click', { target: b.q('[data-phase="before"]') }); await settle();
|
|
395
|
-
assert.equal(lineWords(), 'Both are how Cloud Bongos itself works.');
|
|
396
|
-
assert.equal(asideWords(), 'becomes: This project, Cloud Bongos, has two doors.');
|
|
397
420
|
});
|
|
398
421
|
|
|
399
422
|
await test('the dialog fits the viewport at every width: capped by the screen, the page shrinks first, rows wrap', () => {
|
|
@@ -411,7 +434,8 @@ await test('the dialog fits the viewport at every width: capped by the screen, t
|
|
|
411
434
|
assert.match(rule('.aq-foot'), /flex-wrap: wrap/);
|
|
412
435
|
assert.match(rule('.aq-body'), /min-width: 0/);
|
|
413
436
|
assert.match(rule('.aq-frame'), /flex-grow: 1;[\s\S]*min-width: 0/, 'the rendered page is what shrinks first');
|
|
414
|
-
assert.match(rule('.aq-lines'), /width:
|
|
437
|
+
assert.match(rule('.aq-lines'), /min-width: 0/, 'the words take the full width and can shrink');
|
|
438
|
+
assert.match(rule('.aq-line'), /grid-template-columns: minmax\(0, 1fr\) minmax\(0, 1fr\)/, 'was and now sit side by side');
|
|
415
439
|
assert.match(rule('.aq-line__text'), /overflow-wrap: anywhere/, 'a long word breaks inside its column');
|
|
416
440
|
});
|
|
417
441
|
|