@bongos/core 1.19.375 → 1.19.377
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +94 -39
- 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 +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +76 -5
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +128 -127
- package/docs/copy-registry.json +175 -156
- package/migrations/core_227_task_created_by.sql +83 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/goals-lib.js +5 -5
- package/modules/hall-ui/public/goals-page.js +54 -23
- package/modules/hall-ui/public/goals-render.js +15 -0
- package/modules/hall-ui/public/task-lib.js +29 -0
- package/modules/hall-ui/public/work.js +117 -5
- package/modules/ideas/inbox.js +4 -0
- package/modules/lifecycle/db-tasks.js +61 -5
- package/modules/lifecycle/goal-rollup.js +118 -0
- package/modules/lifecycle/kickoff-checklist.js +138 -0
- package/modules/lifecycle/kickoff-seed.js +69 -0
- package/modules/lifecycle/module.json +4 -2
- package/modules/lifecycle/routes/goals.js +26 -0
- package/modules/lifecycle/routes/kickoff-seed.js +29 -0
- package/modules/lifecycle/routes/task-write-routes.js +3 -0
- package/modules/lifecycle/routes/tasks.js +47 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +1 -0
- package/scripts/gds/init.js +9 -79
- package/scripts/gds/test-path-guard.js +99 -0
- package/src/bongos/auth-admission.js +23 -0
- package/src/module-api.js +5 -1
- package/tests/first_admin_kickoff_seed.mjs +56 -0
- package/tests/goal_sort_staleness.mjs +286 -0
- package/tests/goal_subtitle.mjs +6 -1
- package/tests/goals_lib.mjs +16 -4
- package/tests/kickoff_checklist.mjs +107 -0
- package/tests/kickoff_seed.mjs +93 -0
- package/tests/provisioning_onboard_mode.mjs +3 -4
- package/tests/search_ingest.mjs +3 -2
- package/tests/setup_smoke.mjs +4 -2
- package/tests/test_path_guard.mjs +126 -0
package/tests/goals_lib.mjs
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
// tests/goals_lib.mjs — the Goals page's extracted pure helpers (goals-lib.js,
|
|
2
|
-
// task 1003135). Pins (a) that the lib loads
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// task 1003135). Pins (a) that the lib loads with no dependency on the page's
|
|
3
|
+
// IIFE state — the whole reason it could move out of the oversized
|
|
4
|
+
// goals-page.js — and (b) the behaviour of the helpers the page destructures.
|
|
5
|
+
//
|
|
6
|
+
// task 1003278: "no page state" is NOT "no siblings". The lib already read
|
|
7
|
+
// window.OTB.escapeHtml (subtitleAttr), and its activity clock now comes from
|
|
8
|
+
// window.OTBTaskLib, because the criterion legibility-sort-and-staleness needs
|
|
9
|
+
// ONE meaning of "touched" across both tiers — a synced pair of definitions is
|
|
10
|
+
// the thing it is trying to prevent. So the harness loads the sibling libs the
|
|
11
|
+
// way goals.html does (task-lib before goals-lib) and the property under test
|
|
12
|
+
// stays what it always was: no reach into page state.
|
|
6
13
|
//
|
|
7
14
|
// Run: node tests/goals_lib.mjs
|
|
8
15
|
|
|
@@ -15,17 +22,22 @@ import { makeRunner } from './helpers.mjs';
|
|
|
15
22
|
|
|
16
23
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
24
|
const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goals-lib.js'), 'utf8');
|
|
25
|
+
const TASK_LIB_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/task-lib.js'), 'utf8');
|
|
18
26
|
const { test, summary } = makeRunner();
|
|
19
27
|
|
|
20
28
|
const windowObj = {};
|
|
21
29
|
const sandbox = { window: windowObj, Set, Map, Math, Number, String, Array, Object, Date, JSON };
|
|
22
30
|
sandbox.globalThis = sandbox;
|
|
23
31
|
vm.createContext(sandbox);
|
|
32
|
+
// The REAL sibling, in load order — stubbing it would let the shared activity
|
|
33
|
+
// clock rot behind a fake that always agrees.
|
|
34
|
+
vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
|
|
24
35
|
vm.runInContext(SRC, sandbox, { filename: 'goals-lib.js' });
|
|
25
36
|
const lib = windowObj.OTBGoalsLib;
|
|
26
37
|
|
|
27
38
|
await test('loads standalone in a bare window (no page-state dependency) and exports the full surface', () => {
|
|
28
39
|
assert.ok(lib, 'window.OTBGoalsLib must be set');
|
|
40
|
+
assert.equal('state' in sandbox, false, 'the lib must not reach for page state');
|
|
29
41
|
for (const k of ['DONE_STATUSES', 'CUT_STATUSES', 'isTodoStatus', 'ring', 'memberName',
|
|
30
42
|
'readinessOf', 'EMPTY_ROLLUP', 'goalRollLine', 'matchesTodoSearch', 'msOf', 'taskActivityMs']) {
|
|
31
43
|
assert.ok(k in lib, `missing export: ${k}`);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// tests/kickoff_checklist.mjs — the shared kickoff checklist (task 1003239).
|
|
2
|
+
//
|
|
3
|
+
// One list, two filers: `bongos init` at scaffold time (self-host) and the lifecycle
|
|
4
|
+
// module at the founder's first sign-in (hub-provisioned, where init ran --no-seed).
|
|
5
|
+
// The first-sign-in shape must contain only what is still TRUE after a hub standup,
|
|
6
|
+
// and must never name a surface that does not exist — the task's own rule: a blank
|
|
7
|
+
// board beats untrue copy.
|
|
8
|
+
//
|
|
9
|
+
// Run: node --test tests/kickoff_checklist.mjs
|
|
10
|
+
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const { kickoffChecklist, productionTopologyKickoff } = require('../modules/lifecycle/kickoff-checklist.js');
|
|
17
|
+
const { VALID_KINDS, VALID_DISCIPLINES } = require('../modules/lifecycle/task-classifier.js');
|
|
18
|
+
|
|
19
|
+
const spec = () => ({
|
|
20
|
+
firstAdmin: 'founder', repo: { owner: 'o', name: 'n' },
|
|
21
|
+
domains: { publicOrigin: 'https://p.example' }, modules: [],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('default (self-host): the seven owner-manual steps, topology included', () => {
|
|
25
|
+
const t = kickoffChecklist(spec());
|
|
26
|
+
assert.equal(t.length, 7);
|
|
27
|
+
assert.ok(t.some((x) => /OAuth/i.test(x.title)), 'OAuth app step');
|
|
28
|
+
assert.ok(t.some((x) => /founding owner/i.test(x.title)), 'first sign-in step');
|
|
29
|
+
assert.ok(t.some((x) => /repo binding/i.test(x.title)), 'repo binding step');
|
|
30
|
+
assert.ok(t.some((x) => /Production topology/i.test(x.title)), 'topology step');
|
|
31
|
+
assert.ok(t.every((x) => x.title && x.description), 'every step has a title + description');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('provisioned: only the topology step is omitted', () => {
|
|
35
|
+
const t = kickoffChecklist(spec(), { provisioned: true });
|
|
36
|
+
assert.equal(t.length, 6);
|
|
37
|
+
assert.ok(!t.some((x) => /Production topology/i.test(x.title)));
|
|
38
|
+
assert.ok(t.some((x) => /OAuth/i.test(x.title)), 'the self-host owner still registers their OAuth app');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('firstSignIn: only what is still true after a hub standup', () => {
|
|
42
|
+
const t = kickoffChecklist(spec(), { firstSignIn: true });
|
|
43
|
+
assert.deepEqual(
|
|
44
|
+
t.map((x) => x.title),
|
|
45
|
+
[
|
|
46
|
+
'Decide how people join, and invite the first builders',
|
|
47
|
+
'Arm the merge gate before a second builder joins (ci deploy mode)',
|
|
48
|
+
'Fill the instance look with your own LLM',
|
|
49
|
+
],
|
|
50
|
+
);
|
|
51
|
+
for (const x of t) {
|
|
52
|
+
assert.ok(!/OAuth|founding owner|repo binding|topology/i.test(x.title + x.description),
|
|
53
|
+
`"${x.title}" names a step the standup already did`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('firstSignIn: the admission step points at surfaces that exist, never an env var', () => {
|
|
58
|
+
const join = kickoffChecklist(spec(), { firstSignIn: true })[0];
|
|
59
|
+
assert.ok(!/ENVPREFIX|OPEN_XENOS_ENROLLMENT|config\//.test(join.description), 'no env var or config file a hub founder cannot reach');
|
|
60
|
+
assert.match(join.description, /How people join/, 'the hub setting (task 1003044)');
|
|
61
|
+
assert.match(join.description, /manage page/, 'where the setting lives');
|
|
62
|
+
assert.match(join.description, /Watch page/, 'where invites live');
|
|
63
|
+
assert.match(join.description, /GitHub username/, 'how an invite is addressed');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('firstSignIn: per-module keys still follow the enabled modules', () => {
|
|
67
|
+
const t = kickoffChecklist({ ...spec(), modules: ['art-pipeline', 'discord'] }, { firstSignIn: true });
|
|
68
|
+
assert.equal(t.length, 5);
|
|
69
|
+
assert.ok(t.some((x) => /Gemini/i.test(x.title)));
|
|
70
|
+
assert.ok(t.some((x) => /Discord/i.test(x.title)));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('firstSignIn: never reads spec.repo or spec.firstAdmin (the steps that need them are omitted)', () => {
|
|
74
|
+
assert.doesNotThrow(() => kickoffChecklist({}, { firstSignIn: true }));
|
|
75
|
+
assert.equal(kickoffChecklist({}, { firstSignIn: true }).length, 3);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('every variant satisfies the DB CHECK vocabulary (tasks_kind_check / tasks_discipline_check)', () => {
|
|
79
|
+
const full = { ...spec(), modules: ['art-pipeline', 'discord', 'dev-box'] };
|
|
80
|
+
const all = [
|
|
81
|
+
...kickoffChecklist(full),
|
|
82
|
+
...kickoffChecklist(full, { provisioned: true }),
|
|
83
|
+
...kickoffChecklist(full, { firstSignIn: true }),
|
|
84
|
+
productionTopologyKickoff(),
|
|
85
|
+
];
|
|
86
|
+
for (const t of all) {
|
|
87
|
+
assert.ok(VALID_KINDS.has(t.kind), `"${t.title}": kind '${t.kind}' violates tasks_kind_check`);
|
|
88
|
+
if (t.discipline !== undefined) {
|
|
89
|
+
assert.ok(VALID_DISCIPLINES.has(t.discipline), `"${t.title}": discipline '${t.discipline}' violates tasks_discipline_check`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('the look step is a decision BY an ideator in every variant', () => {
|
|
95
|
+
for (const opts of [{}, { provisioned: true }, { firstSignIn: true }]) {
|
|
96
|
+
const look = kickoffChecklist(spec(), opts).find((t) => /instance look/i.test(t.title));
|
|
97
|
+
assert.ok(look, `look step present for ${JSON.stringify(opts)}`);
|
|
98
|
+
assert.equal(look.kind, 'decision');
|
|
99
|
+
assert.equal(look.discipline, 'ideator');
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('returns fresh objects — a filer mutating one cannot poison the next call', () => {
|
|
104
|
+
const a = kickoffChecklist(spec(), { firstSignIn: true });
|
|
105
|
+
a[0].title = 'mutated';
|
|
106
|
+
assert.notEqual(kickoffChecklist(spec(), { firstSignIn: true })[0].title, 'mutated');
|
|
107
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// tests/kickoff_seed.mjs — the lifecycle module files the kickoff checklist at the
|
|
2
|
+
// founder's first sign-in, and only onto an EMPTY board (task 1003239).
|
|
3
|
+
//
|
|
4
|
+
// The seed is idempotent by emptiness, not by path: the self-host path already filed
|
|
5
|
+
// its checklist before anyone signed in, so it must be skipped there without any
|
|
6
|
+
// "was I provisioned" marker. Every case runs against an in-memory pool + insert —
|
|
7
|
+
// no database.
|
|
8
|
+
//
|
|
9
|
+
// Run: node --test tests/kickoff_seed.mjs
|
|
10
|
+
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const { seedKickoffIfEmpty, specFromBranding } = require('../modules/lifecycle/kickoff-seed.js');
|
|
17
|
+
const buildRouter = require('../modules/lifecycle/routes/kickoff-seed.js');
|
|
18
|
+
const api = require('../src/module-api.js');
|
|
19
|
+
|
|
20
|
+
const spec = { firstAdmin: 'founder', repo: { owner: 'o', name: 'n' }, domains: { publicOrigin: 'https://p.example' }, modules: [] };
|
|
21
|
+
|
|
22
|
+
function fakePool({ taskCount, versions }) {
|
|
23
|
+
const queries = [];
|
|
24
|
+
return {
|
|
25
|
+
queries,
|
|
26
|
+
query: async (sql) => {
|
|
27
|
+
queries.push(sql);
|
|
28
|
+
if (/FROM tasks/.test(sql)) return { rows: [{ n: taskCount }] };
|
|
29
|
+
if (/FROM versions/.test(sql)) return { rows: versions };
|
|
30
|
+
throw new Error(`unexpected query: ${sql}`);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('an empty board gets the first-sign-in checklist, as ready tasks attributed to the founder', async () => {
|
|
36
|
+
const inserted = [];
|
|
37
|
+
const pool = fakePool({ taskCount: 0, versions: [{ id: 'V1' }] });
|
|
38
|
+
const r = await seedKickoffIfEmpty({ builderId: 7 }, { pool, spec, createTask: async (row) => { inserted.push(row); return row; } });
|
|
39
|
+
assert.deepEqual(r, { seeded: 3, versionId: 'V1' });
|
|
40
|
+
assert.equal(inserted.length, 3);
|
|
41
|
+
for (const row of inserted) {
|
|
42
|
+
assert.equal(row.versionId, 'V1');
|
|
43
|
+
assert.equal(row.status, 'ready', 'claimable from the first board, not backlog');
|
|
44
|
+
assert.equal(row.source, 'first-sign-in');
|
|
45
|
+
assert.equal(row.createdBy, 7);
|
|
46
|
+
assert.ok(row.title && row.description);
|
|
47
|
+
}
|
|
48
|
+
assert.match(inserted[0].title, /how people join/i);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a board that already has tasks is left alone (the self-host path seeded at init)', async () => {
|
|
52
|
+
const inserted = [];
|
|
53
|
+
const pool = fakePool({ taskCount: 12, versions: [{ id: 'V1' }] });
|
|
54
|
+
const r = await seedKickoffIfEmpty({ builderId: 7 }, { pool, spec, createTask: async (row) => inserted.push(row) });
|
|
55
|
+
assert.deepEqual(r, { seeded: 0, skipped: 'tasks_exist' });
|
|
56
|
+
assert.equal(inserted.length, 0);
|
|
57
|
+
assert.ok(!pool.queries.some((q) => /FROM versions/.test(q)), 'never even looks for a version');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('no version to file under → skipped, nothing inserted', async () => {
|
|
61
|
+
const inserted = [];
|
|
62
|
+
const pool = fakePool({ taskCount: 0, versions: [] });
|
|
63
|
+
const r = await seedKickoffIfEmpty({ builderId: 7 }, { pool, spec, createTask: async (row) => inserted.push(row) });
|
|
64
|
+
assert.deepEqual(r, { seeded: 0, skipped: 'no_version' });
|
|
65
|
+
assert.equal(inserted.length, 0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('the version query prefers the building one, then the first by id', async () => {
|
|
69
|
+
const pool = fakePool({ taskCount: 0, versions: [{ id: 'V1' }] });
|
|
70
|
+
await seedKickoffIfEmpty({ builderId: 1 }, { pool, spec, createTask: async () => {} });
|
|
71
|
+
const q = pool.queries.find((s) => /FROM versions/.test(s));
|
|
72
|
+
assert.match(q, /status = 'building'/);
|
|
73
|
+
assert.match(q, /LIMIT 1/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('specFromBranding reads the instance pack and keeps modules empty', () => {
|
|
77
|
+
const s = specFromBranding({ firstAdmin: 'f', repo: { owner: 'a', name: 'b' }, domains: { publicOrigin: 'https://x' } });
|
|
78
|
+
assert.deepEqual(s, { firstAdmin: 'f', repo: { owner: 'a', name: 'b' }, domains: { publicOrigin: 'https://x' }, modules: [] });
|
|
79
|
+
assert.deepEqual(specFromBranding({}).repo, {});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the route factory registers the port once, returns an empty router, and the port delegates', async () => {
|
|
83
|
+
const before = api.hasProvider('lifecycle.kickoffSeed');
|
|
84
|
+
const router = buildRouter();
|
|
85
|
+
buildRouter(); // a rebuild must not trip the single-provider guard
|
|
86
|
+
assert.ok(api.hasProvider('lifecycle.kickoffSeed'));
|
|
87
|
+
assert.equal(typeof router, 'function');
|
|
88
|
+
assert.equal(router.stack.length, 0, 'seeding has no HTTP surface');
|
|
89
|
+
const port = api.resolveOptional('lifecycle.kickoffSeed');
|
|
90
|
+
const r = await port.seedIfEmpty({ builderId: 3 }, { pool: fakePool({ taskCount: 1, versions: [] }), spec });
|
|
91
|
+
assert.deepEqual(r, { seeded: 0, skipped: 'tasks_exist' });
|
|
92
|
+
assert.equal(before, false, 'this test registered it, nothing else in-process had');
|
|
93
|
+
});
|
|
@@ -21,9 +21,8 @@ import { createRequire } from 'node:module';
|
|
|
21
21
|
import http from 'node:http';
|
|
22
22
|
|
|
23
23
|
const require = createRequire(import.meta.url);
|
|
24
|
-
const ROOT = new URL('..', import.meta.url).pathname;
|
|
25
24
|
|
|
26
|
-
const provisioning = require(
|
|
25
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
27
26
|
|
|
28
27
|
let passed = 0, failed = 0;
|
|
29
28
|
function t(name, fn) {
|
|
@@ -99,11 +98,11 @@ provisioning.enqueueIntent = async () => ({ intent: { id: 1, state: 'pending' },
|
|
|
99
98
|
provisioning.recordEvent = async () => {};
|
|
100
99
|
provisioning.getInstanceBySlug = async () => null;
|
|
101
100
|
|
|
102
|
-
const api = require(
|
|
101
|
+
const api = require('../src/module-api.js');
|
|
103
102
|
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
104
103
|
|
|
105
104
|
const express = require('express');
|
|
106
|
-
const provisioningRoutes = require(
|
|
105
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
107
106
|
const app = express();
|
|
108
107
|
app.use(express.json());
|
|
109
108
|
app.use((_req, res, next) => {
|
package/tests/search_ingest.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { strict as assert } from 'node:assert';
|
|
13
13
|
import { createRequire } from 'node:module';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
14
15
|
|
|
15
16
|
const require = createRequire(import.meta.url);
|
|
16
17
|
const ingest = require('../scripts/gds/search-ingest-md.js');
|
|
@@ -61,7 +62,7 @@ test('SENSITIVE_DOC_GLOBS is the curated source of truth (subtrees end with /)',
|
|
|
61
62
|
|
|
62
63
|
test('collectMarkdown finds the repo corpus but excludes node_modules/.git/worktrees/public', () => {
|
|
63
64
|
const path = require('node:path');
|
|
64
|
-
const repoRoot = path.resolve(new URL('..', import.meta.url)
|
|
65
|
+
const repoRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
65
66
|
const real = collectMarkdown(repoRoot, repoRoot);
|
|
66
67
|
// Assert on paths RELATIVE to the repo root — the suite may run from inside a
|
|
67
68
|
// git worktree (…/.claude/worktrees/<name>/…), so the ABSOLUTE paths would all
|
|
@@ -77,7 +78,7 @@ test('collectMarkdown finds the repo corpus but excludes node_modules/.git/workt
|
|
|
77
78
|
|
|
78
79
|
test('chunkFile yields well-formed rows (source_kind doc, hash, rank, sequential index)', () => {
|
|
79
80
|
const path = require('node:path');
|
|
80
|
-
const repoRoot = path.resolve(new URL('..', import.meta.url)
|
|
81
|
+
const repoRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
81
82
|
const rows = chunkFile(path.join(repoRoot, 'docs', 'adr', '0016-trust-boundary-server-enforced-permissions.md'));
|
|
82
83
|
assert.ok(rows.length > 0);
|
|
83
84
|
rows.forEach((r, i) => {
|
package/tests/setup_smoke.mjs
CHANGED
|
@@ -22,8 +22,9 @@ import { spawnSync } from 'node:child_process';
|
|
|
22
22
|
import fs from 'node:fs';
|
|
23
23
|
import os from 'node:os';
|
|
24
24
|
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
25
26
|
|
|
26
|
-
const REPO_ROOT = path.resolve(path.dirname(
|
|
27
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
28
|
const SETUP_JS = path.join(REPO_ROOT, 'scripts/gds/setup.js');
|
|
28
29
|
|
|
29
30
|
const tests = [];
|
|
@@ -36,7 +37,8 @@ function makeFreshHome() {
|
|
|
36
37
|
|
|
37
38
|
function runSetup(args, env = {}) {
|
|
38
39
|
return spawnSync('node', [SETUP_JS, ...args], {
|
|
39
|
-
|
|
40
|
+
// os.homedir() reads USERPROFILE on Windows, so a HOME override alone leaks the real home there.
|
|
41
|
+
env: { ...process.env, ...env, ...(env.HOME ? { USERPROFILE: env.HOME } : {}) },
|
|
40
42
|
encoding: 'utf8',
|
|
41
43
|
timeout: 15000,
|
|
42
44
|
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// tests/test_path_guard.mjs — the guard that keeps a test runnable on a checkout
|
|
2
|
+
// whose path has a space in it (task 1002889).
|
|
3
|
+
//
|
|
4
|
+
// Four tests built their repo root from URL.pathname and threw MODULE_NOT_FOUND at
|
|
5
|
+
// load on the owner's Windows checkout, while CI (no space in its path) stayed
|
|
6
|
+
// green. The guard is tested on synthetic sources, not only the live tree: a scan
|
|
7
|
+
// that passes because the tree is clean today proves nothing about what it REFUSES.
|
|
8
|
+
//
|
|
9
|
+
// The defective form is spelled by concatenation below so this file never trips the
|
|
10
|
+
// guard it tests.
|
|
11
|
+
//
|
|
12
|
+
// Of the four files the task named, tests/provisioning_github_app_routes.mjs had
|
|
13
|
+
// already been converted before this landed (915d273b, task 1003236 — fileURLToPath
|
|
14
|
+
// at its line 17), so it is absent from this change on purpose; the live-tree case
|
|
15
|
+
// at the bottom is what proves all four are clean.
|
|
16
|
+
//
|
|
17
|
+
// Run: node tests/test_path_guard.mjs
|
|
18
|
+
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const { checkTestsUseFileUrlToPath, scanTests } = require('../scripts/gds/test-path-guard.js');
|
|
24
|
+
|
|
25
|
+
let passed = 0;
|
|
26
|
+
let failed = 0;
|
|
27
|
+
function test(name, fn) {
|
|
28
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
29
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const from = (map) => ({
|
|
33
|
+
files: Object.keys(map),
|
|
34
|
+
read: (rel) => (rel in map ? map[rel] : null),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const BAD_TAIL = 'import.meta.url)' + '.pathname';
|
|
38
|
+
|
|
39
|
+
// --- THE DEFECT ------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
test('the shipped defect is refused, with its line and the fix', () => {
|
|
42
|
+
const { violations } = scanTests(...Object.values(from({
|
|
43
|
+
'tests/bad.mjs':
|
|
44
|
+
"import { createRequire } from 'node:module';\n"
|
|
45
|
+
+ `const ROOT = new URL('..', ${BAD_TAIL};\n`
|
|
46
|
+
+ 'const mod = require(`${ROOT}/modules/x.js`);\n',
|
|
47
|
+
})));
|
|
48
|
+
assert.equal(violations.length, 1);
|
|
49
|
+
assert.match(violations[0], /bad\.mjs:2/, 'names the file and line');
|
|
50
|
+
assert.match(violations[0], /%20/, 'says WHY it breaks');
|
|
51
|
+
assert.match(violations[0], /fileURLToPath/, 'and what to write instead');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('the dirname form is the same defect', () => {
|
|
55
|
+
const { violations } = scanTests(...Object.values(from({
|
|
56
|
+
'tests/smoke.mjs': `const REPO_ROOT = path.resolve(path.dirname(new URL(${BAD_TAIL}), '..');\n`,
|
|
57
|
+
})));
|
|
58
|
+
assert.equal(violations.length, 1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('every site in a file is reported, not just the first', () => {
|
|
62
|
+
const { violations } = scanTests(...Object.values(from({
|
|
63
|
+
'tests/two.mjs':
|
|
64
|
+
`const a = path.resolve(new URL('..', ${BAD_TAIL});\n`
|
|
65
|
+
+ 'x();\n'
|
|
66
|
+
+ `const b = path.resolve(new URL('..', ${BAD_TAIL});\n`,
|
|
67
|
+
})));
|
|
68
|
+
assert.equal(violations.length, 2);
|
|
69
|
+
assert.match(violations[1], /two\.mjs:3/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// --- WHAT IT MUST NOT FLAG -------------------------------------------------
|
|
73
|
+
|
|
74
|
+
test('fileURLToPath and a relative createRequire are the correct forms — never flagged', () => {
|
|
75
|
+
const { violations } = scanTests(...Object.values(from({
|
|
76
|
+
'tests/good_a.mjs':
|
|
77
|
+
"import { fileURLToPath } from 'node:url';\n"
|
|
78
|
+
+ "const ROOT = fileURLToPath(new URL('..', import.meta.url));\n",
|
|
79
|
+
'tests/good_b.mjs':
|
|
80
|
+
"const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');\n",
|
|
81
|
+
'tests/good_c.mjs':
|
|
82
|
+
"const require = createRequire(import.meta.url);\n"
|
|
83
|
+
+ "const mod = require('../modules/x.js');\n",
|
|
84
|
+
})));
|
|
85
|
+
assert.deepEqual(violations, []);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a request URL parsed for its pathname is not the defect', () => {
|
|
89
|
+
// http tests routinely do this; the guard is about import.meta.url only.
|
|
90
|
+
const { violations } = scanTests(...Object.values(from({
|
|
91
|
+
'tests/routes.mjs': "const { pathname } = new URL(req.url, 'http://x');\nconst p = new URL(req.url, base).pathname;\n",
|
|
92
|
+
})));
|
|
93
|
+
assert.deepEqual(violations, []);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// --- THE SCAN MUST NOT SILENTLY SUCCEED ------------------------------------
|
|
97
|
+
|
|
98
|
+
test('an empty enumeration is a hard FAIL, not a clean pass', () => {
|
|
99
|
+
const r = checkTestsUseFileUrlToPath({ files: [], read: () => null });
|
|
100
|
+
assert.equal(r.hardFail, true);
|
|
101
|
+
assert.match(r.violations[0], /scan defect/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('an unreadable file is skipped without being counted as scanned', () => {
|
|
105
|
+
const { scanned } = scanTests(['gone.mjs'], () => null);
|
|
106
|
+
assert.equal(scanned, 0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('the check reports hardFail so CI actually stops', () => {
|
|
110
|
+
const r = checkTestsUseFileUrlToPath(from({
|
|
111
|
+
'tests/bad.mjs': `const ROOT = new URL('..', ${BAD_TAIL};\n`,
|
|
112
|
+
}));
|
|
113
|
+
assert.equal(r.ok, false);
|
|
114
|
+
assert.equal(r.hardFail, true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// --- THE LIVE TREE ---------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
test('the tracked tests/ tree is clean (the four sites of task 1002889 are fixed)', () => {
|
|
120
|
+
const r = checkTestsUseFileUrlToPath();
|
|
121
|
+
assert.equal(r.hardFail, false, r.violations.join('\n'));
|
|
122
|
+
assert.ok(/^\d+ test file\(s\) scanned/.test(r.note) && !r.note.startsWith('0 '), r.note);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
console.log(`\ntest_path_guard: ${passed} passed, ${failed} failed`);
|
|
126
|
+
process.exit(failed ? 1 : 0);
|