@bongos/core 1.21.105 → 1.21.107
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 +88 -58
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +91 -2
- package/docs/api-reference.md +5 -3
- package/docs/copy-inventory.md +368 -366
- package/docs/copy-registry.json +475 -457
- package/docs/design/project-startup-direction.md +14 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +10 -3
- package/docs/page-readings.json +1023 -977
- package/migrations/core_275_claims_auto_pickup.sql +24 -0
- package/modules/government/decision-rules.js +1 -0
- package/modules/lifecycle/autopickup.js +94 -0
- package/modules/lifecycle/db-claims.js +18 -4
- package/modules/lifecycle/routes/claims.js +12 -1
- package/modules/lifecycle/routes/project-settings.js +59 -0
- package/modules/public-landing/public/projects-hall-live.states.json +3 -2
- package/modules/public-landing/public/projects-setup.states.json +91 -0
- package/modules/public-landing/public/projects.html +535 -804
- package/modules/public-landing/public/projects.probes.json +3 -3
- package/modules/public-landing/public/projects.states.json +1 -1
- package/modules/ui-design/kit/fixtures/provisioning-instances-hall-live.json +22 -2
- package/modules/ui-design/kit/fixtures/provisioning-instances-setup.json +195 -0
- package/modules/ui-design/kit/serve.js +22 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/government-walk.js +1 -0
- package/scripts/gds/run-unit-tests.js +3 -0
- package/src/bongos/project-settings.js +79 -0
- package/src/module-api.js +8 -1
- package/tests/autopickup_cap.mjs +236 -0
- package/tests/autopickup_cap_db.mjs +115 -0
- package/tests/cosmos_token_contrast.mjs +10 -18
- package/tests/github_handback_any_device.mjs +4 -3
- package/tests/github_one_trip.mjs +45 -47
- package/tests/github_org_account.mjs +2 -2
- package/tests/github_pick_up.mjs +40 -29
- package/tests/government_act_alone_buildings.mjs +1 -0
- package/tests/onboard.mjs +13 -7
- package/tests/project_invite_ui.mjs +6 -3
- package/tests/project_modules_ui.mjs +3 -3
- package/tests/projects_hub.mjs +8 -9
- package/tests/projects_hub_cloud_hop.mjs +77 -125
- package/tests/projects_hub_dns_ready.mjs +42 -51
- package/tests/projects_hub_pre_uat.mjs +68 -69
- package/tests/public_landing_projects.mjs +45 -102
- package/tests/setup_status_facts.mjs +400 -0
- package/tests/wizard_github_feedback.mjs +90 -87
- package/tests/wizard_one_page.mjs +11 -9
- package/tests/wizard_auto_open.mjs +0 -94
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// tests/autopickup_cap.mjs — tweak auto-pickup's setting, monthly cap and claim mark
|
|
2
|
+
// (task 1004677 / BV2.TW19a, ADR 0371 D3, D5, D6).
|
|
3
|
+
//
|
|
4
|
+
// Pinned here (DB-free; the race lives in tests/autopickup_cap_db.mjs):
|
|
5
|
+
// 1. the two parsers and the resolver: off by default, cap 20 by default, an
|
|
6
|
+
// unusable stored value reported and never thrown, a dead DB answers defaults;
|
|
7
|
+
// 2. the refusal ladder: not a tweak, switch off, round limit, monthly cap, and the
|
|
8
|
+
// lock is taken BEFORE either count;
|
|
9
|
+
// 3. claimTask: a marked claim runs the gate before the insert and names the
|
|
10
|
+
// column; an unmarked claim's insert is unchanged;
|
|
11
|
+
// 4. the routes: any builder reads (with this month's count), only
|
|
12
|
+
// project.settings.manage writes, and a half-valid body writes nothing.
|
|
13
|
+
//
|
|
14
|
+
// Run: node --test tests/autopickup_cap.mjs
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const poolMod = require(path.join(ROOT, 'src', 'bongos', 'pool.js'));
|
|
25
|
+
const {
|
|
26
|
+
DEFAULT_AUTOPICKUP, DEFAULT_AUTOPICKUP_CAP, parseAutopickup, parseAutopickupCap,
|
|
27
|
+
resolveTweakAutopickup, describeTweakAutopickup, SETTING_KEYS, AUTOPICKUP_MODES, MAX_AUTOPICKUP_CAP,
|
|
28
|
+
} = require('../src/bongos/project-settings.js');
|
|
29
|
+
const { autoPickupRefusal, LOCK_KEY, MAX_AUTO_APPLIES_PER_ROUND } = require('../modules/lifecycle/autopickup.js');
|
|
30
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
31
|
+
|
|
32
|
+
async function withPool(query, fn) {
|
|
33
|
+
const pool = poolMod.pool;
|
|
34
|
+
const saved = pool.query;
|
|
35
|
+
pool.query = query;
|
|
36
|
+
try { return await fn(); } finally { pool.query = saved; }
|
|
37
|
+
}
|
|
38
|
+
const settingsRows = (map) => async (_sql, [key]) => ({ rows: key in map ? [{ key, value: map[key] }] : [] });
|
|
39
|
+
|
|
40
|
+
// ---- 1. the setting ---------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
test('the parsers accept exactly off|on and a whole positive cap', () => {
|
|
43
|
+
assert.deepEqual([...AUTOPICKUP_MODES], ['off', 'on']);
|
|
44
|
+
assert.equal(api.projectSettings.MAX_AUTOPICKUP_CAP, MAX_AUTOPICKUP_CAP, 'the doorway hands out the same bound');
|
|
45
|
+
assert.deepEqual(parseAutopickup(' On '), { mode: 'on' });
|
|
46
|
+
assert.match(parseAutopickup('maybe').reason, /off \| on/);
|
|
47
|
+
assert.deepEqual(parseAutopickupCap('5'), { cap: 5 });
|
|
48
|
+
assert.match(parseAutopickupCap('0').reason, /positive/);
|
|
49
|
+
assert.match(parseAutopickupCap('2.5').reason, /whole/);
|
|
50
|
+
assert.match(parseAutopickupCap('100000').reason, /out of range/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('an untouched project is off with a cap of 20', async () => {
|
|
54
|
+
assert.equal(DEFAULT_AUTOPICKUP, 'off');
|
|
55
|
+
assert.equal(DEFAULT_AUTOPICKUP_CAP, 20);
|
|
56
|
+
const r = await withPool(settingsRows({}), () => resolveTweakAutopickup());
|
|
57
|
+
assert.deepEqual(r, { mode: 'off', cap: 20, source: { mode: 'default', cap: 'default' }, invalid: [] });
|
|
58
|
+
assert.match(describeTweakAutopickup(r), /waits until a person/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('stored values are read; an unusable one is reported and falls back', async () => {
|
|
62
|
+
const r = await withPool(settingsRows({ [SETTING_KEYS.TWEAK_AUTOPICKUP]: 'on', [SETTING_KEYS.TWEAK_AUTOPICKUP_CAP]: 'lots' }),
|
|
63
|
+
() => resolveTweakAutopickup());
|
|
64
|
+
assert.equal(r.mode, 'on');
|
|
65
|
+
assert.equal(r.source.mode, 'project_settings');
|
|
66
|
+
assert.equal(r.cap, 20);
|
|
67
|
+
assert.equal(r.invalid.length, 1);
|
|
68
|
+
assert.equal(r.invalid[0].raw, 'lots');
|
|
69
|
+
assert.match(describeTweakAutopickup({ mode: 'on', cap: 7 }), /up to 7 a month/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a database that cannot be read answers the defaults', async () => {
|
|
73
|
+
const r = await withPool(async () => { throw new Error('db down'); }, () => resolveTweakAutopickup());
|
|
74
|
+
assert.equal(r.mode, 'off');
|
|
75
|
+
assert.equal(r.cap, 20);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---- 2. the refusal ladder --------------------------------------------------
|
|
79
|
+
|
|
80
|
+
function fakeClient({ round = 0, month = 0 } = {}) {
|
|
81
|
+
const calls = [];
|
|
82
|
+
return {
|
|
83
|
+
calls,
|
|
84
|
+
async query(sql, params) {
|
|
85
|
+
calls.push(sql.includes('pg_advisory_xact_lock') ? `lock:${params[0]}` : sql.includes('date_trunc') ? 'month' : 'round');
|
|
86
|
+
if (sql.includes('pg_advisory_xact_lock')) return { rows: [] };
|
|
87
|
+
return { rows: [{ n: sql.includes('date_trunc') ? month : round }] };
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const tweak = { id: 9, source: 'page-tweak' };
|
|
92
|
+
const on = { mode: 'on', cap: 3 };
|
|
93
|
+
|
|
94
|
+
test('a marked claim on anything but a page tweak is refused, before any lock', async () => {
|
|
95
|
+
const c = fakeClient();
|
|
96
|
+
assert.equal((await autoPickupRefusal(c, { id: 9, source: 'manual' }, on)).code, 'AUTO_PICKUP_NOT_A_TWEAK');
|
|
97
|
+
assert.deepEqual(c.calls, []);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('the switch off refuses a marked claim', async () => {
|
|
101
|
+
assert.equal((await autoPickupRefusal(fakeClient(), tweak, { mode: 'off', cap: 3 })).code, 'AUTO_PICKUP_OFF');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('the lock is taken before either count, on the one project-wide key', async () => {
|
|
105
|
+
const c = fakeClient();
|
|
106
|
+
assert.equal(await autoPickupRefusal(c, tweak, on), null);
|
|
107
|
+
assert.equal(LOCK_KEY, 'page-tweak/autopickup');
|
|
108
|
+
assert.deepEqual(c.calls, ['lock:page-tweak/autopickup', 'round', 'month']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a third automatic apply of one round is refused', async () => {
|
|
112
|
+
assert.equal(MAX_AUTO_APPLIES_PER_ROUND, 2);
|
|
113
|
+
assert.equal(await autoPickupRefusal(fakeClient({ round: 1 }), tweak, on), null);
|
|
114
|
+
const r = await autoPickupRefusal(fakeClient({ round: 2 }), tweak, on);
|
|
115
|
+
assert.equal(r.code, 'AUTO_PICKUP_ROUND_LIMIT');
|
|
116
|
+
assert.match(r.message, /waits for a person/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('the monthly cap refuses at the cap and admits below it', async () => {
|
|
120
|
+
assert.equal(await autoPickupRefusal(fakeClient({ month: 2 }), tweak, on), null);
|
|
121
|
+
const r = await autoPickupRefusal(fakeClient({ month: 3 }), tweak, on);
|
|
122
|
+
assert.equal(r.code, 'AUTO_PICKUP_CAP_REACHED');
|
|
123
|
+
assert.equal(r.cap, 3);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---- 3. claimTask wiring ----------------------------------------------------
|
|
127
|
+
|
|
128
|
+
test('claimTask runs the gate before the insert, and names the column only on a marked claim', () => {
|
|
129
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'db-claims.js'), 'utf8');
|
|
130
|
+
const body = src.slice(src.indexOf('async function claimTask('), src.indexOf('async function claimTasksBatch'));
|
|
131
|
+
const gate = body.indexOf('autoPickupRefusal(client, task, autoPickupSettings)');
|
|
132
|
+
const insert = body.indexOf('INSERT INTO claims');
|
|
133
|
+
assert.ok(gate > 0 && insert > gate, 'the gate runs inside the transaction, before the insert');
|
|
134
|
+
assert.match(body.slice(gate, insert), /if \(refusal\) throw refusal;/, 'a refusal stops the claim');
|
|
135
|
+
assert.match(body, /\$\{autoPickup \? ', auto_pickup' : ''\}/);
|
|
136
|
+
assert.match(body, /goal_id, source FROM tasks WHERE id = \$1 FOR UPDATE/, 'the task row carries its source');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('POST /claims accepts auto_pickup and maps every refusal to a 409', () => {
|
|
140
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'claims.js'), 'utf8');
|
|
141
|
+
assert.match(src, /auto_pickup: \{ type: 'boolean' \}/);
|
|
142
|
+
assert.match(src, /autoPickup: autoPickup === true/);
|
|
143
|
+
for (const code of ['AUTO_PICKUP_NOT_A_TWEAK', 'AUTO_PICKUP_OFF', 'AUTO_PICKUP_CAP_REACHED', 'AUTO_PICKUP_ROUND_LIMIT']) {
|
|
144
|
+
assert.match(src, new RegExp(`err.code === '${code}' \\? 409`));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---- 4. the routes ----------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
const fakeRes = () => ({
|
|
151
|
+
code: null, statusCode: 200, payload: null, headers: {},
|
|
152
|
+
fail(code, opts) { this.code = code; this.statusCode = opts?.status ?? 400; this.payload = { error: code, ...(opts || {}) }; return this; },
|
|
153
|
+
status(c) { this.statusCode = c; return this; },
|
|
154
|
+
set(k, v) { this.headers[k] = v; return this; },
|
|
155
|
+
json(p) { this.payload = p; return this; },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
function buildRouter({ used = 4 } = {}) {
|
|
159
|
+
const ps = api.projectSettings;
|
|
160
|
+
const saved = { resolve: ps.resolveTweakAutopickup, set: ps.set };
|
|
161
|
+
const writes = [];
|
|
162
|
+
const state = { mode: 'off', cap: 20 };
|
|
163
|
+
ps.resolveTweakAutopickup = async () => ({ ...state, source: { mode: 'default', cap: 'default' }, invalid: [] });
|
|
164
|
+
ps.set = async (key, value, by) => {
|
|
165
|
+
writes.push({ key, value, by });
|
|
166
|
+
if (key === 'tweak.autopickup') state.mode = value; else state.cap = value;
|
|
167
|
+
};
|
|
168
|
+
const autopickup = require('../modules/lifecycle/autopickup.js');
|
|
169
|
+
const savedCount = autopickup.countThisMonth;
|
|
170
|
+
autopickup.countThisMonth = async () => used;
|
|
171
|
+
const file = path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js');
|
|
172
|
+
delete require.cache[require.resolve(file)];
|
|
173
|
+
const router = require(file)();
|
|
174
|
+
const route = (method, p) => router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]).route;
|
|
175
|
+
const restore = () => { ps.resolveTweakAutopickup = saved.resolve; ps.set = saved.set; autopickup.countThisMonth = savedCount; };
|
|
176
|
+
return { route, writes, restore };
|
|
177
|
+
}
|
|
178
|
+
const handler = (r) => r.stack[r.stack.length - 1].handle;
|
|
179
|
+
|
|
180
|
+
test('the read is open to any builder and reports this month\'s count against the cap', async () => {
|
|
181
|
+
const { route, restore } = buildRouter({ used: 4 });
|
|
182
|
+
try {
|
|
183
|
+
const get = route('get', '/project-settings/tweak-autopickup');
|
|
184
|
+
assert.equal(get.stack.length, 2, 'requireBuilder + the handler');
|
|
185
|
+
const res = fakeRes();
|
|
186
|
+
await handler(get)({}, res);
|
|
187
|
+
assert.equal(res.payload.mode, 'off');
|
|
188
|
+
assert.equal(res.payload.used_this_month, 4);
|
|
189
|
+
assert.equal(res.payload.remaining_this_month, 16);
|
|
190
|
+
assert.equal(res.payload.per_round_limit, 2);
|
|
191
|
+
assert.equal(res.headers['Cache-Control'], 'no-store');
|
|
192
|
+
} finally { restore(); }
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('the write is gated on project.settings.manage and the Rules decision rule', () => {
|
|
196
|
+
const { route, restore } = buildRouter();
|
|
197
|
+
try {
|
|
198
|
+
const write = route('patch', '/project-settings/tweak-autopickup').stack;
|
|
199
|
+
assert.equal(write.length, 4);
|
|
200
|
+
assert.equal(write[2].handle.name, 'actAloneGate');
|
|
201
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js'), 'utf8');
|
|
202
|
+
assert.match(src, /router\.patch\('\/project-settings\/tweak-autopickup', api\.requireBuilder, api\.requirePermission\('project\.settings\.manage'\)/);
|
|
203
|
+
} finally { restore(); }
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('a half-valid body writes nothing; a good one writes both and echoes the new read', async () => {
|
|
207
|
+
const { route, writes, restore } = buildRouter({ used: 0 });
|
|
208
|
+
try {
|
|
209
|
+
const patch = handler(route('patch', '/project-settings/tweak-autopickup'));
|
|
210
|
+
const bad = fakeRes();
|
|
211
|
+
await patch({ body: { mode: 'on', cap: 0 }, builder: { id: 7 } }, bad);
|
|
212
|
+
assert.equal(bad.code, 'bad_tweak_autopickup');
|
|
213
|
+
assert.equal(writes.length, 0);
|
|
214
|
+
|
|
215
|
+
const empty = fakeRes();
|
|
216
|
+
await patch({ body: {}, builder: { id: 7 } }, empty);
|
|
217
|
+
assert.equal(empty.code, 'bad_tweak_autopickup');
|
|
218
|
+
|
|
219
|
+
const good = fakeRes();
|
|
220
|
+
await patch({ body: { mode: 'ON', cap: 5 }, builder: { id: 7 } }, good);
|
|
221
|
+
assert.equal(good.code, null);
|
|
222
|
+
assert.deepEqual(writes, [
|
|
223
|
+
{ key: 'tweak.autopickup', value: 'on', by: 7 },
|
|
224
|
+
{ key: 'tweak.autopickup_monthly_cap', value: 5, by: 7 },
|
|
225
|
+
]);
|
|
226
|
+
assert.equal(good.payload.mode, 'on');
|
|
227
|
+
assert.equal(good.payload.cap, 5);
|
|
228
|
+
} finally { restore(); }
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('core_275 is additive: one nullable column, recorded in schema_migrations', () => {
|
|
232
|
+
const sql = fs.readFileSync(path.join(ROOT, 'migrations', 'core_275_claims_auto_pickup.sql'), 'utf8');
|
|
233
|
+
assert.match(sql, /ALTER TABLE claims ADD COLUMN IF NOT EXISTS auto_pickup boolean;/);
|
|
234
|
+
assert.doesNotMatch(sql, /NOT NULL|DROP /);
|
|
235
|
+
assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('core_275_claims_auto_pickup'\)/);
|
|
236
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// tests/autopickup_cap_db.mjs — the auto-pickup cap holds under a race, against a
|
|
2
|
+
// REAL Postgres (task 1004677 / BV2.TW19a, ADR 0371 D5).
|
|
3
|
+
//
|
|
4
|
+
// tests/autopickup_cap.mjs proves the refusal ladder DB-free. What it cannot prove
|
|
5
|
+
// is the claim this file exists for: two runners racing for the LAST slot cannot
|
|
6
|
+
// both get it. That is a property of pg_advisory_xact_lock plus the count running
|
|
7
|
+
// inside the claim's transaction, so it needs a real database and two real
|
|
8
|
+
// connections.
|
|
9
|
+
//
|
|
10
|
+
// It SELF-SKIPS (exit 0) with no reachable Postgres, and is listed in
|
|
11
|
+
// run-unit-tests.js's INTEGRATION set so the DB-free lane never runs it. It builds
|
|
12
|
+
// throwaway `tasks` and `claims` tables, so point it at a scratch database.
|
|
13
|
+
//
|
|
14
|
+
// Run: DATABASE_URL=postgres://... node tests/autopickup_cap_db.mjs
|
|
15
|
+
|
|
16
|
+
import pg from 'pg';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import url from 'node:url';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await pool.query('SELECT 1');
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.log(`autopickup_cap_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}). Set DATABASE_URL to run.`);
|
|
30
|
+
await pool.end().catch(() => {});
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
const { rows: realRows } = await pool.query(`SELECT to_regclass('public.builders') IS NOT NULL AS has_builders`);
|
|
34
|
+
if (realRows[0].has_builders) {
|
|
35
|
+
console.log('autopickup_cap_db.mjs SKIPPED — this database has a `builders` table, so it is not a scratch DB.');
|
|
36
|
+
await pool.end().catch(() => {});
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let passed = 0, failed = 0;
|
|
41
|
+
async function t(name, fn) {
|
|
42
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
43
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await pool.query(`
|
|
47
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY);
|
|
48
|
+
CREATE TABLE IF NOT EXISTS tasks (id bigint PRIMARY KEY, source text);
|
|
49
|
+
CREATE TABLE IF NOT EXISTS claims (
|
|
50
|
+
id bigserial PRIMARY KEY, task_id bigint, builder_id bigint,
|
|
51
|
+
claimed_at timestamptz DEFAULT now(), released_at timestamptz);
|
|
52
|
+
`);
|
|
53
|
+
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
|
|
54
|
+
await pool.query(fs.readFileSync(path.join(HERE, '..', 'migrations', 'core_275_claims_auto_pickup.sql'), 'utf8'));
|
|
55
|
+
|
|
56
|
+
const { autoPickupRefusal, countThisMonth } = require('../modules/lifecycle/autopickup.js');
|
|
57
|
+
|
|
58
|
+
async function reset() {
|
|
59
|
+
await pool.query('TRUNCATE claims, tasks');
|
|
60
|
+
await pool.query(`INSERT INTO tasks (id, source) VALUES (1,'page-tweak'),(2,'page-tweak'),(3,'page-tweak'),(4,'manual')`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// One marked claim the way claimTask makes it: BEGIN, the gate (which takes the
|
|
64
|
+
// lock and counts), the insert, COMMIT. `pause` holds the transaction open after
|
|
65
|
+
// the count so the other racer's count is forced to happen while it is in flight.
|
|
66
|
+
async function markedClaim(taskId, settings, pause = 0) {
|
|
67
|
+
const client = await pool.connect();
|
|
68
|
+
try {
|
|
69
|
+
await client.query('BEGIN');
|
|
70
|
+
const refusal = await autoPickupRefusal(client, { id: taskId, source: 'page-tweak' }, settings);
|
|
71
|
+
if (refusal) { await client.query('ROLLBACK'); return refusal.code; }
|
|
72
|
+
if (pause) await new Promise((r) => setTimeout(r, pause));
|
|
73
|
+
await client.query('INSERT INTO claims (task_id, builder_id, auto_pickup) VALUES ($1, 1, TRUE)', [taskId]);
|
|
74
|
+
await client.query('COMMIT');
|
|
75
|
+
return 'claimed';
|
|
76
|
+
} catch (e) {
|
|
77
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
78
|
+
throw e;
|
|
79
|
+
} finally { client.release(); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await t('core_275 adds a nullable auto_pickup column', async () => {
|
|
83
|
+
const { rows } = await pool.query(`SELECT is_nullable, data_type FROM information_schema.columns WHERE table_name='claims' AND column_name='auto_pickup'`);
|
|
84
|
+
assert.equal(rows[0].data_type, 'boolean');
|
|
85
|
+
assert.equal(rows[0].is_nullable, 'YES');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await t('two runners racing for the last slot: exactly one wins', async () => {
|
|
89
|
+
await reset();
|
|
90
|
+
await pool.query(`INSERT INTO claims (task_id, builder_id, auto_pickup) VALUES (3, 1, TRUE)`); // cap 2, one used
|
|
91
|
+
const settings = { mode: 'on', cap: 2 };
|
|
92
|
+
const results = await Promise.all([markedClaim(1, settings, 300), markedClaim(2, settings, 300)]);
|
|
93
|
+
assert.deepEqual(results.sort(), ['AUTO_PICKUP_CAP_REACHED', 'claimed'], JSON.stringify(results));
|
|
94
|
+
assert.equal(await countThisMonth(pool), 2);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await t('a round applied twice automatically refuses a third marked claim', async () => {
|
|
98
|
+
await reset();
|
|
99
|
+
const settings = { mode: 'on', cap: 20 };
|
|
100
|
+
assert.equal(await markedClaim(1, settings), 'claimed');
|
|
101
|
+
assert.equal(await markedClaim(1, settings), 'claimed');
|
|
102
|
+
assert.equal(await markedClaim(1, settings), 'AUTO_PICKUP_ROUND_LIMIT');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await t('unmarked claims and last month\'s marked claims do not count', async () => {
|
|
106
|
+
await reset();
|
|
107
|
+
await pool.query(`INSERT INTO claims (task_id, builder_id, auto_pickup, claimed_at) VALUES
|
|
108
|
+
(1, 1, NULL, now()), (2, 1, FALSE, now()), (3, 1, TRUE, date_trunc('month', now()) - interval '1 day'),
|
|
109
|
+
(4, 1, TRUE, now())`); // task 4 is not a page tweak
|
|
110
|
+
assert.equal(await countThisMonth(pool), 0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await pool.end();
|
|
114
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
115
|
+
process.exit(failed ? 1 : 0); // the doorway's own pool would otherwise hold the process open
|
|
@@ -515,24 +515,16 @@ test('text is never dimmed by subtree opacity', () => {
|
|
|
515
515
|
assert.deepEqual(offenders, [], `\n ${offenders.join('\n ')}\n`);
|
|
516
516
|
});
|
|
517
517
|
|
|
518
|
-
test('
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const body = m[1];
|
|
529
|
-
if (/(?:border(?:-\w+)?|background)\s*:\s*var\(\s*--sea\s*\)/.test(body)) {
|
|
530
|
-
bad.push(`raw --sea state mark: ${m[0].slice(0, 80)}`);
|
|
531
|
-
}
|
|
532
|
-
if (/border-(?:left|bottom)\s*:[^;]*#fff\b/i.test(body)) {
|
|
533
|
-
bad.push(`hardcoded #fff tick: ${m[0].slice(0, 80)}`);
|
|
534
|
-
}
|
|
535
|
-
}
|
|
518
|
+
test('part 03 draws no stage strip, and its fact marks use the status inks, never raw --sea or #fff (task 1004683)', () => {
|
|
519
|
+
// The done panel's page-local .prog strip (a mirror of cosmos's .strip) left with the
|
|
520
|
+
// stage dots: part 03 is one list of checked facts now. The mine and manage views keep
|
|
521
|
+
// cosmos's own .strip. A fact's mark carries its state in the --ok / --err / --accent
|
|
522
|
+
// inks (which the day room re-points through --wz-*); raw --sea or a literal white
|
|
523
|
+
// would be the 1.74:1 dark tick this test was first written for.
|
|
524
|
+
assert.equal([...pageSrc.matchAll(/\.prog[^{}]*\{([^}]*)\}/g)].length, 0, 'no .prog rules are left in projects.html');
|
|
525
|
+
const marks = [...pageSrc.matchAll(/\.setFact\.(done|bad|now|you) \.d\{([^}]*)\}/g)];
|
|
526
|
+
assert.ok(marks.length >= 4, 'the four marked fact states are found');
|
|
527
|
+
const bad = marks.filter((m) => /var\(\s*--sea\s*\)|#fff\b/i.test(m[2])).map((m) => m[0].slice(0, 80));
|
|
536
528
|
assert.deepEqual(bad, [], `\n ${bad.join('\n ')}\n`);
|
|
537
529
|
});
|
|
538
530
|
|
|
@@ -82,8 +82,8 @@ function region(startMark, endMark) {
|
|
|
82
82
|
const e = s < 0 ? -1 : SCRIPT.indexOf(endMark, s);
|
|
83
83
|
return s > 0 && e > s ? SCRIPT.slice(s, e) : null;
|
|
84
84
|
}
|
|
85
|
-
const POLLS = region(" /* ── C8: the wizard's polls run only while the create view is on stage",
|
|
86
|
-
const WATCH_VARS = region(' var pollTimer = null;',
|
|
85
|
+
const POLLS = region(" /* ── C8: the wizard's polls run only while the create view is on stage", " /* ── the owner's instance read (task 2083)");
|
|
86
|
+
const WATCH_VARS = region(' var pollTimer = null;', ' function startProgress(id) {');
|
|
87
87
|
const START_PROGRESS = (SCRIPT.match(/ {4}function startProgress\(id\) \{[\s\S]*?\n {4}\}/) || [null])[0];
|
|
88
88
|
const DOORS = region(" /* ── the hub's three doors ── */", ' return { enter: enter, leave: leave, onAuth: onAuth };');
|
|
89
89
|
|
|
@@ -136,7 +136,8 @@ function page({ url, who, storage = 'empty', name = '', hidden = false, instance
|
|
|
136
136
|
renderDone() { log.rendered += 1; }, renderSteps() { log.rendered += 1; },
|
|
137
137
|
resetForNewProject() { log.resets += 1; },
|
|
138
138
|
saveDraft() {},
|
|
139
|
-
|
|
139
|
+
// part 03's facts (task 1004683) are tests/setup_status_facts.mjs; here they only have to exist
|
|
140
|
+
paintFacts() {}, homeFacts: () => [], ghFacts: () => [], setupReady: () => false, hostOk: () => true, addressReady: () => true,
|
|
140
141
|
};
|
|
141
142
|
sandbox.globalThis = sandbox;
|
|
142
143
|
vm.createContext(sandbox);
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
// app's OWN key: the creds read back out of a REAL webEnvBody, a JWT that verifies,
|
|
9
9
|
// installed / not installed / cannot tell, device flow on / off / cannot tell, and the
|
|
10
10
|
// tick recording only what GitHub confirmed — never a secret in a log line.
|
|
11
|
-
// 3. the REAL projection (githubSetupRead) run into the REAL page
|
|
12
|
-
// the vm-slice idiom): each server state draws the
|
|
11
|
+
// 3. the REAL projection (githubSetupRead) run into the REAL page facts (projects.html,
|
|
12
|
+
// ghFacts, the vm-slice idiom; task 1004683): each server state draws the fact the founder should see.
|
|
13
13
|
// 4. the device-flow finding is written down where the next builder will look.
|
|
14
14
|
//
|
|
15
15
|
// Run: node --test tests/github_one_trip.mjs
|
|
@@ -185,38 +185,35 @@ test('the window query takes each project\'s LATEST attempt, past placement, sti
|
|
|
185
185
|
assert.match(fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'provision.js'), 'utf8'), /require\('\.\/provision-github-setup\.js'\)\.verifyGithubAppSetups\(deps\)/, 'it runs on every runner tick, after the conversions');
|
|
186
186
|
});
|
|
187
187
|
|
|
188
|
-
// ── 3. the real projection into the real
|
|
188
|
+
// ── 3. the real projection into the real page facts ─────────────────────────
|
|
189
|
+
// Since task 1004683 part 03 draws FACTS, not checklist markup: ghFacts() is pure over
|
|
190
|
+
// what the reads said, and paintFacts draws it from nodes (tests/setup_status_facts.mjs
|
|
191
|
+
// runs the whole route-to-DOM path). Here: the GitHub half, fed by the real projection.
|
|
189
192
|
|
|
190
|
-
const
|
|
191
|
-
const
|
|
193
|
+
const FS = SCRIPT.indexOf(' function ghStep(key) {');
|
|
194
|
+
const FE = SCRIPT.indexOf(' /* ── drawing it: nodes and textContent only', FS);
|
|
192
195
|
const SS = SCRIPT.indexOf(' /* the checklist the server derived (task 1004649)');
|
|
193
196
|
const SE = SCRIPT.indexOf(' function pollManifest(stateToken) {', SS);
|
|
194
|
-
const PS = SCRIPT.indexOf(' function stepsPaint() {');
|
|
195
|
-
const PE = SCRIPT.indexOf(' (function hopInput()', PS);
|
|
196
197
|
|
|
197
198
|
test('the regions are findable (a rename must fail loudly)', () => {
|
|
198
|
-
assert.ok(
|
|
199
|
+
assert.ok(FS > 0 && FE > FS && SS > 0 && SE > SS);
|
|
200
|
+
assert.match(SCRIPT.slice(FS, FE), /function ghFacts\(now\)/);
|
|
199
201
|
});
|
|
200
202
|
|
|
201
203
|
function page({ manifest, appSlug = null }) {
|
|
202
204
|
const sandbox = {
|
|
203
|
-
live: { manifest, manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug, setup: null, installUrl: null, instStatus: 'active', addrReady: true },
|
|
205
|
+
live: { manifest, manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug, appOrg: null, setup: null, installUrl: null, pickUp: null, verifying: false, askedPlatform: false, instStatus: 'active', addrReady: true, inst: null, readAt: 0, gone: '', repo: null },
|
|
204
206
|
state: { name: 'Mer<cury', slug: 'mercury', owner: 'acme', repo: 'mercury', step: 2 },
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
esc: (x) => String(x).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
208
|
-
GH_ICON: '', manualOauthDetails: () => '', projectPageLink: (t) => t, autoOpenTick: () => '',
|
|
209
|
-
progressMarkup: (a, b, note) => `<prog>${note}</prog>`, encodeURIComponent, JSON, Array,
|
|
207
|
+
resolvedDomain: () => 'mercury.example.com', apexBase: () => 'example.com', addressReady: () => true,
|
|
208
|
+
document: { hidden: false, hasFocus: () => true },
|
|
210
209
|
};
|
|
211
210
|
vm.createContext(sandbox);
|
|
212
|
-
vm.runInContext(`${SCRIPT.slice(
|
|
211
|
+
vm.runInContext(`${SCRIPT.slice(FS, FE)}\n${SCRIPT.slice(SS, SE)}`, sandbox);
|
|
213
212
|
return sandbox;
|
|
214
213
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
function cls(li) { return ((li.match(/^<li(?: class="([^"]*)")?/) || [])[1] || '').split(/\s+/).filter(Boolean); }
|
|
214
|
+
// vm-born values compared through JSON
|
|
215
|
+
const draw = (sb) => JSON.parse(JSON.stringify(vm.runInContext('ghFacts(Date.now())', sb)));
|
|
216
|
+
const states = (rows) => rows.map((r) => r.state);
|
|
220
217
|
|
|
221
218
|
async function serverSays(status, appSlug, eventRows) {
|
|
222
219
|
const db = { query: async () => ({ rows: eventRows }) };
|
|
@@ -224,55 +221,50 @@ async function serverSays(status, appSlug, eventRows) {
|
|
|
224
221
|
return GS.githubSetupRead(db, { target_ref: 'acme/mercury' }, row, status);
|
|
225
222
|
}
|
|
226
223
|
|
|
227
|
-
test('the mockup\'s four
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
assert.
|
|
232
|
-
assert.
|
|
233
|
-
assert.equal(
|
|
224
|
+
test('the mockup\'s four facts, in its words and order; the typed name is text, never markup', () => {
|
|
225
|
+
const rows = draw(page({ manifest: null }));
|
|
226
|
+
assert.deepEqual(rows.map((r) => r.title), ['Approve the Mer<cury app on GitHub', 'Install it on your repo', 'Builders can sign in', 'Command-line sign-in']);
|
|
227
|
+
assert.deepEqual(rows.map((r) => r.key), ['approve', 'install', 'sign_in', 'device_flow']);
|
|
228
|
+
assert.equal(rows[0].act, 'connect', 'one click starts the one trip, from inside the Approve fact');
|
|
229
|
+
assert.equal(rows[0].state, 'you', 'it is the founder\'s turn');
|
|
230
|
+
assert.equal(rows[3].opt, true, 'command-line sign-in is optional');
|
|
231
|
+
assert.doesNotMatch(SCRIPT.slice(FS, FE), /esc\(|innerHTML/, 'the facts carry text; the painter writes nodes');
|
|
234
232
|
});
|
|
235
233
|
|
|
236
|
-
test('each server state draws the
|
|
234
|
+
test('each server state draws the facts the founder should see — from the REAL projection', async () => {
|
|
237
235
|
// keys placed, nothing verified on GitHub yet
|
|
238
236
|
let sb = page({ manifest: 'converting', appSlug: 'mercury-app' });
|
|
239
237
|
let d = await serverSays('converting', 'mercury-app', []);
|
|
240
|
-
vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)})`, sb);
|
|
238
|
+
vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)}, ${JSON.stringify(d)})`, sb);
|
|
241
239
|
let rows = draw(sb);
|
|
242
|
-
assert.deepEqual(rows
|
|
243
|
-
assert.
|
|
244
|
-
assert.equal(
|
|
240
|
+
assert.deepEqual(states(rows), ['done', 'you', 'now', 'todo']);
|
|
241
|
+
assert.equal(rows[1].href, 'https://github.com/apps/mercury-app/installations/new?state=S1', 'the way back to the install, with the attempt');
|
|
242
|
+
assert.equal(rows[1].sub, 'acme/mercury', 'the repo the server says it installs on');
|
|
243
|
+
assert.equal(rows[1].right, 'not yet seen');
|
|
245
244
|
|
|
246
245
|
// sign-in verified, install still unconfirmed: NOT connected
|
|
247
246
|
sb = page({ manifest: 'done', appSlug: 'mercury-app' });
|
|
248
247
|
d = await serverSays('done', 'mercury-app', []);
|
|
249
|
-
vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)})`, sb);
|
|
248
|
+
vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)}, ${JSON.stringify(d)})`, sb);
|
|
250
249
|
rows = draw(sb);
|
|
251
|
-
assert.deepEqual(rows
|
|
252
|
-
assert.
|
|
250
|
+
assert.deepEqual(states(rows), ['done', 'you', 'done', 'todo']);
|
|
251
|
+
assert.equal(JSON.stringify(rows[3].sub), JSON.stringify(['Turn it on later in ', { t: 'the app settings', href: 'https://github.com/settings/apps/mercury-app' }, '.']));
|
|
253
252
|
assert.equal(vm.runInContext('setupOpen()', sb), true, 'the page keeps watching for the install');
|
|
254
253
|
|
|
255
254
|
// GitHub confirmed the install to the runner: connected, device flow still optional
|
|
256
255
|
sb = page({ manifest: 'done', appSlug: 'mercury-app' });
|
|
257
256
|
d = await serverSays('done', 'mercury-app', [{ event: 'github-app-installed', at: '2026-10-08T10:00:00Z' }]);
|
|
258
|
-
vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)})`, sb);
|
|
257
|
+
vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)}, ${JSON.stringify(d)})`, sb);
|
|
259
258
|
rows = draw(sb);
|
|
260
|
-
assert.deepEqual(rows
|
|
261
|
-
assert.match(rows[1], /The app is on acme\/mercury/);
|
|
259
|
+
assert.deepEqual(states(rows), ['done', 'done', 'done', 'todo']);
|
|
262
260
|
assert.equal(vm.runInContext('setupOpen()', sb), false);
|
|
263
|
-
|
|
264
|
-
const items = rows.map((li) => ({ classList: { contains: (c) => cls(li).includes(c) } }));
|
|
265
|
-
const ghState = { textContent: '' };
|
|
266
|
-
sb.$ = (id) => (id === 'humanSteps' ? { children: items } : id === 'ghState' ? ghState : null);
|
|
267
|
-
sb.manifestInFlight = () => false;
|
|
268
|
-
vm.runInContext('stepsPaint()', sb);
|
|
269
|
-
assert.equal(ghState.textContent, 'connected ✓');
|
|
261
|
+
assert.equal(vm.runInContext('setupCount([], ghFacts(Date.now()))', sb), 'ready ✓', 'the optional fact does not hold the count back');
|
|
270
262
|
|
|
271
263
|
// and once GitHub has device flow on, the fourth ticks too
|
|
272
264
|
sb = page({ manifest: 'done', appSlug: 'mercury-app' });
|
|
273
265
|
d = await serverSays('done', 'mercury-app', [{ event: 'github-app-installed', at: '2026-10-08T10:00:00Z' }, { event: 'github-app-device-flow', at: '2026-10-08T10:05:00Z' }]);
|
|
274
|
-
vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)})`, sb);
|
|
275
|
-
assert.
|
|
266
|
+
vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)}, ${JSON.stringify(d)})`, sb);
|
|
267
|
+
assert.equal(draw(sb)[3].state, 'done');
|
|
276
268
|
});
|
|
277
269
|
|
|
278
270
|
test('seedSetup is change-only, and an install link must be GitHub\'s', () => {
|
|
@@ -286,6 +278,12 @@ test('seedSetup is change-only, and an install link must be GitHub\'s', () => {
|
|
|
286
278
|
vm.runInContext(`seedSetup(${c}, 'javascript:alert(1)')`, sb2);
|
|
287
279
|
assert.equal(vm.runInContext('live.installUrl', sb2), null);
|
|
288
280
|
assert.equal(vm.runInContext('seedSetup(null, null)', sb2), false);
|
|
281
|
+
// the repo it installs on rides the read, strict
|
|
282
|
+
const sb3 = page({ manifest: 'done', appSlug: 'a' });
|
|
283
|
+
vm.runInContext(`seedSetup(${c}, null, { repo: 'acme/mer cury' })`, sb3);
|
|
284
|
+
assert.equal(vm.runInContext('live.repo', sb3), null, 'a malformed repo never reaches the page');
|
|
285
|
+
vm.runInContext(`seedSetup(${c}, null, { repo: 'acme/mercury' })`, sb3);
|
|
286
|
+
assert.equal(vm.runInContext('live.repo', sb3), 'acme/mercury');
|
|
289
287
|
});
|
|
290
288
|
|
|
291
289
|
// ── 4. the finding is written down ──────────────────────────────────────────
|
|
@@ -113,7 +113,7 @@ const PICKER = region(" /* ── whose GitHub account: mine or an organisati
|
|
|
113
113
|
const ENSURE_REPO = region(' function ensureFreshRepo() {', " $('doCreate').addEventListener('click'");
|
|
114
114
|
const NOTE_ORG = region(" /* the organisation the server says this attempt's app is on: change-only */", ' function startManifest(btn) {');
|
|
115
115
|
const START = region(' function startManifest(btn) {', ' /* the "start again" offer once a wait has clearly gone wrong');
|
|
116
|
-
const SETTINGS = region(' /* the organisation that owns the app, when the founder made it on one (task 1004651) */', ' /*
|
|
116
|
+
const SETTINGS = region(' /* the organisation that owns the app, when the founder made it on one (task 1004651) */', ' /* When a fact was seen, said plainly');
|
|
117
117
|
|
|
118
118
|
test('the page regions are still findable (a rename must fail loudly, not test nothing)', () => {
|
|
119
119
|
for (const [name, r] of Object.entries({ PICKER, ENSURE_REPO, NOTE_ORG, START, SETTINGS })) assert.ok(r, `${name} region not found`);
|
|
@@ -165,7 +165,7 @@ function page({ state = {}, live = {} } = {}) {
|
|
|
165
165
|
live: { manifest: null, manifestMsg: '', manifestFailure: null, appSlug: null, appOrg: null, setup: null, installUrl: null, ...live },
|
|
166
166
|
repoAccess: true, instanceId: 42, me: { github_login: 'founder' },
|
|
167
167
|
hopForRepoAccess: () => false, syncHome() {}, saveDraft() {},
|
|
168
|
-
rememberManifest() {},
|
|
168
|
+
rememberManifest() {}, paintFacts() {}, repaintFacts() {}, pollManifest() {}, watchGhTab() {},
|
|
169
169
|
};
|
|
170
170
|
sandbox.manifestStartedAt = 0;
|
|
171
171
|
vm.createContext(sandbox);
|