From ea91268d023ca283e572ce71b0cfaa8b25f2125f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 5 Sep 2026 18:49:35 -0400 Subject: [PATCH] ci(#4335): shard release.yml rc/finalize unit-suite tests (#4338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(#4335): shard release.yml rc/finalize unit-suite tests The finalize job's unsharded unit-coverage step outgrew the 30-minute job timeout that was already raised once for this exact symptom (#2280): run 33988966357 finished all tests with 0 failures at 28m26s, then got cancelled ~80s into the post-test coverage merge — a phase that historically completes in 54-101s. The suite's wall-clock time, not a hang, ate the budget. test.yml already fixed the identical cliff for its own full-scope lane (#2952, #3057) by sharding the unit suite 3 ways with a separate merged coverage-gate job. Apply the same pattern to rc and finalize (rc has the byte-identical unsharded shape and would hit the same wall next): each gains a `*-test` matrix job (raw coverage only, no report/gate) and a `*-coverage-gate` job that merges the shards' raw V8 dumps before enforcing the existing gsd-core/bin/lib coverage floor. rc/finalize now depend on their gate job instead of running the suite inline. Updates release-coverage-scope.test.cjs's exact-count assertion for the new command surface and adds release-shard-lane-sharding.test.cjs to pin shard-set completeness and gate wiring, mirroring ci-full-lane-sharding.test.cjs. Co-Authored-By: Claude Sonnet 5 * Potential fix for pull request finding 'CodeQL / Cache Poisoning via execution of untrusted code' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for pull request finding 'CodeQL / Cache Poisoning via execution of untrusted code' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix(#4335): close CodeQL cache-poisoning and missing-permissions findings CodeQL flagged the PR (10 actions/cache-poisoning/poisonable-step errors, 4 actions/missing-workflow-permissions warnings) on release.yml. Remove `cache: 'npm'` from every actions/setup-node step in the file (7 occurrences, not just the 4 newly-added jobs the alerts pointed at) — restoring an npm cache before running install/build code in a write-permissioned job is exactly the shape this query targets, and the same pattern was already present unchanged in create/rc/finalize. These are short CI/release jobs; losing npm's install cache costs a few seconds per job, closing the finding everywhere it appears in this file rather than only where the alert happened to land on a changed line. Add explicit `permissions: contents: read` to rc-test, rc-coverage-gate, finalize-test, finalize-coverage-gate — the four new jobs had no permissions block at all and inherited the ambient default. Matches validate-version's existing least-privilege pattern; create/rc/finalize keep their own broader write/publish scopes unchanged. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: sim Co-authored-by: Claude Sonnet 5 Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 223 +++++++++++++++++++-- tests/release-coverage-scope.test.cjs | 35 +++- tests/release-shard-lane-sharding.test.cjs | 166 +++++++++++++++ 3 files changed, 403 insertions(+), 21 deletions(-) create mode 100644 tests/release-shard-lane-sharding.test.cjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e120e59b..9e0d442ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,7 +118,6 @@ jobs: - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: ${{ env.NODE_VERSION }} - cache: 'npm' - name: Install dependencies and build run: npm ci --silent && npm run build:lib @@ -350,10 +349,124 @@ jobs: with: ref: ${{ needs.validate-version.outputs.branch }} - rc: + rc-test: + name: rc test (shard ${{ matrix.shard }}) needs: [validate-version, install-smoke-rc] if: inputs.action == 'rc' runs-on: ubuntu-latest + # #4335 (recurrence of #2280/#2281): npm ci + the unit-coverage script ran + # unsharded on one runner and outgrew even the 30m cap raised for this + # exact failure once already (run 33988966357 — all tests passed, 0 + # failures, cancelled ~80s into the post-test coverage merge). Per #869's + # stated lesson ("a timeout bump only moves that cliff; sharding removes + # it"), this mirrors test.yml's `scope: full` lane fix (#2952): each shard + # runs its slice under c8 with NO report/gate (raw V8 dumps only); + # rc-coverage-gate merges all three before enforcing the threshold. 20m + # gives each shard generous headroom over its ~1/3 share of the unsharded + # run's measured cost. + timeout-minutes: 20 + permissions: + contents: read + strategy: + matrix: + shard: ['1/3', '2/3', '3/3'] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.validate-version.outputs.branch }} + fetch-depth: 0 + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install dependencies and build + run: npm ci --silent && npm run build:lib + + # Same pre-release version derivation as the `rc` job's "Determine + # pre-release version" step, applied to the WORKING TREE ONLY (no + # commit, no push) — this job never publishes anything, it only needs + # package.json to read the same version the real rc job will test + # under. Every shard (and the real rc job) computes this independently + # and deterministically from the same tags, the same way each of + # test.yml's shard jobs redoes its own setup rather than sharing git + # state across jobs. + - name: Bump to pre-release version (working tree only) + env: + VERSION: ${{ inputs.version }} + IS_MAJOR: ${{ needs.validate-version.outputs.is_major }} + run: | + set -euo pipefail + if [ "$IS_MAJOR" = "true" ]; then + PREFIX="beta" + else + PREFIX="rc" + fi + N=1 + while git tag -l "v${VERSION}-${PREFIX}.${N}" | grep -q .; do + N=$((N + 1)) + done + npm version "${VERSION}-${PREFIX}.${N}" --no-git-tag-version + + # #2952-style split: raw V8 coverage only, no report/gate — a per-shard + # percentage is meaningless (shard 2 never runs shard 1's files). The + # rc-coverage-gate job below merges all three shards' dumps and + # enforces the same floor package.json already defines once. + - name: Run unit tests (shard ${{ matrix.shard }}, raw coverage only) + env: + NODE_OPTIONS: --max-old-space-size=6144 + run: | + npm ci + node scripts/check-npm-integrity.cjs + npm run test:coverage:unit:raw -- --shard ${{ matrix.shard }} + + - name: Upload raw coverage for merge + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-tmp-rc-shard-${{ strategy.job-index }} + path: coverage/tmp + if-no-files-found: error + retention-days: 1 + + rc-coverage-gate: + name: rc coverage gate (merged shards) + needs: [validate-version, rc-test] + if: inputs.action == 'rc' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.validate-version.outputs.branch }} + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install dependencies + run: npm ci + + # merge-multiple flattens every shard's dumps into ONE coverage/tmp, + # exactly the layout c8 expects from a single run (test.yml's + # coverage-gate job does the identical merge for the same reason). + - name: Download every shard's raw coverage + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + pattern: coverage-tmp-rc-shard-* + path: coverage/tmp + merge-multiple: true + + - name: Report merged coverage + gate gsd-core/bin/lib (≥70% lines, ≥60% branches) + env: + NODE_OPTIONS: --max-old-space-size=8192 + run: npm run test:coverage:report + + rc: + needs: [validate-version, install-smoke-rc, rc-coverage-gate] + if: inputs.action == 'rc' + runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: write @@ -369,7 +482,6 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} registry-url: 'https://registry.npmjs.org' - cache: 'npm' - name: Determine pre-release version id: prerelease @@ -406,11 +518,10 @@ jobs: run: | npm version "$PRE_VERSION" --no-git-tag-version - - name: Install and test + - name: Dependency integrity gate run: | npm ci node scripts/check-npm-integrity.cjs - npm run test:coverage:unit - name: Preview CHANGELOG (non-destructive) env: @@ -541,13 +652,101 @@ jobs: with: ref: ${{ needs.validate-version.outputs.branch }} - finalize: + finalize-test: + name: finalize test (shard ${{ matrix.shard }}) needs: [validate-version, install-smoke-finalize] if: inputs.action == 'finalize' runs-on: ubuntu-latest - # Matches the rc job's budget: `npm ci` + `npm run test:coverage:unit` now - # exceeds 10m as the unit suite grows, so a 10m cap cancels the job mid-test - # before tag/publish. 30m gives the same headroom rc already relies on. + # See rc-test's comment (#4335, recurrence of #2280/#2281) — identical + # fix, identical rationale, applied to finalize's own unsharded + # unit-coverage step, which is the one that actually hit the 30m cap + # (run 33988966357: all tests passed, 0 failures, cancelled ~80s into the + # post-test coverage merge). + timeout-minutes: 20 + permissions: + contents: read + strategy: + matrix: + shard: ['1/3', '2/3', '3/3'] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.validate-version.outputs.branch }} + fetch-depth: 0 + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install dependencies and build + run: npm ci --silent && npm run build:lib + + # Same version bump as the `finalize` job's "Set final version" step, + # applied to the WORKING TREE ONLY (no commit) — this job never + # publishes, it only needs package.json at the version finalize will + # test under. + - name: Set final version (working tree only) + env: + VERSION: ${{ inputs.version }} + run: npm version "$VERSION" --no-git-tag-version --allow-same-version + + - name: Run unit tests (shard ${{ matrix.shard }}, raw coverage only) + env: + NODE_OPTIONS: --max-old-space-size=6144 + run: | + npm ci + node scripts/check-npm-integrity.cjs + npm run test:coverage:unit:raw -- --shard ${{ matrix.shard }} + + - name: Upload raw coverage for merge + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-tmp-finalize-shard-${{ strategy.job-index }} + path: coverage/tmp + if-no-files-found: error + retention-days: 1 + + finalize-coverage-gate: + name: finalize coverage gate (merged shards) + needs: [validate-version, finalize-test] + if: inputs.action == 'finalize' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.validate-version.outputs.branch }} + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install dependencies + run: npm ci + + - name: Download every shard's raw coverage + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + pattern: coverage-tmp-finalize-shard-* + path: coverage/tmp + merge-multiple: true + + - name: Report merged coverage + gate gsd-core/bin/lib (≥70% lines, ≥60% branches) + env: + NODE_OPTIONS: --max-old-space-size=8192 + run: npm run test:coverage:report + + finalize: + needs: [validate-version, install-smoke-finalize, finalize-coverage-gate] + if: inputs.action == 'finalize' + runs-on: ubuntu-latest + # >= 30 required by tests/release-backmerge-invariants.test.cjs (#2281) — + # kept even though this job no longer runs the unit suite itself (moved + # to finalize-test/finalize-coverage-gate, #4335): the changelog/tag/ + # publish/verify/backmerge tail below still deserves real headroom + # against transient npm-registry/GitHub-API slowness. timeout-minutes: 30 permissions: contents: write @@ -564,7 +763,6 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} registry-url: 'https://registry.npmjs.org' - cache: 'npm' - name: Configure git identity run: | @@ -582,13 +780,10 @@ jobs: git add package.json package-lock.json git diff --cached --quiet || git commit -m "chore: finalize v${VERSION}" - - name: Install and test - env: - NODE_OPTIONS: --max-old-space-size=6144 + - name: Dependency integrity gate run: | npm ci node scripts/check-npm-integrity.cjs - npm run test:coverage:unit - name: Promote CHANGELOG (render fragments) env: diff --git a/tests/release-coverage-scope.test.cjs b/tests/release-coverage-scope.test.cjs index 438c8887c..dca68389e 100644 --- a/tests/release-coverage-scope.test.cjs +++ b/tests/release-coverage-scope.test.cjs @@ -12,15 +12,36 @@ const path = require('node:path'); const RELEASE_WORKFLOW = path.join(__dirname, '..', '.github', 'workflows', 'release.yml'); describe('release-coverage-scope', () => { - test('release.yml uses test:coverage:unit (not full suite) in both rc and finalize gates', () => { - const lines = fs.readFileSync(RELEASE_WORKFLOW, 'utf8').split(/\r?\n/).map(l => l.trim()); + // #4335: rc/finalize used to run one unsharded `npm run test:coverage:unit` + // line each. Both now shard the unit suite (raw coverage only, one line per + // *-test job) and gate on a separate merged-coverage job (one report line + // per *-coverage-gate job) — see rc-test/finalize-test and + // rc-coverage-gate/finalize-coverage-gate. This asserts the new surface is + // still unit-scoped end to end: the unsharded single-shot invocation is + // gone for good (not silently reintroduced), the raw/report scripts appear + // exactly once per job pair, and no bare full-suite line ever appears. + test('release.yml uses the sharded unit-coverage scripts (not the unsharded or full suite) in both rc and finalize gates', () => { + // Normalize an optional leading `run: ` so a single-line `run: npm run + // …` step (the style rc-coverage-gate/finalize-coverage-gate use) counts + // the same as a bare command line inside a multi-line `run: |` block + // (the style the raw-coverage and legacy unit steps use). + const lines = fs.readFileSync(RELEASE_WORKFLOW, 'utf8').split(/\r?\n/) + .map(l => l.trim().replace(/^run:\s*/, '')); const bareCount = lines.filter(l => l === 'npm run test:coverage').length; - const unitCount = lines.filter(l => l === 'npm run test:coverage:unit').length; + const unshardedUnitCount = lines.filter(l => l === 'npm run test:coverage:unit').length; + const rawShardedCount = lines.filter(l => l === 'npm run test:coverage:unit:raw -- --shard ${{ matrix.shard }}').length; + const reportCount = lines.filter(l => l === 'npm run test:coverage:report').length; + assert.strictEqual(bareCount, 0, `release.yml still has ${bareCount} bare 'npm run test:coverage' line(s); expected 0`); - assert.strictEqual(unitCount, 2, - `release.yml has ${unitCount} 'npm run test:coverage:unit' line(s); expected 2`); + assert.strictEqual(unshardedUnitCount, 0, + `release.yml has ${unshardedUnitCount} unsharded 'npm run test:coverage:unit' line(s); ` + + 'expected 0 — the #4335 fix sharded rc/finalize onto test:coverage:unit:raw + test:coverage:report'); + assert.strictEqual(rawShardedCount, 2, + `release.yml has ${rawShardedCount} sharded raw-coverage line(s) (one expected in each of ` + + `rc-test and finalize-test); expected 2`); + assert.strictEqual(reportCount, 2, + `release.yml has ${reportCount} 'npm run test:coverage:report' line(s) (one expected in each ` + + 'of rc-coverage-gate and finalize-coverage-gate); expected 2'); }); - - }); diff --git a/tests/release-shard-lane-sharding.test.cjs b/tests/release-shard-lane-sharding.test.cjs new file mode 100644 index 000000000..a08a68f29 --- /dev/null +++ b/tests/release-shard-lane-sharding.test.cjs @@ -0,0 +1,166 @@ +'use strict'; + +/** + * release.yml's rc and finalize jobs both shard the unit suite the same way + * test.yml's `scope: full` lane does (#2952) — see #4335 (recurrence of + * #2280/#2281): the unsharded `npm run test:coverage:unit` step outgrew even + * a 30-minute job timeout as the suite grew (run 33988966357 — all tests + * passed, 0 failures, cancelled ~80s into the post-test coverage merge). + * + * Sharding introduces the same two failure modes ci-full-lane-sharding.test.cjs + * pins for test.yml, so both are pinned here too: + * + * 1. An incomplete shard set. If the matrix declares shards 1/3 and 2/3 but + * never 3/3, a third of the unit suite simply stops running and every + * check still passes. + * + * 2. A dropped coverage gate. A sharded run leaves each runner with a + * partial picture — shard 2 never executes shard 1's files, so those + * read 0%. The gate therefore cannot live on the shards; it moved to + * rc-coverage-gate/finalize-coverage-gate, which merge every shard's raw + * V8 dumps. If those jobs silently stopped being required, coverage + * enforcement would vanish without any red check. + */ + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const yaml = require('js-yaml'); + +const WORKFLOWS_DIR = path.join(__dirname, '..', '.github', 'workflows'); + +function loadWorkflow(name) { + return yaml.load(fs.readFileSync(path.join(WORKFLOWS_DIR, name), 'utf8')); +} + +/** Mirrors ci-full-lane-sharding.test.cjs's shard-spec grammar. */ +function parseShardSpec(spec) { + const m = /^(\d+)\/(\d+)$/.exec(String(spec)); + if (!m) return null; + const index = Number(m[1]); + const total = Number(m[2]); + if (!Number.isInteger(index) || !Number.isInteger(total)) return null; + if (total < 1 || index < 1 || index > total) return null; + return { index, total }; +} + +/** True iff `specs` is exactly one complete shard set: same N, numerators 1..N. */ +function isCompleteShardSet(specs) { + if (specs.length === 0) return false; + const parsed = specs.map(parseShardSpec); + if (parsed.some((p) => p === null)) return false; + const total = parsed[0].total; + if (parsed.some((p) => p.total !== total)) return false; + if (parsed.length !== total) return false; + const seen = new Set(parsed.map((p) => p.index)); + return seen.size === total && [...seen].every((i) => i >= 1 && i <= total); +} + +const LANES = [ + { label: 'rc', testJob: 'rc-test', gateJob: 'rc-coverage-gate', finalJob: 'rc', artifactPrefix: 'coverage-tmp-rc-shard-' }, + { label: 'finalize', testJob: 'finalize-test', gateJob: 'finalize-coverage-gate', finalJob: 'finalize', artifactPrefix: 'coverage-tmp-finalize-shard-' }, +]; + +test('release.yml rc/finalize unit-suite lanes are sharded and complete (#4335)', async (t) => { + const workflow = loadWorkflow('release.yml'); + + for (const lane of LANES) { + await t.test(`${lane.label}: the *-test job's matrix declares a complete shard set`, () => { + const job = workflow.jobs[lane.testJob]; + assert.ok(job, `release.yml declares no \`${lane.testJob}\` job`); + const shards = (job.strategy && job.strategy.matrix && job.strategy.matrix.shard) || []; + assert.ok( + Array.isArray(shards) && shards.length > 1, + `\`${lane.testJob}\` is not actually sharded (matrix.shard: ${JSON.stringify(shards)}) — ` + + 'that is the #4335/#2952 regression: the whole suite on one runner grows past its cap.', + ); + assert.ok( + isCompleteShardSet(shards), + `\`${lane.testJob}\`'s declared shards ${JSON.stringify(shards)} are not a complete set. ` + + 'Every entry must share one denominator N and the numerators must be exactly 1..N — a ' + + 'missing numerator silently stops running that slice of the suite while every check stays green.', + ); + }); + + await t.test(`${lane.label}: each shard runs its own slice, not the whole suite`, () => { + const job = workflow.jobs[lane.testJob]; + const unitStep = job.steps.find( + (s) => typeof s.run === 'string' && s.run.includes('test:coverage:unit:raw'), + ); + assert.ok(unitStep, `no step in \`${lane.testJob}\` runs the raw unit coverage script`); + assert.match( + unitStep.run, /--shard \$\{\{ matrix\.shard \}\}/, + `the unit step in \`${lane.testJob}\` does not pass matrix.shard through to run-tests.cjs, ` + + 'so every shard would run the ENTIRE suite — N times the cost, no speedup.', + ); + }); + + await t.test(`${lane.label}: each shard uploads a distinctly-named raw coverage artifact`, () => { + const job = workflow.jobs[lane.testJob]; + const uploadStep = job.steps.find( + (s) => String(s.uses || '').includes('upload-artifact'), + ); + assert.ok(uploadStep, `no upload-artifact step in \`${lane.testJob}\``); + assert.ok( + String(uploadStep.with && uploadStep.with.name).startsWith(lane.artifactPrefix), + `\`${lane.testJob}\`'s upload-artifact name does not start with '${lane.artifactPrefix}' ` + + `(was: ${JSON.stringify(uploadStep.with && uploadStep.with.name)})`, + ); + }); + + await t.test(`${lane.label}: a dedicated coverage-gate job exists and merges the shards`, () => { + const gate = workflow.jobs[lane.gateJob]; + assert.ok(gate, `release.yml declares no \`${lane.gateJob}\` job`); + assert.ok( + (gate.needs || []).includes(lane.testJob), + `\`${lane.gateJob}\` must depend on \`${lane.testJob}\` — it merges that job's shard artifacts`, + ); + + const merges = gate.steps.some( + (s) => String(s.uses || '').includes('download-artifact') + && s.with && s.with['merge-multiple'] === true + && String(s.with.pattern || '').startsWith(lane.artifactPrefix), + ); + assert.ok( + merges, + `\`${lane.gateJob}\` does not download the \`${lane.artifactPrefix}*\` shard artifacts with ` + + 'merge-multiple. Without every shard merged into one coverage/tmp, the gate scores a ' + + 'partial run: files no shard in hand executed read 0%.', + ); + + const reports = gate.steps.some( + (s) => typeof s.run === 'string' && s.run.includes('test:coverage:report'), + ); + assert.ok( + reports, + `\`${lane.gateJob}\` never runs the coverage report+gate script — the gsd-core/bin/lib ` + + 'coverage floor would be gone', + ); + }); + + await t.test(`${lane.label}: the final job depends on its coverage gate`, () => { + const finalJob = workflow.jobs[lane.finalJob]; + assert.ok(finalJob, `release.yml declares no \`${lane.finalJob}\` job`); + assert.ok( + (finalJob.needs || []).includes(lane.gateJob), + `\`${lane.finalJob}\` does not depend on \`${lane.gateJob}\` — a red/skipped coverage gate ` + + `could not block ${lane.label} from tagging/publishing`, + ); + }); + } +}); + +test('release.yml shard-spec parsing is exact at its boundaries (#4335)', () => { + assert.deepEqual(parseShardSpec('1/3'), { index: 1, total: 3 }); + assert.deepEqual(parseShardSpec('3/3'), { index: 3, total: 3 }); + assert.equal(parseShardSpec('0/3'), null); + assert.equal(parseShardSpec('4/3'), null); + assert.equal(parseShardSpec('a/3'), null); + + assert.equal(isCompleteShardSet(['1/3', '2/3', '3/3']), true); + assert.equal(isCompleteShardSet(['1/3', '2/3']), false, 'missing numerator'); + assert.equal(isCompleteShardSet(['1/3', '2/3', '2/3']), false, 'duplicate numerator'); + assert.equal(isCompleteShardSet(['1/3', '2/3', '3/4']), false, 'mixed denominator'); + assert.equal(isCompleteShardSet([]), false, 'empty set'); +});