@bongos/core 1.21.125 → 1.21.126
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 +70 -40
- package/docs/api/openapi.json +4 -4
- package/docs/copy-inventory.md +128 -126
- package/docs/copy-registry.json +188 -170
- package/docs/module-api-changelog.md +3 -1
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +152 -143
- package/modules/hall-ui/public/goal-forest-lib.js +36 -0
- package/modules/hall-ui/public/goal-forest-render.js +50 -10
- package/modules/hall-ui/public/goals-page.js +19 -22
- package/modules/hall-ui/public/goals-render.js +6 -16
- package/modules/hall-ui/public/goals.css +13 -4
- package/modules/hall-ui/public/goals.html +22 -24
- package/modules/hall-ui/public/goals.states.json +19 -1
- package/modules/hall-ui/records/goal-dependency-forest.md +4 -3
- package/modules/platform-identity/invite-authz.js +1 -1
- package/modules/provisioning/catalog-bridge.js +1 -1
- package/modules/provisioning/provisioning.js +1 -1
- package/modules/provisioning/route-helpers.js +50 -0
- package/modules/provisioning/routes/disconnect.js +6 -5
- package/modules/provisioning/routes/env-manifest.js +11 -27
- package/modules/provisioning/routes/provisioning.js +58 -75
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +14 -0
- package/scripts/gds/module-assess-tests.js +40 -11
- package/scripts/gds/module-assess-version.js +10 -2
- package/scripts/gds/module-test-runner/README.md +44 -0
- package/scripts/gds/module-test-runner/module-tests.yml +87 -0
- package/scripts/gds/module-test-runner.js +358 -0
- package/src/module-api.js +1 -1
- package/tests/goal_forest_lib.mjs +28 -0
- package/tests/goal_forest_page.mjs +106 -11
- package/tests/module_assess_price_parity.mjs +2 -2
- package/tests/module_assess_score.mjs +13 -3
- package/tests/module_test_runner.mjs +294 -0
- package/tests/project_catalog_routes.mjs +1 -1
- package/tests/provisioning_module_selection.mjs +1 -1
- package/tests/provisioning_project_owner_gate.mjs +271 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# The separate testing environment for a published module's own tests (task 1004460).
|
|
2
|
+
#
|
|
3
|
+
# THIS FILE DOES NOT RUN IN THE CORE REPO. Copy it to .github/workflows/module-tests.yml
|
|
4
|
+
# in the owner's separate PRIVATE test repo (see README.md beside it). The control
|
|
5
|
+
# plane (scripts/gds/module-test-runner.js) dispatches it and pulls the result back;
|
|
6
|
+
# nothing here ever holds a DB credential or a control-plane secret.
|
|
7
|
+
#
|
|
8
|
+
# Two jobs on purpose: `fetch` can read the repo (where the tarball waits on a
|
|
9
|
+
# throwaway branch) but runs no module code; `test` runs the module's code with a
|
|
10
|
+
# GITHUB_TOKEN that has NO permissions, so a module that scrapes its token still
|
|
11
|
+
# cannot read another run's tarball. Inside `test`, the module's code runs as an
|
|
12
|
+
# unprivileged user with its network cut (see "Cut the network for module code").
|
|
13
|
+
name: module-tests
|
|
14
|
+
run-name: module-tests ${{ inputs.version_id }} ${{ inputs.nonce }}
|
|
15
|
+
|
|
16
|
+
on:
|
|
17
|
+
workflow_dispatch:
|
|
18
|
+
inputs:
|
|
19
|
+
version_id: { description: store_module_versions.id, required: true, type: string }
|
|
20
|
+
module_key: { description: the module's key, required: true, type: string }
|
|
21
|
+
nonce: { description: this run's nonce (32 hex), required: true, type: string }
|
|
22
|
+
core_repo: { description: owner/name of the public core, required: true, type: string }
|
|
23
|
+
core_ref: { description: the core commit to test against, required: true, type: string }
|
|
24
|
+
|
|
25
|
+
permissions: {}
|
|
26
|
+
|
|
27
|
+
jobs:
|
|
28
|
+
fetch:
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
permissions: { contents: read }
|
|
31
|
+
timeout-minutes: 5
|
|
32
|
+
steps:
|
|
33
|
+
- name: Check the inputs
|
|
34
|
+
env: { NONCE: "${{ inputs.nonce }}", VERSION_ID: "${{ inputs.version_id }}" }
|
|
35
|
+
run: |
|
|
36
|
+
[[ "$NONCE" =~ ^[0-9a-f]{32}$ ]] || { echo "bad nonce"; exit 1; }
|
|
37
|
+
[[ "$VERSION_ID" =~ ^[0-9]+$ ]] || { echo "bad version id"; exit 1; }
|
|
38
|
+
- uses: actions/checkout@v4
|
|
39
|
+
with: { ref: "module-test/${{ inputs.nonce }}", path: tarball, persist-credentials: false }
|
|
40
|
+
- uses: actions/upload-artifact@v4
|
|
41
|
+
with: { name: "tarball-${{ inputs.nonce }}", path: tarball/module.tgz, retention-days: 1 }
|
|
42
|
+
|
|
43
|
+
test:
|
|
44
|
+
needs: fetch
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
permissions: {}
|
|
47
|
+
timeout-minutes: 30
|
|
48
|
+
steps:
|
|
49
|
+
- uses: actions/checkout@v4
|
|
50
|
+
with: { repository: "${{ inputs.core_repo }}", ref: "${{ inputs.core_ref }}", path: core, persist-credentials: false }
|
|
51
|
+
- uses: actions/setup-node@v4
|
|
52
|
+
with: { node-version: 22 }
|
|
53
|
+
- uses: actions/download-artifact@v4
|
|
54
|
+
with: { name: "tarball-${{ inputs.nonce }}", path: in }
|
|
55
|
+
- name: Install the core
|
|
56
|
+
working-directory: core
|
|
57
|
+
run: npm ci --no-audit --no-fund
|
|
58
|
+
# Module code never runs as `runner` (which has passwordless sudo and the
|
|
59
|
+
# runner's own files): it runs as `moduletest`, a fresh user with no sudo, and
|
|
60
|
+
# the firewall rejects every packet that user sends except on loopback. So a
|
|
61
|
+
# module's tests get no network egress, and cannot lift the block themselves.
|
|
62
|
+
# The install above was the last step that needed the network.
|
|
63
|
+
- name: Cut the network for module code
|
|
64
|
+
run: |
|
|
65
|
+
sudo useradd --create-home --shell /bin/bash moduletest
|
|
66
|
+
sudo mv core in /home/moduletest/
|
|
67
|
+
sudo chown -R moduletest:moduletest /home/moduletest
|
|
68
|
+
for t in iptables ip6tables; do
|
|
69
|
+
sudo "$t" -A OUTPUT -m owner --uid-owner moduletest -o lo -j ACCEPT
|
|
70
|
+
sudo "$t" -A OUTPUT -m owner --uid-owner moduletest -j REJECT
|
|
71
|
+
done
|
|
72
|
+
if sudo -u moduletest -H curl -sS --max-time 5 https://github.com >/dev/null 2>&1; then
|
|
73
|
+
echo "the network block did not hold"; exit 1
|
|
74
|
+
fi
|
|
75
|
+
- name: Run the module's own tests
|
|
76
|
+
env:
|
|
77
|
+
VERSION_ID: "${{ inputs.version_id }}"
|
|
78
|
+
MODULE_KEY: "${{ inputs.module_key }}"
|
|
79
|
+
NONCE: "${{ inputs.nonce }}"
|
|
80
|
+
run: |
|
|
81
|
+
mkdir -p out
|
|
82
|
+
NODE_DIR="$(dirname "$(command -v node)")"
|
|
83
|
+
sudo -u moduletest -H env -i PATH="$NODE_DIR:/usr/bin:/bin" HOME=/home/moduletest MODULE_TEST_SANDBOX=1 \
|
|
84
|
+
bash -c 'cd /home/moduletest/core && exec node scripts/gds/module-assess-tests.js --tarball ../in/module.tgz --key "$1" --version-id "$2" --nonce "$3" --json' \
|
|
85
|
+
_ "$MODULE_KEY" "$VERSION_ID" "$NONCE" > out/signal.json
|
|
86
|
+
- uses: actions/upload-artifact@v4
|
|
87
|
+
with: { name: "module-tests-${{ inputs.version_id }}-${{ inputs.nonce }}", path: out/signal.json, retention-days: 3 }
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/module-test-runner.js — the separate testing environment for a
|
|
3
|
+
// published module's OWN tests (task 1004460; ADR 0343). The owner chose GitHub's
|
|
4
|
+
// free runners (2026-10-14): no new server, nothing bought.
|
|
5
|
+
//
|
|
6
|
+
// WHY A SEPARATE PRIVATE REPO. A module's tests are third-party code, so they never
|
|
7
|
+
// run on the control plane (module-assess-tests.js refuses outside
|
|
8
|
+
// MODULE_TEST_SANDBOX=1), and never in this repo's own Actions either (.github/ is
|
|
9
|
+
// protected substrate, and its runs can reach this repo's secrets). They run in a
|
|
10
|
+
// separate PRIVATE repo the owner creates, holding only the workflow in
|
|
11
|
+
// scripts/gds/module-test-runner/ — private so run logs and inputs stay non-public.
|
|
12
|
+
//
|
|
13
|
+
// THE ROUND TRIP. Nothing on the runner ever holds a DB credential:
|
|
14
|
+
// 1. dispatch — the control plane pushes the version's tarball to a throwaway
|
|
15
|
+
// orphan branch `module-test/<nonce>` in the test repo and dispatches the
|
|
16
|
+
// workflow with { version_id, module_key, nonce, core_repo, core_ref }. The
|
|
17
|
+
// nonce goes into the pending Tests row's detail.runner.
|
|
18
|
+
// 2. the run — a `fetch` job (contents: read, no module code) copies the tarball
|
|
19
|
+
// into the run as an artifact; a separate `test` job (permissions: {}) checks
|
|
20
|
+
// out the core at core_ref, runs module-assess-tests.js --tarball with
|
|
21
|
+
// MODULE_TEST_SANDBOX=1, and uploads the signal JSON as artifact
|
|
22
|
+
// module-tests-<version_id>-<nonce>.
|
|
23
|
+
// 3. collect — the control plane PULLS: it finds the run whose run-name carries
|
|
24
|
+
// the pending row's nonce, takes only that run's artifact of that exact name,
|
|
25
|
+
// checks the payload names the same version and nonce, inserts the signal row
|
|
26
|
+
// itself, recomposes the score, and deletes the branch.
|
|
27
|
+
//
|
|
28
|
+
// WHY A PER-RUN BRANCH, NOT A DOWNLOAD TICKET. A ticket would mean a new
|
|
29
|
+
// unauthenticated route on the control plane (and a change to the protected
|
|
30
|
+
// route-rank-check), i.e. a fresh inbound path to the server holding the secrets.
|
|
31
|
+
// The branch keeps every byte flowing OUT from the control plane. The cost: a paid
|
|
32
|
+
// module's tarball sits in the private test repo for the length of one run. It is
|
|
33
|
+
// safe from OTHER modules' runs because the only job that can read the repo's
|
|
34
|
+
// contents (`fetch`) runs no module code, and the job that does (`test`) gets a
|
|
35
|
+
// GITHUB_TOKEN with no permissions — so a module that scrapes its runner's token
|
|
36
|
+
// still cannot read another run's branch. A module author can always make their own
|
|
37
|
+
// tests pass, so the score is never trust-critical; what this protects is the
|
|
38
|
+
// secrets and other authors' code.
|
|
39
|
+
//
|
|
40
|
+
// Configured by three control-plane env vars (all unset = off; Tests then stays
|
|
41
|
+
// `pending`, exactly as before this task):
|
|
42
|
+
// MODULE_TEST_REPO owner/name of the private test repo
|
|
43
|
+
// MODULE_TEST_TOKEN a fine-grained token scoped to ONLY that repo, with
|
|
44
|
+
// Actions: read & write and Contents: read & write
|
|
45
|
+
// MODULE_TEST_CORE_REPO owner/name of the public core the runner checks out
|
|
46
|
+
// (default: this checkout's `origin`)
|
|
47
|
+
// MODULE_TEST_REF the test repo branch holding the workflow (default main)
|
|
48
|
+
//
|
|
49
|
+
// node scripts/gds/module-test-runner.js dispatch <store_module_versions.id>
|
|
50
|
+
// node scripts/gds/module-test-runner.js collect
|
|
51
|
+
|
|
52
|
+
const fs = require('node:fs');
|
|
53
|
+
const crypto = require('node:crypto');
|
|
54
|
+
const zlib = require('node:zlib');
|
|
55
|
+
const { execFileSync } = require('node:child_process');
|
|
56
|
+
|
|
57
|
+
const WORKFLOW_FILE = 'module-tests.yml';
|
|
58
|
+
const BRANCH_PREFIX = 'module-test/';
|
|
59
|
+
const API = 'https://api.github.com';
|
|
60
|
+
// A run that has not finished this long after dispatch is recorded not_scored.
|
|
61
|
+
const MAX_RUN_AGE_MS = 6 * 60 * 60_000;
|
|
62
|
+
const MAX_SIGNAL_BYTES = 256 * 1024;
|
|
63
|
+
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
64
|
+
const NONCE_RE = /^[0-9a-f]{32}$/;
|
|
65
|
+
|
|
66
|
+
const runName = (versionId, nonce) => `module-tests ${versionId} ${nonce}`;
|
|
67
|
+
const artifactName = (versionId, nonce) => `module-tests-${versionId}-${nonce}`;
|
|
68
|
+
|
|
69
|
+
function originRepo() {
|
|
70
|
+
try {
|
|
71
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: __dirname, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
72
|
+
const m = url.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
73
|
+
return m ? m[1] : null;
|
|
74
|
+
} catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The runner config, or null when it is not set up (the default: Tests stays pending).
|
|
78
|
+
function runnerConfig(env = process.env, { origin = originRepo } = {}) {
|
|
79
|
+
const repo = (env.MODULE_TEST_REPO || '').trim();
|
|
80
|
+
const token = (env.MODULE_TEST_TOKEN || '').trim();
|
|
81
|
+
if (!repo || !token) return null;
|
|
82
|
+
if (!REPO_RE.test(repo)) throw new Error(`MODULE_TEST_REPO must be owner/name, got "${repo}"`);
|
|
83
|
+
const coreRepo = (env.MODULE_TEST_CORE_REPO || '').trim() || origin();
|
|
84
|
+
if (!coreRepo || !REPO_RE.test(coreRepo)) throw new Error('MODULE_TEST_CORE_REPO is unset and the core repo could not be read from git origin');
|
|
85
|
+
const ref = (env.MODULE_TEST_REF || '').trim() || 'main';
|
|
86
|
+
return { repo, token, coreRepo, ref };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// One GitHub REST call. Resolves the parsed body (or null for 204); throws with
|
|
90
|
+
// .status on a non-2xx.
|
|
91
|
+
async function gh(cfg, method, urlPath, body, { fetchImpl = fetch } = {}) {
|
|
92
|
+
const res = await fetchImpl(urlPath.startsWith('https://') ? urlPath : `${API}${urlPath}`, {
|
|
93
|
+
method,
|
|
94
|
+
headers: { Authorization: `Bearer ${cfg.token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'bongos-module-test-runner', ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
|
95
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
const text = await res.text().catch(() => '');
|
|
99
|
+
throw Object.assign(new Error(`GitHub ${method} ${urlPath} → ${res.status} ${text.slice(0, 200)}`), { status: res.status });
|
|
100
|
+
}
|
|
101
|
+
return res.status === 204 ? null : res.json();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The commit the control plane is running, so the runner tests against the same core.
|
|
105
|
+
function currentCoreRef() {
|
|
106
|
+
const { buildInfo } = require('../../src/build-info');
|
|
107
|
+
return buildInfo().commit || 'main';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readVersionTarball(ver) {
|
|
111
|
+
const { versionArtifactFile } = require('../../src/bongos/module-store');
|
|
112
|
+
return fs.promises.readFile(versionArtifactFile(ver));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Push the tarball to a throwaway orphan branch and dispatch the workflow.
|
|
116
|
+
// Resolves the runner record kept in the pending row: { nonce, branch, dispatched_at }.
|
|
117
|
+
async function dispatchRun(ver, { cfg, fetchImpl, readTarball = readVersionTarball, coreRef = currentCoreRef, now = () => new Date() } = {}) {
|
|
118
|
+
const tgz = await readTarball(ver);
|
|
119
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
120
|
+
const branch = `${BRANCH_PREFIX}${nonce}`;
|
|
121
|
+
const r = `/repos/${cfg.repo}`;
|
|
122
|
+
const blob = await gh(cfg, 'POST', `${r}/git/blobs`, { content: tgz.toString('base64'), encoding: 'base64' }, { fetchImpl });
|
|
123
|
+
const tree = await gh(cfg, 'POST', `${r}/git/trees`, { tree: [{ path: 'module.tgz', mode: '100644', type: 'blob', sha: blob.sha }] }, { fetchImpl });
|
|
124
|
+
const commit = await gh(cfg, 'POST', `${r}/git/commits`, { message: `module tests: version ${ver.id}`, tree: tree.sha, parents: [] }, { fetchImpl });
|
|
125
|
+
await gh(cfg, 'POST', `${r}/git/refs`, { ref: `refs/heads/${branch}`, sha: commit.sha }, { fetchImpl });
|
|
126
|
+
try {
|
|
127
|
+
await gh(cfg, 'POST', `${r}/actions/workflows/${WORKFLOW_FILE}/dispatches`, {
|
|
128
|
+
ref: cfg.ref || 'main',
|
|
129
|
+
inputs: { version_id: String(ver.id), module_key: ver.module_key, nonce, core_repo: cfg.coreRepo, core_ref: coreRef() },
|
|
130
|
+
}, { fetchImpl });
|
|
131
|
+
} catch (e) {
|
|
132
|
+
await deleteBranch(cfg, branch, { fetchImpl });
|
|
133
|
+
throw e;
|
|
134
|
+
}
|
|
135
|
+
return { nonce, branch, dispatched_at: now().toISOString() };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function deleteBranch(cfg, branch, { fetchImpl } = {}) {
|
|
139
|
+
try { await gh(cfg, 'DELETE', `/repos/${cfg.repo}/git/refs/heads/${branch}`, null, { fetchImpl }); }
|
|
140
|
+
catch (e) { if (e.status !== 404 && e.status !== 422) throw e; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The newest Tests row for a version, or null.
|
|
144
|
+
async function newestTestsRow(versionId, db) {
|
|
145
|
+
const { rows: [row] } = await db.query(
|
|
146
|
+
`SELECT id, outcome, detail FROM module_assessment_signals
|
|
147
|
+
WHERE version_id = $1 AND part = 'tests' ORDER BY measured_at DESC, id DESC LIMIT 1`, [versionId]);
|
|
148
|
+
return row || null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Called where a version's Tests is recorded `pending` (module-assess-version.js).
|
|
152
|
+
// Dispatches a run only when the runner is configured and the version has no Tests
|
|
153
|
+
// result yet, or only a pending one no run was sent for — so a re-assess never hides
|
|
154
|
+
// a real score behind a newer pending. Resolves null when nothing was dispatched.
|
|
155
|
+
// Never throws: a failed dispatch is logged and Tests stays plain pending.
|
|
156
|
+
async function maybeDispatch(ver, { db, env = process.env, log = console, ...opts } = {}) {
|
|
157
|
+
let cfg;
|
|
158
|
+
try { cfg = opts.cfg || runnerConfig(env); }
|
|
159
|
+
catch (e) { log.warn(`module test runner misconfigured: ${e.message}`); return null; }
|
|
160
|
+
if (!cfg) return null;
|
|
161
|
+
const newest = await newestTestsRow(ver.id, db);
|
|
162
|
+
if (newest && !(newest.outcome === 'pending' && !(newest.detail && newest.detail.runner))) return null;
|
|
163
|
+
let runner;
|
|
164
|
+
try { runner = await dispatchRun(ver, { cfg, ...opts }); }
|
|
165
|
+
catch (e) { log.warn(`module test run for version ${ver.id} not dispatched: ${e.message}`); return null; }
|
|
166
|
+
await db.query(
|
|
167
|
+
`INSERT INTO module_assessment_signals (version_id, part, outcome, score, sample_size, reason, detail)
|
|
168
|
+
VALUES ($1, 'tests', 'pending', NULL, NULL, $2, $3)`,
|
|
169
|
+
[ver.id, 'running in the separate test environment', JSON.stringify({ runner })]);
|
|
170
|
+
ensureSweeper({ db, cfg, log });
|
|
171
|
+
return runner;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// The zip a GitHub artifact downloads as. A small reader (stored + deflate) so no
|
|
176
|
+
// zip dependency joins package.json. Returns the named entry's bytes, or null.
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
function readZipEntry(zip, name, { maxBytes = MAX_SIGNAL_BYTES } = {}) {
|
|
179
|
+
let eocd = -1;
|
|
180
|
+
for (let i = zip.length - 22; i >= Math.max(0, zip.length - 22 - 0xffff); i -= 1) {
|
|
181
|
+
if (zip.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
|
182
|
+
}
|
|
183
|
+
if (eocd < 0) throw new Error('not a zip: no end-of-central-directory record');
|
|
184
|
+
const count = zip.readUInt16LE(eocd + 10);
|
|
185
|
+
let p = zip.readUInt32LE(eocd + 16);
|
|
186
|
+
for (let n = 0; n < count; n += 1) {
|
|
187
|
+
if (p + 46 > zip.length || zip.readUInt32LE(p) !== 0x02014b50) throw new Error('bad zip central directory');
|
|
188
|
+
const method = zip.readUInt16LE(p + 10);
|
|
189
|
+
const compSize = zip.readUInt32LE(p + 20);
|
|
190
|
+
const size = zip.readUInt32LE(p + 24);
|
|
191
|
+
const nameLen = zip.readUInt16LE(p + 28);
|
|
192
|
+
const extraLen = zip.readUInt16LE(p + 30);
|
|
193
|
+
const commentLen = zip.readUInt16LE(p + 32);
|
|
194
|
+
const local = zip.readUInt32LE(p + 42);
|
|
195
|
+
const entryName = zip.toString('utf8', p + 46, p + 46 + nameLen);
|
|
196
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
197
|
+
if (entryName !== name) continue;
|
|
198
|
+
if (size > maxBytes || compSize > maxBytes) throw new Error(`zip entry ${name} is over ${maxBytes} bytes`);
|
|
199
|
+
if (zip.readUInt32LE(local) !== 0x04034b50) throw new Error('bad zip local header');
|
|
200
|
+
const start = local + 30 + zip.readUInt16LE(local + 26) + zip.readUInt16LE(local + 28);
|
|
201
|
+
const data = zip.subarray(start, start + compSize);
|
|
202
|
+
if (method === 0) return Buffer.from(data);
|
|
203
|
+
if (method === 8) return zlib.inflateRawSync(data, { maxOutputLength: maxBytes });
|
|
204
|
+
throw new Error(`zip entry ${name} uses unsupported method ${method}`);
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The runner's payload is written by a job that ran third-party code, so it is
|
|
210
|
+
// held to the exact shape insertSignal stores and clipped; it is never trusted to
|
|
211
|
+
// name its own version or nonce (those must match the run it was taken from).
|
|
212
|
+
function validateSignal(raw, { versionId, nonce }) {
|
|
213
|
+
if (!raw || typeof raw !== 'object') throw new Error('the result is not a JSON object');
|
|
214
|
+
if (String(raw.version_id) !== String(versionId) || raw.nonce !== nonce) throw new Error('the result names a different version or run');
|
|
215
|
+
const { outcome } = raw;
|
|
216
|
+
if (!['scored', 'no_data', 'not_scored'].includes(outcome)) throw new Error(`unknown outcome "${outcome}"`);
|
|
217
|
+
let score = null;
|
|
218
|
+
let sample = outcome === 'no_data' ? 0 : null;
|
|
219
|
+
if (outcome === 'scored') {
|
|
220
|
+
if (!Number.isInteger(raw.score) || raw.score < 0 || raw.score > 100) throw new Error('a scored result needs an integer score 0–100');
|
|
221
|
+
if (!Number.isInteger(raw.sample_size) || raw.sample_size < 1 || raw.sample_size > 10_000) throw new Error('a scored result needs a positive sample_size');
|
|
222
|
+
score = raw.score; sample = raw.sample_size;
|
|
223
|
+
}
|
|
224
|
+
const files = Array.isArray(raw.detail && raw.detail.files) ? raw.detail.files.slice(0, 200).map((f) => ({
|
|
225
|
+
file: String((f && f.file) || '').slice(0, 200),
|
|
226
|
+
passed: !!(f && f.passed),
|
|
227
|
+
...(f && f.timed_out ? { timed_out: true } : {}),
|
|
228
|
+
...(f && f.tail ? { tail: String(f.tail).slice(0, 600) } : {}),
|
|
229
|
+
})) : [];
|
|
230
|
+
return { outcome, score, sample_size: sample, reason: String(raw.reason || '').slice(0, 500), detail: { files } };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Download one artifact's zip. GitHub answers with a redirect to blob storage; the
|
|
234
|
+
// token is not sent past it.
|
|
235
|
+
async function downloadArtifact(cfg, artifact, { fetchImpl = fetch } = {}) {
|
|
236
|
+
const res = await fetchImpl(artifact.archive_download_url, { headers: { Authorization: `Bearer ${cfg.token}`, 'User-Agent': 'bongos-module-test-runner' }, redirect: 'manual' });
|
|
237
|
+
let final = res;
|
|
238
|
+
if (res.status >= 300 && res.status < 400) final = await fetchImpl(res.headers.get('location'));
|
|
239
|
+
if (!final.ok) throw new Error(`artifact download → ${final.status}`);
|
|
240
|
+
return Buffer.from(await final.arrayBuffer());
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Every version whose newest Tests row is a pending run we dispatched. Driven
|
|
244
|
+
// from the versions (one row each) with one lookup per version on core_265's
|
|
245
|
+
// idx_module_assessment_signals_current, so the cost follows the number of
|
|
246
|
+
// versions, never the length of the append-only signal history.
|
|
247
|
+
async function pendingRuns(db) {
|
|
248
|
+
const { rows } = await db.query(
|
|
249
|
+
`SELECT s.id, s.version_id, s.outcome, s.detail
|
|
250
|
+
FROM store_module_versions v
|
|
251
|
+
CROSS JOIN LATERAL (
|
|
252
|
+
SELECT id, version_id, outcome, detail FROM module_assessment_signals
|
|
253
|
+
WHERE version_id = v.id AND part = 'tests'
|
|
254
|
+
ORDER BY measured_at DESC, id DESC LIMIT 1) s
|
|
255
|
+
WHERE s.outcome = 'pending' AND s.detail ? 'runner'`);
|
|
256
|
+
return rows.filter((r) => r.outcome === 'pending' && r.detail && r.detail.runner && NONCE_RE.test(r.detail.runner.nonce || ''));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Pull finished runs' results into module_assessment_signals. Resolves one entry
|
|
260
|
+
// per pending run: { version_id, status, outcome? } — status 'recorded' (the run
|
|
261
|
+
// finished; its result, or why there is none), 'waiting' (still running), or
|
|
262
|
+
// 'expired' (never started or never finished within MAX_RUN_AGE_MS: not_scored).
|
|
263
|
+
async function collectResults({ db, cfg, fetchImpl, now = () => Date.now(), recompose, insert } = {}) {
|
|
264
|
+
const pool = db || require('../../src/bongos/pool').pool;
|
|
265
|
+
const pending = await pendingRuns(pool);
|
|
266
|
+
if (pending.length === 0) return [];
|
|
267
|
+
const { runs = [] } = await gh(cfg, 'GET', `/repos/${cfg.repo}/actions/workflows/${WORKFLOW_FILE}/runs?event=workflow_dispatch&per_page=100`, null, { fetchImpl });
|
|
268
|
+
const doInsert = insert || require('./module-assess-tests').insertSignal;
|
|
269
|
+
const doRecompose = recompose || ((id) => require('./module-assess-score').recomposeScore(id, { db: pool }));
|
|
270
|
+
const out = [];
|
|
271
|
+
for (const p of pending) {
|
|
272
|
+
const { nonce, branch, dispatched_at: at } = p.detail.runner;
|
|
273
|
+
const run = runs.find((r) => r.display_title === runName(p.version_id, nonce));
|
|
274
|
+
const expired = now() - Date.parse(at) > MAX_RUN_AGE_MS;
|
|
275
|
+
let signal = null;
|
|
276
|
+
let status = 'recorded';
|
|
277
|
+
if (!run || run.status !== 'completed') {
|
|
278
|
+
if (!expired) { out.push({ version_id: p.version_id, status: 'waiting' }); continue; }
|
|
279
|
+
signal = { outcome: 'not_scored', score: null, sample_size: null, reason: run ? 'the test run did not finish in time' : 'the test run never started', detail: {} };
|
|
280
|
+
status = 'expired';
|
|
281
|
+
} else {
|
|
282
|
+
try {
|
|
283
|
+
const { artifacts = [] } = await gh(cfg, 'GET', `/repos/${cfg.repo}/actions/runs/${run.id}/artifacts?per_page=100`, null, { fetchImpl });
|
|
284
|
+
const art = artifacts.find((a) => a.name === artifactName(p.version_id, nonce) && !a.expired);
|
|
285
|
+
if (!art) throw new Error(`the test run ended (${run.conclusion}) without a result`);
|
|
286
|
+
const json = readZipEntry(await downloadArtifact(cfg, art, { fetchImpl }), 'signal.json');
|
|
287
|
+
if (!json) throw new Error('the result artifact carries no signal.json');
|
|
288
|
+
signal = validateSignal(JSON.parse(json.toString('utf8')), { versionId: p.version_id, nonce });
|
|
289
|
+
} catch (e) {
|
|
290
|
+
signal = { outcome: 'not_scored', score: null, sample_size: null, reason: String(e.message).slice(0, 500), detail: {} };
|
|
291
|
+
}
|
|
292
|
+
signal.detail = { ...signal.detail, runner: { nonce, run_id: run.id } };
|
|
293
|
+
}
|
|
294
|
+
await doInsert(p.version_id, signal, { db: pool });
|
|
295
|
+
await doRecompose(p.version_id);
|
|
296
|
+
if (branch && branch.startsWith(BRANCH_PREFIX)) await deleteBranch(cfg, branch, { fetchImpl }).catch(() => {});
|
|
297
|
+
out.push({ version_id: p.version_id, status, outcome: signal.outcome });
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// In the web process: once a run is dispatched, sweep for results every few
|
|
303
|
+
// minutes until none are pending, then stop. A restart drops the timer; the next
|
|
304
|
+
// dispatch restarts it, and `collect` below picks up anything left over.
|
|
305
|
+
const SWEEP_MS = 5 * 60_000;
|
|
306
|
+
let sweeper = null;
|
|
307
|
+
|
|
308
|
+
// One sweep. Resolves true to keep sweeping (a run is still out, or the sweep
|
|
309
|
+
// failed and should be tried again), false once nothing is left waiting.
|
|
310
|
+
async function sweepTick({ db, cfg, log = console, collect = collectResults }) {
|
|
311
|
+
try {
|
|
312
|
+
const res = await collect({ db, cfg });
|
|
313
|
+
return res.some((r) => r.status === 'waiting');
|
|
314
|
+
} catch (e) {
|
|
315
|
+
log.warn(`module test result sweep failed: ${e.message}`);
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function ensureSweeper({ db, cfg, log = console }) {
|
|
321
|
+
if (sweeper) return;
|
|
322
|
+
sweeper = setInterval(async () => {
|
|
323
|
+
if (!(await sweepTick({ db, cfg, log })) && sweeper) { clearInterval(sweeper); sweeper = null; }
|
|
324
|
+
}, SWEEP_MS);
|
|
325
|
+
if (sweeper.unref) sweeper.unref();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function main(argv) {
|
|
329
|
+
const cfg = runnerConfig();
|
|
330
|
+
if (!cfg) { console.error('the module test runner is not configured (set MODULE_TEST_REPO and MODULE_TEST_TOKEN)'); return 1; }
|
|
331
|
+
const { pool } = require('../../src/bongos/pool');
|
|
332
|
+
try {
|
|
333
|
+
if (argv[0] === 'dispatch' && /^\d+$/.test(argv[1] || '')) {
|
|
334
|
+
const { rows: [ver] } = await pool.query('SELECT id, module_key, version, artifact_path FROM store_module_versions WHERE id = $1', [argv[1]]);
|
|
335
|
+
if (!ver) { console.error(`no store_module_versions row ${argv[1]}`); return 1; }
|
|
336
|
+
const runner = await maybeDispatch(ver, { db: pool, cfg });
|
|
337
|
+
console.log(runner ? `dispatched version ${ver.id}: run ${runName(ver.id, runner.nonce)}` : `version ${ver.id} already has a Tests result or a run in flight — nothing dispatched`);
|
|
338
|
+
return runner ? 0 : 1;
|
|
339
|
+
}
|
|
340
|
+
if (argv[0] === 'collect') {
|
|
341
|
+
const res = await collectResults({ db: pool, cfg });
|
|
342
|
+
for (const r of res) console.log(`version ${r.version_id}: ${r.status}${r.outcome ? ` (${r.outcome})` : ''}`);
|
|
343
|
+
if (res.length === 0) console.log('no test runs pending');
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
console.error('usage: node scripts/gds/module-test-runner.js dispatch <version id> | collect');
|
|
347
|
+
return 2;
|
|
348
|
+
} finally {
|
|
349
|
+
if (sweeper) { clearInterval(sweeper); sweeper = null; }
|
|
350
|
+
await pool.end();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (require.main === module) {
|
|
355
|
+
main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (e) => { console.error(e.stack || e.message); process.exitCode = 1; });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
module.exports = { runnerConfig, maybeDispatch, collectResults, sweepTick, readZipEntry, validateSignal, runName, artifactName };
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.126'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -141,6 +141,34 @@ await test('describe states the numbers plainly, and an empty forest says so', (
|
|
|
141
141
|
assert.equal(L.describe(L.analyse({})), 'No task in this goal depends on another task in this goal.');
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
// ---- the band card's summary (task 1004738) ---------------------------------------
|
|
145
|
+
|
|
146
|
+
await test('brief is the short form of describe, and falls back to it for an empty forest', () => {
|
|
147
|
+
const a = L.analyse({ nodes: [1, 2, 3, 4, 5].map((i) => node(i)), edges: [edge(2, 1), edge(3, 2), edge(5, 4)] });
|
|
148
|
+
assert.equal(L.brief(a), '5 tasks linked by dependencies, in 2 chains.');
|
|
149
|
+
assert.equal(L.brief(L.analyse({})), L.describe(L.analyse({})));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await test('shipFirst ranks the open tasks nothing open blocks by how many open tasks wait on them, down the chain', () => {
|
|
153
|
+
// 1 ← 2 ← 3 and 1 ← 4: task 1 unblocks three. 5 ← 6: task 5 unblocks one.
|
|
154
|
+
const a = L.analyse({ nodes: [1, 2, 3, 4, 5, 6].map((i) => node(i)), edges: [edge(2, 1), edge(3, 2), edge(4, 1), edge(6, 5)] });
|
|
155
|
+
assert.deepEqual(L.shipFirst(a, 3).map((r) => [r.id, r.unblocks]), [['1', 3], ['5', 1]],
|
|
156
|
+
'a task still waiting on an open one is never "first"; one that unblocks nothing is left off');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await test('shipFirst: a shipped dependency frees what waits on it, and closed work is never listed or counted', () => {
|
|
160
|
+
const a = L.analyse({
|
|
161
|
+
nodes: [node(1, { status: 'shipped' }), node(2), node(3), node(4, { status: 'abandoned' })],
|
|
162
|
+
edges: [edge(2, 1), edge(3, 2), edge(4, 2)],
|
|
163
|
+
});
|
|
164
|
+
assert.deepEqual(L.shipFirst(a, 3).map((r) => [r.id, r.unblocks]), [['2', 1]], 'task 2 is up next and unblocks only the open task 3');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await test('shipFirst stops at the limit and breaks ties by task id', () => {
|
|
168
|
+
const a = L.analyse({ nodes: [1, 2, 3, 4, 5, 6, 7, 8].map((i) => node(i)), edges: [edge(5, 4), edge(6, 3), edge(7, 2), edge(8, 1)] });
|
|
169
|
+
assert.deepEqual(L.shipFirst(a, 3).map((r) => r.id), ['1', '2', '3']);
|
|
170
|
+
});
|
|
171
|
+
|
|
144
172
|
// ---- purity -----------------------------------------------------------------------
|
|
145
173
|
|
|
146
174
|
await test('the lib is pure: no DOM, no fetch, no cross-goal input', () => {
|
|
@@ -36,10 +36,11 @@ const settle = async () => { for (let i = 0; i < 20; i++) await tick(); };
|
|
|
36
36
|
const t = (id, extra = {}) => ({ task_id: String(id), title: `Task ${id}`, status: 'ready', priority: 3, ...extra });
|
|
37
37
|
const e = (from, to) => ({ from: String(from), to: String(to) });
|
|
38
38
|
|
|
39
|
-
async function boot({ status = 200, fail = false, payload = { nodes: [], edges: [] } } = {}) {
|
|
39
|
+
async function boot({ status = 200, fail = false, payload = { nodes: [], edges: [] }, conflictPairs = null, view = 'dependencies' } = {}) {
|
|
40
40
|
const root = makeEl('div');
|
|
41
41
|
const reg = (id) => { const el = makeEl('div', { id }); root.appendChild(el); return el; };
|
|
42
|
-
['goal-deps-h', 'goal-deps-count', 'goal-deps-lede', 'goal-deps-body'
|
|
42
|
+
['goal-deps-h', 'goal-deps-count', 'goal-deps-lede', 'goal-deps-body', 'goal-deps-back',
|
|
43
|
+
'goal-deps-card-count', 'goal-deps-card-lede', 'goal-deps-card-body', 'goal-deps-card-foot'].forEach(reg);
|
|
43
44
|
|
|
44
45
|
const documentObj = {
|
|
45
46
|
getElementById: (id) => walk(root, (el) => (el.id === id ? el : null)),
|
|
@@ -83,7 +84,7 @@ async function boot({ status = 200, fail = false, payload = { nodes: [], edges:
|
|
|
83
84
|
vm.runInContext(read('bulk-toolbar.js'), sandbox, { filename: 'bulk-toolbar.js' }); // task 1003141
|
|
84
85
|
vm.runInContext(RENDER_JS, sandbox, { filename: 'goals-render.js' });
|
|
85
86
|
|
|
86
|
-
const state = { current: 7, forest: null, forestError: false };
|
|
87
|
+
const state = { current: 7, view, forest: null, forestError: false, conflictPairs };
|
|
87
88
|
const ctx = {
|
|
88
89
|
API: '/api/bongos',
|
|
89
90
|
state,
|
|
@@ -96,7 +97,10 @@ async function boot({ status = 200, fail = false, payload = { nodes: [], edges:
|
|
|
96
97
|
},
|
|
97
98
|
};
|
|
98
99
|
const renderers = windowObj.OTBGoalsRender(ctx);
|
|
99
|
-
|
|
100
|
+
// The band card's renderer is the forest module's own export — goals-render.js
|
|
101
|
+
// reaches it through its private `forestRender`, so the test makes its own.
|
|
102
|
+
const card = windowObj.OTBGoalForestRender(ctx);
|
|
103
|
+
return { $: documentObj.getElementById, requests, state, renderers, card };
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
// ---- the honest states (ADR 0227) ---------------------------------------------
|
|
@@ -217,14 +221,105 @@ await test('renderDetail calls loadDependencyForest, beside loadGoalTasks, for t
|
|
|
217
221
|
assert.match(RENDER_JS, /loadGoalTasks\(g\.id\);\s*\n\s*loadDependencyForest\(g\.id\);/);
|
|
218
222
|
});
|
|
219
223
|
|
|
220
|
-
|
|
224
|
+
// ---- the band card + the full view (task 1004738) ----------------------------
|
|
225
|
+
|
|
226
|
+
const sectionOf = (html, id) => {
|
|
227
|
+
const start = html.indexOf(`<section id="${id}"`);
|
|
228
|
+
return start < 0 ? '' : html.slice(start, html.indexOf('</section>', start));
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
await test('the band\'s third column is the compact Dependencies card, where Conflicts was — no long block before Tasks', () => {
|
|
221
232
|
const html = fs.readFileSync(path.join(HALL, 'goals.html'), 'utf8');
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
assert.
|
|
226
|
-
assert.
|
|
227
|
-
assert.equal(
|
|
233
|
+
const detail = sectionOf(html, 'goal-detail-view');
|
|
234
|
+
const cols = detail.split('class="goal-band__col"').slice(1);
|
|
235
|
+
assert.equal(cols.length, 3, 'still three columns');
|
|
236
|
+
assert.match(cols[2], /^\s*id="goal-deps-card"/, 'Dependencies takes the third slot');
|
|
237
|
+
assert.equal(/>Conflicts</.test(cols.join('')), false, 'no Conflicts column left in the band');
|
|
238
|
+
assert.equal(detail.includes('id="goal-deps-scroll"'), false, 'the forest is off the goal page');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await test('the full picture is #/goal/N/dependencies: the forest, then the whole conflict set; the old /conflicts lands there', () => {
|
|
242
|
+
const html = fs.readFileSync(path.join(HALL, 'goals.html'), 'utf8');
|
|
243
|
+
const view = sectionOf(html, 'goal-dependencies-view');
|
|
244
|
+
const depsAt = view.indexOf('id="goal-deps-scroll"');
|
|
245
|
+
const conflictsAt = view.indexOf('id="goal-conflicts-full"');
|
|
246
|
+
assert.ok(view.includes('id="goal-deps-back"') && depsAt > -1 && depsAt < conflictsAt, 'back link, forest, then conflicts');
|
|
247
|
+
assert.equal(sectionOf(html, 'goal-conflicts-view'), '', 'no separate conflicts view any more');
|
|
248
|
+
const page = read('goals-page.js');
|
|
249
|
+
assert.match(page, /dependencies: '#goal-dependencies-view'/);
|
|
250
|
+
assert.match(page, /\(dependencies\|conflicts\|members\|completion\)/);
|
|
251
|
+
assert.match(page, /mSub\[2\] === 'conflicts' \? 'dependencies'/, 'the inbox\'s /conflicts links keep working');
|
|
252
|
+
assert.match(RENDER_JS, /function renderConflicts\(\) \{[\s\S]*?forestRender\.renderDependencyCard\(\);/, 'the card re-renders when the conflicts land');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await test('the card states the shape, lists what to ship first, counts conflicts, and links to the full view', async () => {
|
|
256
|
+
const h = await boot({
|
|
257
|
+
payload: { nodes: [t(1, { title: 'Base' }), t(2), t(3), t(4, { title: 'Other root' }), t(5)], edges: [e(2, 1), e(3, 2), e(5, 4)] },
|
|
258
|
+
conflictPairs: [{ a: 2, b: 5 }, { a: 3, b: 5 }],
|
|
259
|
+
});
|
|
260
|
+
await h.renderers.loadDependencyForest(7);
|
|
261
|
+
assert.equal(h.$('goal-deps-card-count').hidden, false);
|
|
262
|
+
assert.equal(h.$('goal-deps-card-count').textContent, '5');
|
|
263
|
+
assert.equal(h.$('goal-deps-card-lede').textContent, '5 tasks linked by dependencies, in 2 chains.');
|
|
264
|
+
const body = h.$('goal-deps-card-body');
|
|
265
|
+
const rows = body.querySelectorAll('.gf-brief__row');
|
|
266
|
+
assert.deepEqual(rows.map((r) => r.querySelector('a').getAttribute('href')), ['#/task/1', '#/task/4']);
|
|
267
|
+
assert.deepEqual(rows.map((r) => r.querySelector('.gf-brief__n').textContent), ['unblocks 2', 'unblocks 1']);
|
|
268
|
+
const line = body.querySelector('.gf-brief__conflicts');
|
|
269
|
+
assert.equal(line.textContent, '2 file conflicts between open tasks.');
|
|
270
|
+
assert.match(line.className, /gf-brief__conflicts--live/);
|
|
271
|
+
const foot = h.$('goal-deps-card-foot');
|
|
272
|
+
assert.equal(foot.hidden, false);
|
|
273
|
+
assert.equal(foot.querySelector('a').getAttribute('href'), '#/goal/7/dependencies');
|
|
274
|
+
assert.equal(h.$('goal-deps-back').getAttribute('href'), '#/goal/7');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await test('on the goal page only the card is drawn — the forest waits for its own view, where its titles can be measured', async () => {
|
|
278
|
+
const h = await boot({ view: 'detail', payload: { nodes: [t(1), t(2)], edges: [e(2, 1)] } });
|
|
279
|
+
await h.renderers.loadDependencyForest(7);
|
|
280
|
+
assert.equal(h.$('goal-deps-body').querySelector('svg'), null, 'no forest drawn into the hidden view');
|
|
281
|
+
assert.equal(h.$('goal-deps-body').innerHTML, '');
|
|
282
|
+
assert.equal(h.$('goal-deps-card-lede').textContent, '2 tasks linked by dependencies, in 1 chain.');
|
|
283
|
+
h.state.view = 'dependencies';
|
|
284
|
+
h.renderers.renderDependencyForest();
|
|
285
|
+
assert.ok(h.$('goal-deps-body').querySelector('svg'), 'opening the view draws it');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await test('the card: no conflicts says so, unknown conflicts says nothing, and an empty goal has no door to an empty page', async () => {
|
|
289
|
+
const none = await boot({ payload: { nodes: [], edges: [] }, conflictPairs: [] });
|
|
290
|
+
await none.renderers.loadDependencyForest(7);
|
|
291
|
+
assert.match(none.$('goal-deps-card-lede').textContent, /No task in this goal depends on another/);
|
|
292
|
+
assert.equal(none.$('goal-deps-card-count').hidden, true);
|
|
293
|
+
assert.equal(none.$('goal-deps-card-body').querySelector('.gf-brief__conflicts').textContent, 'No file conflicts between open tasks.');
|
|
294
|
+
assert.equal(none.$('goal-deps-card-foot').hidden, true);
|
|
295
|
+
|
|
296
|
+
const unknown = await boot({ payload: { nodes: [], edges: [] }, conflictPairs: null });
|
|
297
|
+
await unknown.renderers.loadDependencyForest(7);
|
|
298
|
+
assert.equal(unknown.$('goal-deps-card-body').querySelector('.gf-brief__conflicts'), null, 'a failed or pending conflicts read draws no line, not a zero');
|
|
299
|
+
|
|
300
|
+
const onlyConflicts = await boot({ payload: { nodes: [], edges: [] }, conflictPairs: [{ a: 1, b: 2 }] });
|
|
301
|
+
await onlyConflicts.renderers.loadDependencyForest(7);
|
|
302
|
+
assert.equal(onlyConflicts.$('goal-deps-card-foot').hidden, false, 'conflicts alone still earn the full view');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await test('the card: while reading, and after a failed read, it says so — and a late conflicts list re-renders it', async () => {
|
|
306
|
+
const h = await boot({ fail: true });
|
|
307
|
+
const p = h.renderers.loadDependencyForest(7);
|
|
308
|
+
assert.equal(h.$('goal-deps-card-lede').textContent, 'Reading…');
|
|
309
|
+
await p;
|
|
310
|
+
assert.match(h.$('goal-deps-card-lede').textContent, /Couldn.t load this goal.s task dependencies/);
|
|
311
|
+
h.state.conflictPairs = [{ a: 1, b: 2 }];
|
|
312
|
+
h.card.renderDependencyCard();
|
|
313
|
+
assert.equal(h.$('goal-deps-card-body').querySelector('.gf-brief__conflicts').textContent, '1 file conflict between open tasks.');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
await test('a "ship first" title is builder-authored text and never becomes markup in the card either', async () => {
|
|
317
|
+
const evil = '<img src=x onerror=alert(1)>';
|
|
318
|
+
const h = await boot({ payload: { nodes: [t(1, { title: evil }), t(2)], edges: [e(2, 1)] } });
|
|
319
|
+
await h.renderers.loadDependencyForest(7);
|
|
320
|
+
const body = h.$('goal-deps-card-body');
|
|
321
|
+
assert.equal(body.querySelector('img'), null);
|
|
322
|
+
assert.ok(body.textContent.includes(evil));
|
|
228
323
|
});
|
|
229
324
|
|
|
230
325
|
summary();
|