@1aboveio/skills 1.0.7 → 1.0.9
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/.codex-plugin/plugin.json +13 -0
- package/.codex-plugin/resources.json +1827 -0
- package/README.md +22 -0
- package/package.json +9 -2
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/airflow/release-check.mjs +3 -1
- package/skills/app-dev/android-foundation/SKILL.md +92 -34
- package/skills/app-dev/android-foundation/assets/android-ci-job.yml +13 -7
- package/skills/app-dev/android-foundation/references/ci.md +23 -13
- package/skills/app-dev/android-foundation/references/delivery-focus.md +29 -0
- package/skills/app-dev/android-foundation/references/diagnosis.md +136 -0
- package/skills/app-dev/android-foundation/references/gotchas.md +20 -11
- package/skills/app-dev/android-foundation/references/setup.md +15 -6
- package/skills/app-dev/android-foundation/release-check.mjs +3 -1
- package/skills/app-dev/android-foundation/scripts/device-run.sh +148 -42
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/app-debug/release-check.mjs +3 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/better-auth/release-check.mjs +3 -1
- package/skills/app-dev/data-catalog/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/release-check.mjs +3 -1
- package/skills/app-dev/data-quality/SKILL.md +1 -1
- package/skills/app-dev/data-quality/release-check.mjs +3 -1
- package/skills/app-dev/ios-foundation/SKILL.md +1 -1
- package/skills/app-dev/ios-foundation/release-check.mjs +3 -1
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/monorepo/release-check.mjs +3 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/release-check.mjs +3 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/release-check.mjs +3 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/pyspark/release-check.mjs +3 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/python-backend/release-check.mjs +3 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/shadcn/release-check.mjs +3 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/release-check.mjs +3 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-build/release-check.mjs +3 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/release-check.mjs +3 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/release-check.mjs +3 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/docker/release-check.mjs +3 -1
- package/skills/cicd/github-actions/SKILL.md +1 -1
- package/skills/cicd/github-actions/release-check.mjs +3 -1
- package/skills/cicd/github-flow/SKILL.md +1 -1
- package/skills/cicd/github-flow/release-check.mjs +3 -1
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/google-cloud/release-check.mjs +3 -1
- package/skills/cicd/mergify/SKILL.md +1 -1
- package/skills/cicd/mergify/release-check.mjs +3 -1
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/cicd/podman/release-check.mjs +3 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-review/release-check.mjs +3 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/article-to-image/release-check.mjs +3 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/flowchart-svg/release-check.mjs +3 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/image-gen/release-check.mjs +3 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/mermaid/release-check.mjs +3 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/release-check.mjs +3 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-publisher/release-check.mjs +3 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/wechat-search/release-check.mjs +3 -1
- package/skills/content/xai-search/SKILL.md +88 -13
- package/skills/content/xai-search/release-check.mjs +3 -1
- package/skills/content/xai-search/scripts/xai_search.sh +125 -149
- package/skills/core/distill-writing/SKILL.md +1 -1
- package/skills/core/distill-writing/release-check.mjs +3 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/doc-to-md/release-check.mjs +3 -1
- package/skills/core/editorial-review/SKILL.md +1 -1
- package/skills/core/editorial-review/release-check.mjs +3 -1
- package/skills/core/house-style/SKILL.md +1 -1
- package/skills/core/house-style/release-check.mjs +3 -1
- package/skills/core/plain-language/SKILL.md +1 -1
- package/skills/core/plain-language/release-check.mjs +3 -1
- package/skills/core/polish-cn/SKILL.md +1 -1
- package/skills/core/polish-cn/release-check.mjs +3 -1
- package/skills/core/reg-parser/SKILL.md +1 -1
- package/skills/core/reg-parser/release-check.mjs +3 -1
- package/skills/core/terminology/SKILL.md +1 -1
- package/skills/core/terminology/release-check.mjs +3 -1
- package/skills/core/write-doc/SKILL.md +1 -1
- package/skills/core/write-doc/release-check.mjs +3 -1
- package/skills/engineering/better-review/SKILL.md +1 -1
- package/skills/engineering/better-review/release-check.mjs +3 -1
- package/skills/engineering/contributing/SKILL.md +1 -1
- package/skills/engineering/contributing/release-check.mjs +3 -1
- package/skills/engineering/e2e-test/SKILL.md +1 -1
- package/skills/engineering/e2e-test/release-check.mjs +3 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
- package/skills/engineering/engineering-runtime/release-check.mjs +3 -1
- package/skills/engineering/engineering-runtime/scripts/installation-resolver.mjs +191 -0
- package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +51 -10
- package/skills/engineering/ensure-coverage/SKILL.md +1 -1
- package/skills/engineering/ensure-coverage/release-check.mjs +3 -1
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/golden-path/release-check.mjs +3 -1
- package/skills/engineering/harness-runtime/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/release-check.mjs +3 -1
- package/skills/engineering/resolve-issues/SKILL.md +11 -20
- package/skills/engineering/resolve-issues/references/delivery-progress.md +63 -0
- package/skills/engineering/resolve-issues/references/expire.md +24 -11
- package/skills/engineering/resolve-issues/references/implementation.md +9 -1
- package/skills/engineering/resolve-issues/references/preflight.md +7 -0
- package/skills/engineering/resolve-issues/references/shared-modules.md +27 -0
- package/skills/engineering/resolve-issues/references/task-plan.md +12 -5
- package/skills/engineering/resolve-issues/release-check.mjs +3 -1
- package/skills/engineering/resolve-issues/scripts/active-installation.mjs +32 -0
- package/skills/engineering/resolve-issues/scripts/install-hooks.mjs +3 -0
- package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +17 -1
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/resolve-release/release-check.mjs +3 -1
- package/skills/engineering/rush-issues/SKILL.md +5 -2
- package/skills/engineering/rush-issues/release-check.mjs +3 -1
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/rush-release/release-check.mjs +3 -1
- package/skills/engineering/test-quality/SKILL.md +1 -1
- package/skills/engineering/test-quality/release-check.mjs +3 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
- package/skills/risk-compliance/compliance-audit/release-check.mjs +3 -1
- package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
- package/skills/risk-compliance/contract-compare/release-check.mjs +3 -1
- package/skills/risk-compliance/contract-review/SKILL.md +1 -1
- package/skills/risk-compliance/contract-review/release-check.mjs +3 -1
- package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
- package/skills/risk-compliance/doc-authenticity/release-check.mjs +3 -1
- package/skills/risk-compliance/fraud-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/fraud-analysis/release-check.mjs +3 -1
- package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/payment-analysis/release-check.mjs +3 -1
- package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
- package/skills/risk-compliance/similar-domain/release-check.mjs +3 -1
- package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
- package/skills/risk-compliance/tld-detection/release-check.mjs +3 -1
- package/skills/app-dev/android-foundation/scripts/device-run.test.mjs +0 -172
- package/skills/app-dev/data-catalog/scripts/data-catalog.test.mjs +0 -36
- package/skills/app-dev/data-quality/scripts/data-quality.test.mjs +0 -12
- package/skills/cicd/github-actions/scripts/ci-run-audit.test.mjs +0 -637
- package/skills/cicd/github-flow/scripts/flow.test.mjs +0 -114
- package/skills/cicd/github-flow/scripts/persist-conventions.test.mjs +0 -108
- package/skills/cicd/mergify/scripts/audit.test.mjs +0 -201
- package/skills/cicd/mergify/scripts/entrypoints.test.mjs +0 -106
- package/skills/cicd/mergify/scripts/pr-620-mergify-comments.fixture.json +0 -30
- package/skills/cicd/mergify/scripts/stack-push.test.mjs +0 -233
- package/skills/cicd/mergify/scripts/watch-pr-delivery.test.mjs +0 -1826
- package/skills/content/image-gen/scripts/credentials.test.mjs +0 -212
- package/skills/content/image-gen/scripts/options.test.mjs +0 -245
- package/skills/content/xai-search/tests/smoke_test.sh +0 -36
- package/skills/core/polish-cn/scripts/polish-cn.test.mjs +0 -244
- package/skills/core/reg-parser/scripts/reg-parser.test.mjs +0 -165
- package/skills/engineering/better-review/scripts/pr-context.test.mjs +0 -168
- package/skills/engineering/better-review/scripts/rereview-scope.test.mjs +0 -587
- package/skills/engineering/better-review/scripts/scan-diff.test.mjs +0 -414
- package/skills/engineering/better-review/scripts/vendor-sync.test.mjs +0 -96
- package/skills/engineering/contributing/scripts/persist-conventions.test.mjs +0 -118
- package/skills/engineering/e2e-test/scripts/presentation-checks.test.mjs +0 -106
- package/skills/engineering/e2e-test/scripts/presentation-perceivability.test.mjs +0 -117
- package/skills/engineering/e2e-test/scripts/presentation-reachability.test.mjs +0 -69
- package/skills/engineering/e2e-test/scripts/presentation-render-health.test.mjs +0 -98
- package/skills/engineering/e2e-test/scripts/presentation-sweep.test.mjs +0 -71
- package/skills/engineering/e2e-test/scripts/presentation-temporal.test.mjs +0 -59
- package/skills/engineering/e2e-test/scripts/project-route-manifest.test.mjs +0 -76
- package/skills/engineering/engineering-runtime/engineering-runtime.test.mjs +0 -68
- package/skills/engineering/engineering-runtime/scripts/invocation-policy.test.mjs +0 -95
- package/skills/engineering/engineering-runtime/scripts/main-module.test.mjs +0 -288
- package/skills/engineering/engineering-runtime/scripts/required-check-plan.test.mjs +0 -96
- package/skills/engineering/ensure-coverage/scripts/adapters/nextjs-inventory.test.mjs +0 -96
- package/skills/engineering/ensure-coverage/scripts/check-quarantine-expiry.test.mjs +0 -47
- package/skills/engineering/ensure-coverage/scripts/ci-audit.test.mjs +0 -86
- package/skills/engineering/ensure-coverage/scripts/coverage-ledger.test.mjs +0 -447
- package/skills/engineering/ensure-coverage/scripts/design-parity.test.mjs +0 -552
- package/skills/engineering/ensure-coverage/scripts/lint-tests.test.mjs +0 -289
- package/skills/engineering/ensure-coverage/scripts/mock-policy-config.test.mjs +0 -119
- package/skills/engineering/golden-path/scripts/golden-path.test.mjs +0 -1457
- package/skills/engineering/harness-runtime/discover-models.test.mjs +0 -363
- package/skills/engineering/harness-runtime/harness-runtime.test.mjs +0 -967
- package/skills/engineering/harness-runtime/model-catalog-seed.test.mjs +0 -829
- package/skills/engineering/harness-runtime/model-catalog.test.mjs +0 -366
- package/skills/engineering/resolve-issues/scripts/cleanup-runs.test.mjs +0 -280
- package/skills/engineering/resolve-issues/scripts/dropbox.test.mjs +0 -346
- package/skills/engineering/resolve-issues/scripts/findings-reuse.test.mjs +0 -88
- package/skills/engineering/resolve-issues/scripts/github-reviews.test.mjs +0 -264
- package/skills/engineering/resolve-issues/scripts/install-hooks.test.mjs +0 -79
- package/skills/engineering/resolve-issues/scripts/issue-tree.test.mjs +0 -107
- package/skills/engineering/resolve-issues/scripts/manual-invocation.test.mjs +0 -14
- package/skills/engineering/resolve-issues/scripts/next-actions-capacity.test.mjs +0 -149
- package/skills/engineering/resolve-issues/scripts/next-actions.test.mjs +0 -915
- package/skills/engineering/resolve-issues/scripts/plan-sync.test.mjs +0 -156
- package/skills/engineering/resolve-issues/scripts/preflight-models.test.mjs +0 -448
- package/skills/engineering/resolve-issues/scripts/ready-check.test.mjs +0 -217
- package/skills/engineering/resolve-issues/scripts/rehydrate.test.mjs +0 -91
- package/skills/engineering/resolve-issues/scripts/review-verdict.test.mjs +0 -1060
- package/skills/engineering/resolve-issues/scripts/size-check.test.mjs +0 -458
- package/skills/engineering/resolve-release/scripts/apply.test.mjs +0 -99
- package/skills/engineering/resolve-release/scripts/dark-flags.test.mjs +0 -100
- package/skills/engineering/resolve-release/scripts/flow.test.mjs +0 -340
- package/skills/engineering/resolve-release/scripts/green-head.test.mjs +0 -101
- package/skills/engineering/resolve-release/scripts/npm-version-absence.test.mjs +0 -426
- package/skills/engineering/resolve-release/scripts/plan-npm.test.mjs +0 -659
- package/skills/engineering/resolve-release/scripts/plan-toml-boundary.test.mjs +0 -317
- package/skills/engineering/resolve-release/scripts/plan-uv.test.mjs +0 -291
- package/skills/engineering/resolve-release/scripts/plan.test.mjs +0 -428
- package/skills/engineering/test-quality/scripts/lint-tests.test.mjs +0 -591
- package/skills/engineering/test-quality/scripts/mock-policy-config.test.mjs +0 -119
- package/skills/engineering/test-quality/scripts/persist-conventions.test.mjs +0 -104
- package/skills/risk-compliance/compliance-audit/scripts/compliance-audit.test.mjs +0 -14
- package/skills/risk-compliance/doc-authenticity/manual-invocation.test.mjs +0 -12
- package/skills/risk-compliance/doc-authenticity/scripts/validate_verdict.test.mjs +0 -212
- package/skills/risk-compliance/tld-detection/scripts/detect_ladders.test.mjs +0 -14
- package/skills/risk-compliance/tld-detection/scripts/detect_streams.test.mjs +0 -14
- package/skills/risk-compliance/tld-detection/tests/test_detect_ladders.py +0 -134
- package/skills/risk-compliance/tld-detection/tests/test_detect_streams.py +0 -520
|
@@ -1,637 +0,0 @@
|
|
|
1
|
-
// Tests exercise pure decisions, the gh provider boundary and the real saved-data CLI.
|
|
2
|
-
import { test } from 'node:test'
|
|
3
|
-
import assert from 'node:assert/strict'
|
|
4
|
-
import fs from 'node:fs'
|
|
5
|
-
import os from 'node:os'
|
|
6
|
-
import path from 'node:path'
|
|
7
|
-
import { spawnSync } from 'node:child_process'
|
|
8
|
-
import { fileURLToPath } from 'node:url'
|
|
9
|
-
|
|
10
|
-
import { analyze, render, lane, DEFAULT_IGNORE, fetchRuns, fetchRun, profileRun, renderProfile } from './ci-run-audit-core.mjs'
|
|
11
|
-
|
|
12
|
-
const T0 = Date.parse('2026-09-01T00:00:00Z')
|
|
13
|
-
const at = (m) => new Date(T0 + m * 60000).toISOString()
|
|
14
|
-
|
|
15
|
-
// A job that starts at `start` minutes and runs `mins`, with optional setup minutes.
|
|
16
|
-
function job(name, conclusion, start, mins, setup = 0) {
|
|
17
|
-
const steps = []
|
|
18
|
-
if (setup) steps.push({ name: 'Set up job', conclusion: 'success', started: at(start), completed: at(start + setup) })
|
|
19
|
-
steps.push({ name: `Run ${name}`, conclusion, started: at(start + setup), completed: at(start + mins) })
|
|
20
|
-
return { name, conclusion, created: at(start), started: at(start), completed: at(start + mins), steps }
|
|
21
|
-
}
|
|
22
|
-
let id = 0
|
|
23
|
-
const runOf = (o) => ({ id: ++id, workflow: 'CI', event: 'pull_request', branch: 'feat/x', defaultBranch: 'main', created: at(o.start ?? 0), ...o })
|
|
24
|
-
|
|
25
|
-
test('lane: queue branches, PRs, default-branch pushes, and everything else', () => {
|
|
26
|
-
assert.equal(lane({ branch: 'mergify/merge-queue/abc', event: 'pull_request' }), 'queue')
|
|
27
|
-
assert.equal(lane({ branch: 'gh-readonly-queue/main/pr-1', event: 'merge_group' }), 'queue')
|
|
28
|
-
assert.equal(lane({ branch: 'feat/x', event: 'pull_request' }), 'pr')
|
|
29
|
-
assert.equal(lane({ branch: 'trunk', defaultBranch: 'trunk', event: 'push' }), 'main')
|
|
30
|
-
assert.equal(lane({ branch: 'main', event: 'schedule' }), 'schedule')
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
test('re-proved: a job passing again on the same tree counts, by lane pair; a new tree does not', () => {
|
|
34
|
-
const a = analyze([
|
|
35
|
-
runOf({ sha: 's1', tree: 't1', start: 0, jobs: [job('test', 'success', 0, 4)] }),
|
|
36
|
-
runOf({ sha: 'q1', tree: 't1', branch: 'mergify/merge-queue/1', start: 10, jobs: [job('test', 'success', 10, 4)] }),
|
|
37
|
-
runOf({ sha: 'm1', tree: 't1', event: 'push', branch: 'main', start: 20, jobs: [job('test', 'success', 20, 4)] }),
|
|
38
|
-
runOf({ sha: 's2', tree: 't2', start: 30, jobs: [job('test', 'success', 30, 4)] }),
|
|
39
|
-
])
|
|
40
|
-
assert.equal(a.reproved.passMin, 16)
|
|
41
|
-
assert.equal(a.reproved.reprovedMin, 8)
|
|
42
|
-
assert.deepEqual(a.reproved.byLanePair, { 'pr→queue': 4, 'pr→main': 4 })
|
|
43
|
-
assert.equal(a.reproved.byJob.test, 8)
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
test('re-proved: scheduled repeats are deliberate, reported apart and not counted as waste', () => {
|
|
47
|
-
const a = analyze([
|
|
48
|
-
runOf({ sha: 'm1', tree: 't1', event: 'schedule', branch: 'main', start: 0, jobs: [job('nightly', 'success', 0, 10)] }),
|
|
49
|
-
runOf({ sha: 'm1', tree: 't1', event: 'schedule', branch: 'main', start: 1440, jobs: [job('nightly', 'success', 1440, 10)] }),
|
|
50
|
-
])
|
|
51
|
-
assert.equal(a.reproved.reprovedMin, 0)
|
|
52
|
-
assert.equal(a.reproved.scheduledRepeatMin, 10)
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
test('re-proved: a failed first attempt does not make the later pass a repeat', () => {
|
|
56
|
-
const a = analyze([
|
|
57
|
-
runOf({ sha: 's1', tree: 't1', start: 0, jobs: [job('test', 'failure', 0, 4)] }),
|
|
58
|
-
runOf({ sha: 's1', tree: 't1', start: 10, jobs: [job('test', 'success', 10, 4)] }),
|
|
59
|
-
])
|
|
60
|
-
assert.equal(a.reproved.reprovedMin, 0)
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
test('yield: sole catches, shared failures, and fail-then-pass flakes are told apart', () => {
|
|
64
|
-
const a = analyze([
|
|
65
|
-
// t1: unit alone fails -> a sole catch for unit
|
|
66
|
-
runOf({ sha: 's1', tree: 't1', jobs: [job('unit', 'failure', 0, 2), job('lint', 'success', 0, 1)] }),
|
|
67
|
-
// t2: unit and lint both fail -> shared, no sole catch
|
|
68
|
-
runOf({ sha: 's2', tree: 't2', jobs: [job('unit', 'failure', 0, 2), job('lint', 'failure', 0, 1)] }),
|
|
69
|
-
// t3: lint fails then passes on the same tree -> flaky, not a real failure
|
|
70
|
-
runOf({ sha: 's3', tree: 't3', jobs: [job('unit', 'success', 0, 2), job('lint', 'failure', 0, 1)] }),
|
|
71
|
-
runOf({ sha: 's3', tree: 't3', start: 5, jobs: [job('lint', 'success', 5, 1)] }),
|
|
72
|
-
], { minTrees: 3 })
|
|
73
|
-
const row = (n) => a.yield.find((e) => e.job === n)
|
|
74
|
-
assert.deepEqual([row('unit').trees, row('unit').failed, row('unit').sole, row('unit').flaky], [3, 2, 1, 0])
|
|
75
|
-
assert.deepEqual([row('lint').trees, row('lint').failed, row('lint').sole, row('lint').flaky], [3, 1, 0, 1])
|
|
76
|
-
assert.deepEqual(a.noSoleCatch.map((e) => e.job), ['lint'])
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
test('yield: umbrella jobs are ignored, so they never make a real failure look shared', () => {
|
|
80
|
-
const a = analyze([
|
|
81
|
-
runOf({ sha: 's1', tree: 't1', jobs: [job('unit', 'failure', 0, 2), job('all-gates', 'failure', 2, 0.1)] }),
|
|
82
|
-
], { minTrees: 1 })
|
|
83
|
-
assert.equal(a.yield.find((e) => e.job === 'unit').sole, 1)
|
|
84
|
-
assert.equal(a.yield.find((e) => e.job === 'all-gates'), undefined)
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
test('DEFAULT_IGNORE drops roll-ups but keeps real checks that happen to say "gate"', () => {
|
|
88
|
-
for (const n of ['all-gates', 'CI', 'PR admission gate', 'Merge queue gate', 'Post-deploy validation verdict', 'CI timing report']) assert.ok(DEFAULT_IGNORE.test(n), n)
|
|
89
|
-
for (const n of ['smoke-gate', 'route-docs-gate', 'PR Gate (install + builds + syntax)', 'Smoke Gate (golden paths)', 'typecheck']) assert.ok(!DEFAULT_IGNORE.test(n), n)
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
test('overlap: a job whose every failure (>=3) coincided with another job failing', () => {
|
|
93
|
-
const runs = [1, 2, 3].map((i) => runOf({ sha: `s${i}`, tree: `t${i}`, jobs: [job('sweep', 'failure', 0, 5), job('smoke', 'failure', 0, 5)] }))
|
|
94
|
-
runs.push(runOf({ sha: 's4', tree: 't4', jobs: [job('sweep', 'success', 0, 5), job('smoke', 'failure', 0, 5)] }))
|
|
95
|
-
const a = analyze(runs)
|
|
96
|
-
assert.deepEqual(a.overlap.find((o) => o.job === 'sweep'), { job: 'sweep', alwaysWith: 'smoke', failures: 3, otherFailures: 4 })
|
|
97
|
-
assert.equal(a.overlap.find((o) => o.job === 'smoke'), undefined, 'smoke failed once without sweep')
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
test('setup share: flags jobs that are mostly setup, not ones that are mostly work', () => {
|
|
101
|
-
const runs = [1, 2, 3].map((i) => runOf({ sha: `s${i}`, tree: `t${i}`, jobs: [job('ledger', 'success', 0, 1, 0.95), job('e2e', 'success', 0, 10, 2)] }))
|
|
102
|
-
const a = analyze(runs)
|
|
103
|
-
assert.deepEqual(a.setup.map((e) => e.job), ['ledger'])
|
|
104
|
-
assert.ok(Math.abs(a.setup[0].setupShare - 0.95) < 1e-9)
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
test('history keeps same-named workflows and checks separate in yield and overlap', () => {
|
|
108
|
-
const runs = [1, 2, 3].flatMap((i) => [
|
|
109
|
-
runOf({ workflowId: 1, tree: `t${i}`, jobs: [job('unit', 'failure', 0, 2), job('lint', 'failure', 0, 1)] }),
|
|
110
|
-
runOf({ workflowId: 2, tree: `t${i}`, jobs: [job('unit', 'success', 0, 2), job('lint', 'success', 0, 1)] }),
|
|
111
|
-
])
|
|
112
|
-
const a = analyze(runs, { minTrees: 3 })
|
|
113
|
-
assert.equal(a.yield.length, 4)
|
|
114
|
-
const unit = a.yield.find((e) => e.job === 'CI [1] / unit')
|
|
115
|
-
assert.deepEqual([unit.trees, unit.failed, unit.flaky], [3, 3, 0])
|
|
116
|
-
assert.deepEqual(unit.coFail, { 'CI [1] / lint': 3 })
|
|
117
|
-
assert.deepEqual(a.overlap.find((e) => e.job === unit.job), {
|
|
118
|
-
job: unit.job, alwaysWith: 'CI [1] / lint', failures: 3, otherFailures: 3,
|
|
119
|
-
})
|
|
120
|
-
assert.equal(a.yield.find((e) => e.job === 'CI [2] / unit').failed, 0)
|
|
121
|
-
assert.match(render(a), /CI \[1\] \/ unit/)
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
test('history keeps setup samples separate by workflow identity', () => {
|
|
125
|
-
const runs = [1, 2, 3].flatMap((i) => [
|
|
126
|
-
runOf({ workflowId: 1, tree: `t${i}`, jobs: [job('ledger', 'success', 0, 1, 0.95)] }),
|
|
127
|
-
runOf({ workflowId: 2, tree: `t${i}`, jobs: [job('ledger', 'success', 0, 10, 2)] }),
|
|
128
|
-
])
|
|
129
|
-
const a = analyze(runs)
|
|
130
|
-
assert.equal(a.setup.length, 1)
|
|
131
|
-
assert.equal(a.setup[0].job, 'CI [1] / ledger')
|
|
132
|
-
assert.equal(a.setup[0].runs, 3)
|
|
133
|
-
assert.ok(Math.abs(a.setup[0].setupShare - 0.95) < 1e-9)
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
test('history preserves failure overlap across distinct workflows on the same code states', () => {
|
|
137
|
-
const runs = [1, 2, 3].flatMap((i) => [
|
|
138
|
-
runOf({ workflow: 'Unit CI', workflowId: 1, tree: `t${i}`, jobs: [job('unit', 'failure', 0, 2)] }),
|
|
139
|
-
runOf({ workflow: 'Integration CI', workflowId: 2, tree: `t${i}`, jobs: [job('integration', 'failure', 0, 2)] }),
|
|
140
|
-
])
|
|
141
|
-
const a = analyze(runs, { minTrees: 3 })
|
|
142
|
-
for (const e of a.yield) {
|
|
143
|
-
assert.equal(e.failed, 3)
|
|
144
|
-
assert.equal(e.sole, 0)
|
|
145
|
-
assert.equal(e.flaky, 0)
|
|
146
|
-
}
|
|
147
|
-
assert.deepEqual(a.yield.find((e) => e.job === 'unit').coFail, { integration: 3 })
|
|
148
|
-
assert.deepEqual(a.overlap.find((e) => e.job === 'unit'), {
|
|
149
|
-
job: 'unit', alwaysWith: 'integration', failures: 3, otherFailures: 3,
|
|
150
|
-
})
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
test('workflow-path fallback disambiguates repeat candidates and last finishers', () => {
|
|
154
|
-
const a = analyze([
|
|
155
|
-
runOf({ workflowPath: '.github/workflows/a.yml', sha: 'a', tree: 't', jobs: [job('unit', 'success', 0, 1)] }),
|
|
156
|
-
runOf({ workflowPath: '.github/workflows/b.yml', sha: 'b', tree: 't', jobs: [job('unit', 'success', 0, 2)] }),
|
|
157
|
-
runOf({ workflowPath: '.github/workflows/a.yml', sha: 'q', tree: 't', start: 10,
|
|
158
|
-
branch: 'mergify/merge-queue/1', jobs: [job('unit', 'success', 10, 1)] }),
|
|
159
|
-
])
|
|
160
|
-
assert.equal(a.yield.length, 2)
|
|
161
|
-
assert.deepEqual(a.reproved.byJob, { 'CI [.github/workflows/a.yml] / unit': 1 })
|
|
162
|
-
assert.deepEqual(a.criticalPath.lastJob, {
|
|
163
|
-
'CI [.github/workflows/a.yml] / unit': 1,
|
|
164
|
-
'CI [.github/workflows/b.yml] / unit': 1,
|
|
165
|
-
})
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
test('history and profile both exclude unknown or inconsistent setup measurements', () => {
|
|
169
|
-
const variants = [
|
|
170
|
-
{ name: 'missing end', steps: [{ name: 'Test', started: at(0), completed: null }] },
|
|
171
|
-
{ name: 'negative step', steps: [{ name: 'Test', started: at(2), completed: at(1) }] },
|
|
172
|
-
{ name: 'out of bounds', steps: [{ name: 'Test', started: at(9), completed: at(11) }] },
|
|
173
|
-
{ name: 'overlap', steps: [{ name: 'Install deps', started: at(0), completed: at(4) }, { name: 'Test', started: at(3), completed: at(5) }] },
|
|
174
|
-
{ name: 'no steps', steps: [] },
|
|
175
|
-
{ name: 'untimed job', completed: null, steps: [{ name: 'Test', started: at(0), completed: at(5) }] },
|
|
176
|
-
]
|
|
177
|
-
const runs = [1, 2, 3].map((i) => runOf({ tree: `t${i}`, jobs: [
|
|
178
|
-
...variants.map((j) => ({ conclusion: 'success', created: at(0), started: at(0), completed: at(10), ...j })),
|
|
179
|
-
job('measured', 'success', 0, 1, 0.95),
|
|
180
|
-
] }))
|
|
181
|
-
const a = analyze(runs)
|
|
182
|
-
assert.deepEqual(a.setup.map((e) => e.job), ['measured'])
|
|
183
|
-
assert.equal(a.dataGaps.timings.filter((g) => g.reason.startsWith('setup/work')).length, 18)
|
|
184
|
-
for (const j of profileRun(runs, runs[0].id).jobs.slice(0, variants.length)) {
|
|
185
|
-
assert.equal(j.workS, null, j.name)
|
|
186
|
-
assert.equal(j.setupS, null, j.name)
|
|
187
|
-
}
|
|
188
|
-
assert.match(render(a), /18 unavailable timing measurement/)
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
test('history scheduling keeps zero, excludes negative/missing/skipped intervals, and reports no samples as unknown', () => {
|
|
192
|
-
const r = runOf({ jobs: [
|
|
193
|
-
job('zero', 'success', 0, 1),
|
|
194
|
-
{ ...job('delay', 'success', 2, 1), created: at(0) },
|
|
195
|
-
{ ...job('negative', 'success', 0, 1), created: at(2) },
|
|
196
|
-
{ ...job('missing', 'success', 0, 1), created: null },
|
|
197
|
-
{ ...job('skipped', 'skipped', 99, 0), created: at(0) },
|
|
198
|
-
] })
|
|
199
|
-
const a = analyze([r])
|
|
200
|
-
assert.deepEqual(a.queue, { samples: 2, p50: 1, p90: 0 })
|
|
201
|
-
assert.equal(a.dataGaps.timings.filter((g) => g.reason === 'created-to-start interval unavailable').length, 2)
|
|
202
|
-
const unknown = analyze([runOf({ jobs: [r.jobs[2]] })])
|
|
203
|
-
assert.deepEqual(unknown.queue, { samples: 0, p50: null, p90: null })
|
|
204
|
-
assert.match(render(unknown), /0 measured job\(s\), p50 unknown, p90 unknown/)
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
test('history does not invent PR waits from missing SHAs or invalid creation intervals', () => {
|
|
208
|
-
const a = analyze([
|
|
209
|
-
runOf({ sha: null, start: 0, jobs: [job('test', 'success', 0, 1)] }),
|
|
210
|
-
runOf({ sha: null, start: 10, jobs: [job('test', 'success', 10, 3)] }),
|
|
211
|
-
runOf({ sha: 'invalid', start: 20, jobs: [job('test', 'success', 0, 1)] }),
|
|
212
|
-
runOf({ sha: 'missing', created: null, jobs: [job('test', 'success', 0, 1)] }),
|
|
213
|
-
])
|
|
214
|
-
assert.equal(a.criticalPath.prCommits, 2)
|
|
215
|
-
assert.equal(a.criticalPath.p50, 2)
|
|
216
|
-
assert.equal(a.dataGaps.timings.filter((g) => g.reason === 'PR creation-to-completion interval unavailable').length, 2)
|
|
217
|
-
const unknown = analyze([])
|
|
218
|
-
assert.deepEqual(unknown.criticalPath, { prCommits: 0, p50: null, p90: null, lastJob: {} })
|
|
219
|
-
assert.match(render(unknown), /wait p50 \*\*unknown\*\*, p90 \*\*unknown\*\*/)
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
test('critical path: PR wait runs from the first run created to the last job done; queue runs excluded', () => {
|
|
223
|
-
const a = analyze([
|
|
224
|
-
runOf({ sha: 's1', tree: 't1', start: 0, jobs: [job('lint', 'success', 0, 1), job('unit', 'success', 0, 6)] }),
|
|
225
|
-
runOf({ sha: 's1', tree: 't1', workflow: 'Other', start: 1, jobs: [job('smoke', 'success', 1, 3)] }),
|
|
226
|
-
runOf({ sha: 'q1', tree: 't1', branch: 'mergify/merge-queue/1', start: 10, jobs: [job('unit', 'success', 10, 30)] }),
|
|
227
|
-
])
|
|
228
|
-
assert.equal(a.criticalPath.prCommits, 1)
|
|
229
|
-
assert.equal(a.criticalPath.p50, 6)
|
|
230
|
-
assert.deepEqual(a.criticalPath.lastJob, { unit: 1 })
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
test('setup share: time between steps counts as overhead, not as work', () => {
|
|
234
|
-
// A 1-minute job whose only work step lasts 3s; the rest is runner start and gaps.
|
|
235
|
-
const j = (start) => ({ name: 'ledger', conclusion: 'success', created: at(start), started: at(start), completed: at(start + 1),
|
|
236
|
-
steps: [{ name: 'Run node coverage-ledger.mjs validate', conclusion: 'success', started: at(start + 0.5), completed: at(start + 0.55) }] })
|
|
237
|
-
const a = analyze([1, 2, 3].map((i) => runOf({ sha: `s${i}`, tree: `t${i}`, jobs: [j(0)] })))
|
|
238
|
-
assert.equal(a.setup[0]?.job, 'ledger')
|
|
239
|
-
assert.ok(a.setup[0].setupShare > 0.94)
|
|
240
|
-
})
|
|
241
|
-
|
|
242
|
-
test('critical path: a roll-up job finishing last is skipped for the last real job', () => {
|
|
243
|
-
const a = analyze([
|
|
244
|
-
runOf({ sha: 's1', tree: 't1', jobs: [job('unit', 'success', 0, 6), job('all-gates', 'success', 6, 0.2)] }),
|
|
245
|
-
])
|
|
246
|
-
assert.deepEqual(a.criticalPath.lastJob, { unit: 1 })
|
|
247
|
-
assert.equal(a.criticalPath.p50, 6)
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
test('stuck: a job past 6h is reported and kept out of the time figures', () => {
|
|
251
|
-
const a = analyze([
|
|
252
|
-
runOf({ sha: 's1', tree: 't1', jobs: [job('typecheck', 'cancelled', 0, 1440), job('unit', 'success', 0, 3)] }),
|
|
253
|
-
])
|
|
254
|
-
assert.equal(a.stuck.length, 1)
|
|
255
|
-
assert.equal(a.stuck[0].job, 'typecheck')
|
|
256
|
-
assert.equal(a.criticalPath.p50, 3)
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
test('render: says the sample is too small instead of silently listing no zero-yield jobs', () => {
|
|
260
|
-
const md = render(analyze([runOf({ sha: 's1', tree: 't1', jobs: [job('unit', 'success', 0, 3)] })]))
|
|
261
|
-
assert.match(md, /too few to call any job zero-yield/)
|
|
262
|
-
const big = render(analyze([runOf({ sha: 's1', tree: 't1', jobs: [job('unit', 'success', 0, 3)] })], { minTrees: 1 }))
|
|
263
|
-
assert.doesNotMatch(big, /too few/)
|
|
264
|
-
assert.match(big, /No sole catch.*`unit`/)
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
test('render: every section is present and the report calls itself report-only', () => {
|
|
268
|
-
const md = render(analyze([runOf({ sha: 's1', tree: 't1', jobs: [job('unit', 'success', 0, 3)] })]), 'o/r')
|
|
269
|
-
for (const h of ['# CI run audit — o/r', '## 1. Re-proved trees', '## 2. Catch yield', '## 3. Overlap', '## 4. Setup share', '## 5. Critical path', '## 6. Stuck jobs']) assert.match(md, new RegExp(h.replace(/[.()]/g, '\\$&')))
|
|
270
|
-
assert.match(md, /Report-only/)
|
|
271
|
-
})
|
|
272
|
-
|
|
273
|
-
test('unknown trees never prove repeats, SHA fallbacks are separate from verified trees and missing SHAs do not collapse', () => {
|
|
274
|
-
const runs = [
|
|
275
|
-
runOf({ sha: 'same', tree: null, jobs: [job('unit', 'failure', 0, 2)] }),
|
|
276
|
-
runOf({ sha: 'same', tree: null, start: 5, jobs: [job('unit', 'success', 5, 2)] }),
|
|
277
|
-
runOf({ sha: 'different', tree: 'same', start: 10, jobs: [job('unit', 'failure', 10, 2)] }),
|
|
278
|
-
runOf({ sha: null, tree: null, start: 15, jobs: [job('unit', 'failure', 15, 2)] }),
|
|
279
|
-
runOf({ sha: null, tree: null, start: 20, jobs: [job('unit', 'success', 20, 2)] }),
|
|
280
|
-
]
|
|
281
|
-
const a = analyze(runs)
|
|
282
|
-
assert.equal(a.reproved.reprovedMin, 0)
|
|
283
|
-
assert.deepEqual([a.yield[0].trees, a.yield[0].failed, a.yield[0].flaky], [4, 2, 1])
|
|
284
|
-
assert.deepEqual(a.dataGaps.unknownTrees.map((r) => r.runId), [runs[0].id, runs[1].id, runs[3].id, runs[4].id])
|
|
285
|
-
assert.match(render(a), /no verified tree; excluded from identical-tree repeat evidence/)
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
test('metadata repeat identity uses workflow ID instead of a nonunique display name', () => {
|
|
289
|
-
const a = analyze([
|
|
290
|
-
runOf({ workflowId: 1, sha: 'a', tree: 't', jobs: [job('unit', 'success', 0, 2)] }),
|
|
291
|
-
runOf({ workflowId: 2, sha: 'b', tree: 't', start: 5, jobs: [job('unit', 'success', 5, 2)] }),
|
|
292
|
-
runOf({ workflowId: 1, sha: 'c', tree: 't', start: 10, jobs: [job('unit', 'success', 10, 2)] }),
|
|
293
|
-
])
|
|
294
|
-
assert.equal(a.reproved.reprovedMin, 2)
|
|
295
|
-
})
|
|
296
|
-
|
|
297
|
-
test('same PR head across different bases is a metadata candidate with an explicit tested-checkout gap', () => {
|
|
298
|
-
const runs = [
|
|
299
|
-
runOf({ id: 61, sha: 'same-head', tree: 'head-tree', baseSha: 'base-a', jobs: [job('unit', 'success', 0, 2)] }),
|
|
300
|
-
runOf({ id: 62, sha: 'same-head', tree: 'head-tree', baseSha: 'base-b', start: 5, jobs: [job('unit', 'success', 5, 2)] }),
|
|
301
|
-
]
|
|
302
|
-
const a = analyze(runs)
|
|
303
|
-
assert.equal(a.reproved.reprovedMin, 2)
|
|
304
|
-
assert.equal(a.reproved.identity, 'run-metadata-tree-candidates')
|
|
305
|
-
assert.equal(a.reproved.testedCheckoutVerified, false)
|
|
306
|
-
assert.deepEqual(a.dataGaps.testedCheckouts.map((r) => r.runId), [61, 62])
|
|
307
|
-
assert.match(render(a), /not verified repeated tested checkouts/)
|
|
308
|
-
assert.match(render(a), /PR synthetic merges and explicit checkout refs may differ/)
|
|
309
|
-
assert.match(profileRun(runs, 61).dataGaps.testedCheckouts[0].reason, /not reusable gate proof/)
|
|
310
|
-
})
|
|
311
|
-
|
|
312
|
-
test('profile separates dispatch and queue intervals, includes overhead gaps, sorts steps and retains job identity', () => {
|
|
313
|
-
const r = runOf({ id: 51, attempt: 2, url: 'https://github.com/o/r/actions/runs/51',
|
|
314
|
-
workflowId: 8, workflowPath: '.github/workflows/ci.yml', conclusion: 'failure', sha: 'abc', tree: 't', jobs: [{
|
|
315
|
-
id: 501, name: 'matrix (linux)', attempt: 2, url: 'https://github.com/o/r/actions/runs/51/job/501',
|
|
316
|
-
runnerName: 'slot-3', runnerLabels: ['self-hosted', 'linux'], conclusion: 'failure',
|
|
317
|
-
created: at(3), started: at(5), completed: at(15), steps: [
|
|
318
|
-
{ name: 'Set up job', started: at(5), completed: at(6), conclusion: 'success', number: 1 },
|
|
319
|
-
{ name: 'Unit tests', started: at(7), completed: at(11), conclusion: 'success', number: 2 },
|
|
320
|
-
{ name: 'Install dependencies', started: at(11), completed: at(13), conclusion: 'success', number: 3 },
|
|
321
|
-
{ name: 'Smoke', started: at(13), completed: at(14), conclusion: 'failure', number: 4 },
|
|
322
|
-
{ name: 'Publish', conclusion: 'skipped', number: 5 },
|
|
323
|
-
],
|
|
324
|
-
}] })
|
|
325
|
-
const p = profileRun([r], '51'), j = p.jobs[0]
|
|
326
|
-
assert.deepEqual([j.dispatchDelayS, j.queueS, j.elapsedS, j.setupS, j.workS], [180, 120, 600, 300, 300])
|
|
327
|
-
assert.deepEqual(j.steps.map((s) => [s.name, s.durationS]), [['Unit tests', 240], ['Install dependencies', 120], ['Set up job', 60], ['Smoke', 60], ['Publish', 0]])
|
|
328
|
-
assert.deepEqual([p.runId, p.attempt, p.workflowId, j.id, j.attempt, j.conclusion, j.runUrl], [51, 2, 8, 501, 2, 'failure', r.url])
|
|
329
|
-
assert.deepEqual([j.runnerName, j.runnerLabels], ['slot-3', ['self-hosted', 'linux']])
|
|
330
|
-
assert.deepEqual(j.timingGaps, [])
|
|
331
|
-
assert.match(renderProfile(p), /not proven runner queue time/)
|
|
332
|
-
assert.match(renderProfile(p), /Heuristic: job elapsed/)
|
|
333
|
-
})
|
|
334
|
-
|
|
335
|
-
test('profile nulls unavailable timing instead of inventing zero work or runner attribution', () => {
|
|
336
|
-
const r = runOf({ id: 52, sha: 's', tree: null, jobs: [
|
|
337
|
-
{ name: 'pending', conclusion: null, created: at(2), steps: [{ name: 'Tests', started: at(3) }] },
|
|
338
|
-
{ name: 'no steps', started: at(5), completed: at(6), steps: [] },
|
|
339
|
-
{ name: 'bad ordering', created: at(5), started: at(4), completed: at(3), steps: [{ name: 'Tests', started: at(4), completed: at(3) }] },
|
|
340
|
-
{ name: 'outside job window', started: at(5), completed: at(6), steps: [
|
|
341
|
-
{ name: 'Tests', started: at(1), completed: at(2) },
|
|
342
|
-
] },
|
|
343
|
-
{ name: 'overlapping work', started: at(0), completed: at(1), steps: [
|
|
344
|
-
{ name: 'Tests A', started: at(0), completed: at(1) }, { name: 'Tests B', started: at(0), completed: at(1) },
|
|
345
|
-
] },
|
|
346
|
-
{ name: 'overlapping setup and work', started: at(0), completed: at(10), steps: [
|
|
347
|
-
{ name: 'Install dependencies', started: at(0), completed: at(8) },
|
|
348
|
-
{ name: 'Tests', started: at(5), completed: at(10) },
|
|
349
|
-
] },
|
|
350
|
-
] })
|
|
351
|
-
const p = profileRun([r], 52)
|
|
352
|
-
for (const j of p.jobs) {
|
|
353
|
-
assert.equal(j.workS, null, j.name)
|
|
354
|
-
assert.equal(j.setupS, null, j.name)
|
|
355
|
-
assert.equal(j.runnerLabels, null, j.name)
|
|
356
|
-
assert.ok(j.timingGaps.length > 0, j.name)
|
|
357
|
-
}
|
|
358
|
-
assert.equal(p.jobs[0].elapsedS, null)
|
|
359
|
-
assert.equal(p.jobs[1].elapsedS, 60)
|
|
360
|
-
assert.equal(p.jobs[2].queueS, null)
|
|
361
|
-
assert.equal(p.jobs[2].steps[0].durationS, null)
|
|
362
|
-
assert.throws(() => profileRun([r], 99), /run 99 not found/)
|
|
363
|
-
assert.throws(() => profileRun([r, r], 52), /multiple records for run 52/)
|
|
364
|
-
})
|
|
365
|
-
|
|
366
|
-
// Only the external GitHub REST boundary is substituted. All collection and analysis run.
|
|
367
|
-
function provider(routes) {
|
|
368
|
-
const calls = []
|
|
369
|
-
return {
|
|
370
|
-
calls,
|
|
371
|
-
api: async (endpoint) => {
|
|
372
|
-
calls.push(endpoint)
|
|
373
|
-
assert.ok(routes.has(endpoint), `unexpected GitHub request: ${endpoint}`)
|
|
374
|
-
const response = routes.get(endpoint)
|
|
375
|
-
if (response instanceof Error) throw response
|
|
376
|
-
return structuredClone(response)
|
|
377
|
-
},
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
const rawRun = (id, extra = {}) => ({
|
|
381
|
-
id, run_attempt: 1, name: 'CI', workflow_id: 8, path: '.github/workflows/ci.yml',
|
|
382
|
-
html_url: `https://github.com/o/r/actions/runs/${id}`, head_sha: 'a'.repeat(40),
|
|
383
|
-
head_branch: 'feat/x', event: 'pull_request', status: 'completed', conclusion: 'success',
|
|
384
|
-
created_at: at(id * 10), run_started_at: at(id * 10 + 1), ...extra,
|
|
385
|
-
})
|
|
386
|
-
const rawJob = (id, extra = {}) => ({
|
|
387
|
-
id, run_attempt: 1, name: 'unit', status: 'completed', conclusion: 'success',
|
|
388
|
-
html_url: `https://github.com/o/r/actions/runs/1/job/${id}`,
|
|
389
|
-
created_at: at(1), started_at: at(2), completed_at: at(4),
|
|
390
|
-
runner_id: 3, runner_name: 'slot-3', runner_group_id: 4, runner_group_name: 'linux-pool',
|
|
391
|
-
labels: ['self-hosted', 'linux', 'x64'], steps: [
|
|
392
|
-
{ number: 1, name: 'Set up job', conclusion: 'success', started_at: at(2), completed_at: at(2.5) },
|
|
393
|
-
{ number: 2, name: 'Unit tests', conclusion: 'success', started_at: at(2.5), completed_at: at(4) },
|
|
394
|
-
], ...extra,
|
|
395
|
-
})
|
|
396
|
-
const jobsPath = (id, page = 1, attempt = 1) => `repos/o/r/actions/runs/${id}/attempts/${attempt}/jobs?per_page=100&page=${page}`
|
|
397
|
-
const historyPath = (page = 1) => `repos/o/r/actions/runs?status=completed&per_page=100&page=${page}`
|
|
398
|
-
const commitPath = (sha) => `repos/o/r/commits/${sha}`
|
|
399
|
-
const notFound = () => Object.assign(new Error('commit gone'), { status: 404 })
|
|
400
|
-
|
|
401
|
-
test('collector resolves and memoizes commit trees, preserves deleted queue unknowns and does not infer equality from SHA', async () => {
|
|
402
|
-
const deleted = 'b'.repeat(40), verified = 'c'.repeat(40)
|
|
403
|
-
const raw = [rawRun(1), rawRun(2, { head_branch: 'mergify/merge-queue/1' }),
|
|
404
|
-
rawRun(3, { head_sha: deleted }), rawRun(4, { head_sha: deleted }),
|
|
405
|
-
rawRun(5, { head_sha: verified, head_commit: { tree_id: 'other-tree' } })]
|
|
406
|
-
const routes = new Map([
|
|
407
|
-
['repos/o/r', { default_branch: 'main' }], [historyPath(), { workflow_runs: raw }],
|
|
408
|
-
[commitPath(raw[0].head_sha), { commit: { tree: { sha: 'same-tree' } } }],
|
|
409
|
-
[commitPath(deleted), notFound()],
|
|
410
|
-
...raw.map((r) => [jobsPath(r.id), { total_count: 1, jobs: [rawJob(r.id, {
|
|
411
|
-
created_at: at(r.id * 10), started_at: at(r.id * 10 + 1), completed_at: at(r.id * 10 + 3),
|
|
412
|
-
})] }]),
|
|
413
|
-
])
|
|
414
|
-
const gh = provider(routes)
|
|
415
|
-
const runs = await fetchRuns('o/r', 5, gh.api)
|
|
416
|
-
const a = analyze(runs)
|
|
417
|
-
assert.deepEqual(runs.map((r) => r.tree), ['same-tree', 'same-tree', null, null, 'other-tree'])
|
|
418
|
-
assert.equal(gh.calls.filter((p) => p === commitPath(raw[0].head_sha)).length, 1)
|
|
419
|
-
assert.equal(gh.calls.filter((p) => p === commitPath(deleted)).length, 1)
|
|
420
|
-
assert.equal(gh.calls.includes(commitPath(verified)), false)
|
|
421
|
-
assert.equal(a.reproved.reprovedMin, 2)
|
|
422
|
-
assert.deepEqual(a.reproved.byLanePair, { 'pr→queue': 2 })
|
|
423
|
-
assert.deepEqual(a.dataGaps.unknownTrees.map((r) => r.runId), [3, 4])
|
|
424
|
-
assert.match(a.dataGaps.unknownTrees[0].reason, /HTTP 404.*deleted queue/)
|
|
425
|
-
assert.equal(runs[0].treeSource, 'commits.commit.tree.sha')
|
|
426
|
-
const again = await fetchRuns('o/r', 1, gh.api)
|
|
427
|
-
assert.equal(again[0].tree, 'same-tree')
|
|
428
|
-
assert.equal(gh.calls.filter((p) => p === commitPath(raw[0].head_sha)).length, 2, 'cache is scoped to one collection')
|
|
429
|
-
})
|
|
430
|
-
|
|
431
|
-
test('profile collector paginates beyond 100 jobs for the selected attempt and preserves workflow/runner identity', async () => {
|
|
432
|
-
const r = rawRun(6, { run_attempt: 2, conclusion: 'failure', head_commit: { tree_id: 't' } })
|
|
433
|
-
const first = Array.from({ length: 100 }, (_, i) => rawJob(i + 1, { name: `matrix ${i}`, run_attempt: 2 }))
|
|
434
|
-
const last = rawJob(101, { name: 'slow failure', conclusion: 'failure', run_attempt: 2,
|
|
435
|
-
created_at: at(63), started_at: at(65), completed_at: at(70), steps: [
|
|
436
|
-
{ number: 1, name: 'Install dependencies', started_at: at(65), completed_at: at(67), conclusion: 'success' },
|
|
437
|
-
{ number: 2, name: 'Tests', started_at: at(67), completed_at: at(70), conclusion: 'failure' },
|
|
438
|
-
] })
|
|
439
|
-
const gh = provider(new Map([
|
|
440
|
-
['repos/o/r', { default_branch: 'main' }], ['repos/o/r/actions/runs/6', r],
|
|
441
|
-
[jobsPath(6, 1, 2), { total_count: 101, jobs: first }],
|
|
442
|
-
[jobsPath(6, 2, 2), { total_count: 101, jobs: [last] }],
|
|
443
|
-
]))
|
|
444
|
-
const runs = await fetchRun('o/r', 6, gh.api), row = runs[0]
|
|
445
|
-
assert.deepEqual(row.jobs.map((j) => j.id), [...first.map((j) => j.id), 101])
|
|
446
|
-
assert.equal(analyze(runs).yield.find((j) => j.job === 'slow failure').sole, 1)
|
|
447
|
-
assert.deepEqual([row.id, row.attempt, row.workflowId, row.workflowPath, row.url], [6, 2, 8, '.github/workflows/ci.yml', r.html_url])
|
|
448
|
-
assert.deepEqual([row.jobs[100].runnerId, row.jobs[100].runnerName, row.jobs[100].runnerGroupId, row.jobs[100].runnerGroupName], [3, 'slot-3', 4, 'linux-pool'])
|
|
449
|
-
assert.deepEqual(row.jobs[100].runnerLabels, ['self-hosted', 'linux', 'x64'])
|
|
450
|
-
const p = profileRun(runs, 6), lastProfile = p.jobs.find((j) => j.name === 'slow failure')
|
|
451
|
-
assert.deepEqual([lastProfile.attempt, lastProfile.dispatchDelayS, lastProfile.queueS, lastProfile.elapsedS], [2, 180, 120, 300])
|
|
452
|
-
assert.equal(lastProfile.runUrl, r.html_url)
|
|
453
|
-
assert.equal(lastProfile.steps[0].number, 2)
|
|
454
|
-
assert.match(p.dataGaps.collection[0].reason, /only attempt 2 collected/)
|
|
455
|
-
assert.deepEqual(gh.calls, ['repos/o/r', 'repos/o/r/actions/runs/6', jobsPath(6, 1, 2), jobsPath(6, 2, 2)])
|
|
456
|
-
})
|
|
457
|
-
|
|
458
|
-
test('history collector paginates runs and the job collector handles absent totals without losing page two', async () => {
|
|
459
|
-
const raw = Array.from({ length: 101 }, (_, i) => rawRun(i + 1, { head_commit: { tree_id: 't' } }))
|
|
460
|
-
const routes = new Map([
|
|
461
|
-
['repos/o/r', { default_branch: 'main' }],
|
|
462
|
-
[historyPath(1), { workflow_runs: raw.slice(0, 100) }], [historyPath(2), { workflow_runs: raw.slice(100) }],
|
|
463
|
-
...raw.map((r) => [jobsPath(r.id), { jobs: [rawJob(r.id)] }]),
|
|
464
|
-
])
|
|
465
|
-
routes.set(jobsPath(101), { jobs: Array.from({ length: 100 }, (_, i) => rawJob(1000 + i)) })
|
|
466
|
-
routes.set(jobsPath(101, 2), { jobs: [rawJob(2000, { name: 'page two failure', conclusion: 'failure' })] })
|
|
467
|
-
const gh = provider(routes), runs = await fetchRuns('o/r', 101, gh.api)
|
|
468
|
-
assert.deepEqual(runs.map((r) => r.id), raw.map((r) => r.id))
|
|
469
|
-
assert.equal(runs.at(-1).jobs.at(-1).name, 'page two failure')
|
|
470
|
-
assert.equal(analyze(runs).yield.find((j) => j.job === 'page two failure').sole, 1)
|
|
471
|
-
assert.equal(gh.calls.filter((p) => p === jobsPath(101, 2)).length, 1)
|
|
472
|
-
})
|
|
473
|
-
|
|
474
|
-
test('collector explicitly marks missing commit/attempt metadata and never substitutes head SHA for a tree', async () => {
|
|
475
|
-
const r = rawRun(7, { run_attempt: undefined })
|
|
476
|
-
const latest = 'repos/o/r/actions/runs/7/jobs?filter=latest&per_page=100&page=1'
|
|
477
|
-
const gh = provider(new Map([
|
|
478
|
-
['repos/o/r', { default_branch: 'main' }], ['repos/o/r/actions/runs/7', r],
|
|
479
|
-
[latest, { total_count: 1, jobs: [rawJob(701)] }], [commitPath(r.head_sha), { commit: {} }],
|
|
480
|
-
]))
|
|
481
|
-
const [row] = await fetchRun('o/r', 7, gh.api)
|
|
482
|
-
assert.equal(row.tree, null)
|
|
483
|
-
assert.match(row.treeUnknownReason, /no tree SHA/)
|
|
484
|
-
assert.equal(row.attempt, null)
|
|
485
|
-
assert.match(row.collectionGaps[0].reason, /attempt unavailable/)
|
|
486
|
-
assert.equal(profileRun([row], 7).jobs[0].attempt, 1, 'job API attempt remains authoritative when run attempt is missing')
|
|
487
|
-
})
|
|
488
|
-
|
|
489
|
-
test('collector rejects provider errors, inconsistent pagination and cross-attempt jobs instead of returning partial evidence', async () => {
|
|
490
|
-
const cases = [
|
|
491
|
-
['permission', { jobs: [], total_count: 0 }, Object.assign(new Error('denied'), { status: 403 }), /denied/],
|
|
492
|
-
['short page', { jobs: [rawJob(1)], total_count: 2 }, null, /incomplete jobs response/],
|
|
493
|
-
['duplicate IDs', { jobs: [rawJob(1), rawJob(1)], total_count: 2 }, null, /duplicate job/],
|
|
494
|
-
['cross attempt', { jobs: [rawJob(1, { run_attempt: 2 })], total_count: 1 }, null, /attempt mismatch/],
|
|
495
|
-
['oversize', { jobs: [], total_count: 10001 }, null, /job pagination limit/],
|
|
496
|
-
]
|
|
497
|
-
for (const [name, jobs, error, expected] of cases) {
|
|
498
|
-
const r = rawRun(1, error ? {} : { head_commit: { tree_id: 't' } })
|
|
499
|
-
const gh = provider(new Map([
|
|
500
|
-
['repos/o/r', { default_branch: 'main' }], ['repos/o/r/actions/runs/1', r],
|
|
501
|
-
[jobsPath(1), jobs], [commitPath(r.head_sha), error],
|
|
502
|
-
]))
|
|
503
|
-
await assert.rejects(fetchRun('o/r', 1, gh.api), expected, name)
|
|
504
|
-
}
|
|
505
|
-
const gh = provider(new Map())
|
|
506
|
-
await assert.rejects(fetchRuns('../bad', 1, gh.api), /--repo must be owner\/name/)
|
|
507
|
-
await assert.rejects(fetchRuns('o/r', 2001, gh.api), /--runs must be an integer/)
|
|
508
|
-
await assert.rejects(fetchRun('o/r', 0, gh.api), /--profile-run must be an integer/)
|
|
509
|
-
assert.deepEqual(gh.calls, [], 'validate before network access')
|
|
510
|
-
})
|
|
511
|
-
|
|
512
|
-
test('collector refuses changed page totals and an exhausted page budget but accepts an exact full final page', async () => {
|
|
513
|
-
const r = rawRun(1, { head_commit: { tree_id: 't' } })
|
|
514
|
-
const base = [['repos/o/r', { default_branch: 'main' }], ['repos/o/r/actions/runs/1', r]]
|
|
515
|
-
const page = (n) => Array.from({ length: 100 }, (_, i) => rawJob((n - 1) * 100 + i + 1))
|
|
516
|
-
const exact = provider(new Map([...base, [jobsPath(1), { total_count: 100, jobs: page(1) }]]))
|
|
517
|
-
const [full] = await fetchRun('o/r', 1, exact.api)
|
|
518
|
-
assert.deepEqual(full.jobs.map((j) => j.id), page(1).map((j) => j.id))
|
|
519
|
-
assert.equal(exact.calls.includes(jobsPath(1, 2)), false, 'no extra page required for a verified total')
|
|
520
|
-
const changed = provider(new Map([...base,
|
|
521
|
-
[jobsPath(1), { total_count: 101, jobs: page(1) }], [jobsPath(1, 2), { total_count: 102, jobs: [rawJob(101)] }],
|
|
522
|
-
]))
|
|
523
|
-
await assert.rejects(fetchRun('o/r', 1, changed.api), /job total changed/)
|
|
524
|
-
const bounded = provider(new Map([...base, ...Array.from({ length: 100 }, (_, i) => [jobsPath(1, i + 1), { jobs: page(i + 1) }])]))
|
|
525
|
-
await assert.rejects(fetchRun('o/r', 1, bounded.api), /pagination limit reached.*refusing partial/)
|
|
526
|
-
assert.equal(bounded.calls.at(-1), jobsPath(1, 100))
|
|
527
|
-
assert.equal(bounded.calls.includes(jobsPath(1, 101)), false)
|
|
528
|
-
})
|
|
529
|
-
|
|
530
|
-
test('history pagination rejects duplicate runs rather than counting the same execution twice', async () => {
|
|
531
|
-
const r = rawRun(1)
|
|
532
|
-
const gh = provider(new Map([
|
|
533
|
-
['repos/o/r', { default_branch: 'main' }], [historyPath(), { workflow_runs: [r, r] }],
|
|
534
|
-
]))
|
|
535
|
-
await assert.rejects(fetchRuns('o/r', 2, gh.api), /duplicate run ID/)
|
|
536
|
-
assert.equal(gh.calls.some((p) => p.includes('/jobs?')), false)
|
|
537
|
-
})
|
|
538
|
-
|
|
539
|
-
const CLI = fileURLToPath(new URL('./ci-run-audit.mjs', import.meta.url))
|
|
540
|
-
function cli(argv) {
|
|
541
|
-
return spawnSync(process.execPath, [CLI, ...argv], { encoding: 'utf8', timeout: 10000, env: { ...process.env, PATH: '' } })
|
|
542
|
-
}
|
|
543
|
-
function savedFixture(t, data) {
|
|
544
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-run-audit-'))
|
|
545
|
-
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
|
|
546
|
-
const file = path.join(dir, 'runs.json')
|
|
547
|
-
fs.writeFileSync(file, JSON.stringify(data))
|
|
548
|
-
return { dir, file }
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
test('saved-data CLI selects a run, reports all jobs including gates, saves raw rows, and remains re-auditable offline', (t) => {
|
|
552
|
-
const target = runOf({ id: 71, attempt: 3, sha: 's1', tree: null, url: 'https://github.com/o/r/actions/runs/71',
|
|
553
|
-
jobs: [job('unit', 'failure', 2, 5, 1), job('all-gates', 'failure', 7, 1)] })
|
|
554
|
-
const other = runOf({ id: 72, sha: 's2', tree: 't2', jobs: [job('lint', 'success', 0, 1)] })
|
|
555
|
-
const { dir, file } = savedFixture(t, [target, other]), save = path.join(dir, 'copied.json')
|
|
556
|
-
const result = cli(['--from', file, '--profile-run', '71', '--json', '--save', save])
|
|
557
|
-
assert.equal(result.status, 0, result.stderr)
|
|
558
|
-
assert.equal(result.stderr, '')
|
|
559
|
-
const p = JSON.parse(result.stdout)
|
|
560
|
-
assert.deepEqual([p.mode, p.runId, p.attempt, p.runUrl], ['profile', 71, 3, target.url])
|
|
561
|
-
assert.deepEqual(p.jobs.map((j) => j.name), ['unit', 'all-gates'])
|
|
562
|
-
assert.deepEqual([p.jobs[0].elapsedS, p.jobs[0].workS, p.jobs[0].setupS], [300, 240, 60])
|
|
563
|
-
assert.deepEqual(JSON.parse(fs.readFileSync(save, 'utf8')), [target, other])
|
|
564
|
-
const audit = cli(['--from', save, '--json', '--min-trees', '1'])
|
|
565
|
-
assert.equal(audit.status, 0, audit.stderr)
|
|
566
|
-
const a = JSON.parse(audit.stdout)
|
|
567
|
-
assert.equal(a.window.runs, 2)
|
|
568
|
-
assert.equal(a.yield.find((j) => j.job === 'unit').sole, 1)
|
|
569
|
-
assert.equal(a.yield.some((j) => j.job === 'all-gates'), false)
|
|
570
|
-
assert.equal(a.dataGaps.unknownTrees[0].runId, 71)
|
|
571
|
-
const markdown = cli(['--from', file, '--profile-run', '72'])
|
|
572
|
-
assert.equal(markdown.status, 0, markdown.stderr)
|
|
573
|
-
assert.match(markdown.stdout, /CI run profile: CI \/ 72/)
|
|
574
|
-
assert.doesNotMatch(markdown.stdout, /## unit/)
|
|
575
|
-
})
|
|
576
|
-
|
|
577
|
-
test('CLI validates options before fetch, rejects unsafe/unknown flags and reports useful errors without a stack trace', () => {
|
|
578
|
-
const cases = [
|
|
579
|
-
[[], /provide exactly one/],
|
|
580
|
-
[['--repo'], /missing value for --repo/],
|
|
581
|
-
[['--repo', '--json'], /missing value for --repo/],
|
|
582
|
-
[['--repo', 'https://github.com/o/r'], /--repo must be owner\/name/],
|
|
583
|
-
[['--repo', 'o/r', '--from', 'x'], /provide exactly one/],
|
|
584
|
-
[['--repo', 'o/r', '--runs', '0'], /--runs must be an integer/],
|
|
585
|
-
[['--repo', 'o/r', '--runs', '2001'], /--runs must be an integer/],
|
|
586
|
-
[['--repo', 'o/r', '--runs', '1.5'], /--runs must be an integer/],
|
|
587
|
-
[['--repo', 'o/r', '--runs', 'NaN'], /--runs must be an integer/],
|
|
588
|
-
[['--repo', 'o/r', '--min-trees', '0'], /--min-trees must be an integer/],
|
|
589
|
-
[['--repo', 'o/r', '--profile-run', '9007199254740992'], /--profile-run must be an integer/],
|
|
590
|
-
[['--repo', 'o/r', '--profile-run', '2', '--runs', '3'], /--runs is only supported/],
|
|
591
|
-
[['--from', 'x', '--runs', '3'], /--runs is only supported/],
|
|
592
|
-
[['--from', 'x', '--profile-run', '2', '--ignore-job', 'gate'], /audit-only/],
|
|
593
|
-
[['--from', 'x', '--ignore-job', '['], /Invalid regular expression/],
|
|
594
|
-
[['--from', 'x', '--json', '--json'], /duplicate argument/],
|
|
595
|
-
[['--unsafe-api', 'evil'], /unknown argument/],
|
|
596
|
-
]
|
|
597
|
-
for (const [argv, error] of cases) {
|
|
598
|
-
const result = cli(argv)
|
|
599
|
-
assert.equal(result.status, 2, argv.join(' '))
|
|
600
|
-
assert.equal(result.stdout, '', argv.join(' '))
|
|
601
|
-
assert.match(result.stderr, error)
|
|
602
|
-
assert.doesNotMatch(result.stderr, /\n\s+at /)
|
|
603
|
-
}
|
|
604
|
-
const help = cli(['--help'])
|
|
605
|
-
assert.equal(help.status, 0, help.stderr)
|
|
606
|
-
assert.match(help.stderr, /--profile-run/)
|
|
607
|
-
const fetchFailure = cli(['--repo', 'o/r', '--profile-run', '1'])
|
|
608
|
-
assert.equal(fetchFailure.status, 2)
|
|
609
|
-
assert.match(fetchFailure.stderr, /gh ENOENT/)
|
|
610
|
-
assert.equal(fetchFailure.stdout, '')
|
|
611
|
-
})
|
|
612
|
-
|
|
613
|
-
test('saved-data CLI refuses malformed data, absent/ambiguous run IDs and unwritable output with no report', (t) => {
|
|
614
|
-
const { dir, file } = savedFixture(t, [])
|
|
615
|
-
const row = runOf({ id: 81, sha: 's', tree: 't', jobs: [job('unit', 'success', 0, 1)] })
|
|
616
|
-
const cases = [
|
|
617
|
-
['{bad', [], /JSON/],
|
|
618
|
-
[JSON.stringify({ runs: [] }), [], /array of runs/],
|
|
619
|
-
[JSON.stringify([{ id: 1, workflow: 'CI', jobs: {} }]), [], /jobs array/],
|
|
620
|
-
[JSON.stringify([{ ...row, jobs: [{ name: 'unit', started: 'invalid date' }] }]), [], /invalid job started timestamp/],
|
|
621
|
-
[JSON.stringify([row]), ['--profile-run', '99'], /run 99 not found/],
|
|
622
|
-
[JSON.stringify([row, row]), ['--profile-run', '81'], /multiple records/],
|
|
623
|
-
[JSON.stringify([row]), ['--save', dir], /EISDIR/],
|
|
624
|
-
]
|
|
625
|
-
for (const [data, flags, error] of cases) {
|
|
626
|
-
fs.writeFileSync(file, data)
|
|
627
|
-
const result = cli(['--from', file, ...flags])
|
|
628
|
-
assert.equal(result.status, 2, result.stderr)
|
|
629
|
-
assert.equal(result.stdout, '')
|
|
630
|
-
assert.match(result.stderr, error)
|
|
631
|
-
assert.doesNotMatch(result.stderr, /\n\s+at /)
|
|
632
|
-
assert.equal(fs.readFileSync(file, 'utf8'), data)
|
|
633
|
-
}
|
|
634
|
-
const missing = cli(['--from', path.join(dir, 'absent.json')])
|
|
635
|
-
assert.equal(missing.status, 2)
|
|
636
|
-
assert.match(missing.stderr, /ENOENT/)
|
|
637
|
-
})
|