Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/scheduled-full-run-card.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,13 @@ on:
#
# Every name listed here must have an entry in `CARD_CADENCES`
# (scripts/ci/scheduled-full-run.mjs), which refuses a name it does not
# hold rather than default one, so a card never prints the wrong cadence.
workflows: ['CI', 'Lint & Type Check']
# hold rather than default one, so a card never prints the wrong cadence;
# its self-test holds this list and that map to the same names.
#
# `UI Coverage` (ui-coverage-nightly.yml, #25924) is the nightly one: the
# ui coverage thresholds are enforced there and nowhere else, so a red
# night files `nightly full run: red on main (UI Coverage)`.
workflows: ['CI', 'Lint & Type Check', 'UI Coverage']
types: [completed]

permissions: {}
Expand Down
370 changes: 370 additions & 0 deletions .github/workflows/ui-coverage-nightly.yml

Large diffs are not rendered by default.

38 changes: 37 additions & 1 deletion scripts/ci/scheduled-full-run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,8 @@ const titlePrefix = (cadence) => `${cadence} full run: red on main`;
export const CARD_CADENCES = Object.freeze({
CI: '4-hourly',
'Lint & Type Check': '4-hourly',
// ui-coverage-nightly.yml (#25924): objectui's coverage lane, nightly.
'UI Coverage': 'nightly',
});

// THE CADENCES A CADENCE REPLACED, newest first. `CI` and `Lint & Type Check`
Expand Down Expand Up @@ -1072,7 +1074,7 @@ async function releaseGate(argv) {
// remedy is to find what stopped registering, never to lower the number.
const SELF_TEST_BATTERIES = Object.freeze({
'scheduled-full-run concurrency isolation': 32,
'scheduled-full-run card identity and de-dup': 42,
'scheduled-full-run card identity and de-dup': 46,
'card refresh: a stale pm:blocked (the measured #23885 sequence)': 22,
'release gate: the selection rule on the recorded 2026-10-12 listing': 8,
'release gate: the selection rule on synthetic runs': 17,
Expand Down Expand Up @@ -1525,6 +1527,40 @@ async function selfTest() {
}
});
}
// UI Coverage is the one NIGHTLY workflow the card watches (#25924): its card
// says nightly, never 4-hourly, and the workflow runs exactly one daily cron.
const uiCoverage = cardIdentity('UI Coverage');
await check(() => {
if (uiCoverage.cadence !== 'nightly' || uiCoverage.title !== 'nightly full run: red on main (UI Coverage)' || uiCoverage.marker !== 'os-nightly-full-run-ui-coverage') {
throw new Error(`cadence: the UI Coverage identity is (${uiCoverage.cadence}, '${uiCoverage.title}', '${uiCoverage.marker}'), not the nightly one`);
}
});
await check(() => {
for (const b of bodiesOf(uiCoverage)) {
if (!/every night/.test(b)) throw new Error(`cadence: a nightly card body does not say nightly:\n${b}`);
if (/every 4 hours|4 hours ago|every hour\b|an hour ago/.test(b)) throw new Error(`cadence: a nightly card body names another cadence:\n${b}`);
}
});
await check(() => {
const file = '.github/workflows/ui-coverage-nightly.yml';
const text = readFileSync(file, 'utf8');
const name = /^name:\s*(.+?)\s*$/m.exec(text)?.[1];
if (name !== 'UI Coverage') throw new Error(`cadence: ${file} is named '${name}', not the 'UI Coverage' CARD_CADENCES holds`);
const crons = [...text.matchAll(/^\s*-\s*cron:\s*'([^']+)'/gm)].map((m) => m[1]);
if (crons.length !== 1 || !/^\d{1,2} \d{1,2} \* \* \*$/.test(crons[0])) {
throw new Error(`cadence: ${file} declares ${JSON.stringify(crons)}, but CARD_CADENCES says nightly (one daily cron)`);
}
});
await check(() => {
// Every name the watcher lists has a cadence, and every cadence is watched.
const text = readFileSync('.github/workflows/scheduled-full-run-card.yml', 'utf8');
const line = /^\s*workflows:\s*\[(.*)\]\s*$/m.exec(text)?.[1] ?? '';
const watched = [...line.matchAll(/'([^']+)'/g)].map((m) => m[1]).sort();
const held = Object.keys(CARD_CADENCES).sort();
if (JSON.stringify(watched) !== JSON.stringify(held)) {
throw new Error(`cadence: scheduled-full-run-card.yml watches ${JSON.stringify(watched)}, CARD_CADENCES holds ${JSON.stringify(held)}`);
}
});
await check(() => {
let message = '';
try {
Expand Down
164 changes: 164 additions & 0 deletions ui/scripts/__tests__/coverage-shard-reporter-readability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest';
import { parse as parseYaml } from 'yaml';

import { UI_COVERAGE, rootWorkflow } from './helpers/root-ci';

/**
* The pin on the coverage shard leg's reporter set (objectui#9177).
*
* ## What this guards, and why nothing else could
*
* The root `ui-coverage-nightly.yml`'s `ui-coverage` job (objectui's `ci.yml`
* `test-coverage`, ported nightly by objectstack#25924) runs vitest as a
* sharded matrix and merges
* every shard's blob report in the job below it, which is where the configured
* coverage thresholds are enforced. A CLI `--reporter` **replaces** the
* reporter set rather than adding to it, and vitest's resolution is:
*
* if (!resolved.reporters.length) {
* resolved.reporters.push([isAgent ? "agent" : "default", {}]);
* if (process.env.GITHUB_ACTIONS === "true")
* resolved.reporters.push(["github-actions", {}]);
* }
*
* so `--reporter=blob` on its own displaced BOTH halves of that pair. A red
* shard's log then ended at `blob report written to …` — no failing test name,
* no assertion text, no timeout message — and the shard job's only annotation
* was the generic `Process completed with exit code 1.` The failures survived
* only inside the `coverage-blob-N` artifact, which is download-only.
*
* objectui#8545 measured what that costs: two episodes in which one test file
* held `main`'s coverage gate unevaluated for **84** and **87** consecutive
* pushes, both found the same way — a person reading a job log by hand, days
* later.
*
* Nothing else in this repository reads what that step runs.
* `ci-cd-pipeline-doc.test.ts` pins the pairing between `ci.yml` and the
* pipeline page, but it compares COMMANDS, not flags — its extractor is
*
* for (const m of text.matchAll(/\bpnpm\s+([\w:.-]+)/g)) {
* if (rootScripts.has(m[1])) found.add(`pnpm ${m[1]}`);
* }
*
* whose capture stops at the first space, so `pnpm test:coverage` is all it
* ever sees. Every flag on that line could be deleted and that file stays
* green.
*
* ## Both directions, because the failure has two shapes
*
* Dropping `--reporter=default` / `--reporter=github-actions` restores
* objectui#9177. Dropping `--reporter=blob` to "simplify" the line is the same
* defect wearing the other hat: the merge job loses the only input it has, so
* a readable failure is bought with an unevaluated coverage floor
* (objectui#5403). Neither is caught by CI going green, because the shard leg
* passing is exactly the state in which nobody looks at its log.
*
* ⛔ If you are deleting this file, you are deleting the only mechanical guard
* on that reporter set. The measurement behind it is in the ⚠️ comment above
* the step in `ui-coverage-nightly.yml`; re-run it before you change the line.
*/
const workflowSource = rootWorkflow(UI_COVERAGE.file);

/** The reporter the merge job needs, and the two that keep the log readable. */
const BLOB_REPORTER = '--reporter=blob';
const READABLE_REPORTERS = ['--reporter=default', '--reporter=github-actions'];

interface Step {
name?: string;
run?: unknown;
}

/**
* Every `run:` body in the `ui-coverage` job that invokes the coverage suite.
*
* Read through the YAML parser rather than off the raw text: the step is a
* folded block scalar (`run: >-`), so the flags are spread over four source
* lines and a regex over the file would have to re-implement the folding to see
* the command the runner actually executes. Matching on `test:coverage` rather
* than on the full string is what makes a rewrite FAIL here instead of
* vanishing — a pin that searched for today's exact line would simply stop
* finding anything and report a healthy green over a step that no longer
* contains it.
*/
function coverageRunSteps(source: string): Array<{ step: string; run: string }> {
const workflow = parseYaml(source) as {
jobs?: Record<string, { steps?: Step[] }>;
};
const job = workflow.jobs?.[UI_COVERAGE.shards];
expect(
job,
`${UI_COVERAGE.file} no longer defines a \`${UI_COVERAGE.shards}\` job. If the coverage lane was restructured, this pin ` +
'needs a new referent — do not delete it without giving the reporter set one.',
).toBeDefined();
const found: Array<{ step: string; run: string }> = [];
for (const step of job?.steps ?? []) {
if (typeof step.run !== 'string') continue;
if (!step.run.includes('test:coverage')) continue;
found.push({ step: step.name ?? '(unnamed)', run: step.run });
}
return found;
}

describe("the ui coverage shards keep a red run readable", () => {
it('still runs the coverage suite at all — the pin is not vacuous', () => {
// Without this, every assertion below passes over a job that stopped running
// vitest, which is the one way a `toContain` pin lies.
const steps = coverageRunSteps(workflowSource);
expect(
steps.map((s) => s.step),
`no step in ${UI_COVERAGE.file}'s \`${UI_COVERAGE.shards}\` job runs \`test:coverage\` any more. If that is ` +
'deliberate, this pin is obsolete and should be deleted WITH the reasoning that made it ' +
'obsolete written down. If it is not, the nightly lane just lost its coverage measurement.',
).not.toEqual([]);
});

it('is reading the shard leg, not some other coverage invocation', () => {
// Names the population the two assertions below judge: the sharded legs.
// `--merge-reports` in the job below is a different step in a different job
// and is deliberately out of reach here.
const sharded = coverageRunSteps(workflowSource).filter((s) => s.run.includes('--shard='));
expect(
sharded.map((s) => s.step),
`the \`${UI_COVERAGE.shards}\` job no longer passes \`--shard=\` to the coverage suite. The reporter ` +
'rule below is about the SHARDED legs specifically — an unsharded lane has no blob to ' +
'keep and a different set of reporters is right for it.',
).not.toEqual([]);
});

it('passes a reporter that prints failing test names alongside the blob', () => {
const offenders = coverageRunSteps(workflowSource)
.filter((s) => s.run.includes(BLOB_REPORTER))
.flatMap((s) =>
READABLE_REPORTERS.filter((r) => !s.run.includes(r)).map((r) => `${s.step}: missing ${r}`),
);

expect(
offenders,
'a coverage shard passes `--reporter=blob` without the reporters that keep its log readable.\n' +
offenders.map((o) => ` - ${o}`).join('\n') +
'\n\nA CLI `--reporter` REPLACES the default set rather than adding to it, so blob alone ends a ' +
'failing shard at `blob report written to …` with no test name and no assertion text, and leaves ' +
'the download-only artifact as the only copy of the failure. `--reporter=default` restores the ' +
'job log; `--reporter=github-actions` restores the per-test `::error` annotation, which is the ' +
'only form an API reader can get without downloading anything. objectui#9177; the cost is ' +
'objectui#8545, where one test file held the coverage gate unevaluated for 84 and 87 pushes.',
).toEqual([]);
});

it('keeps the blob reporter the merge job depends on', () => {
const offenders = coverageRunSteps(workflowSource)
.filter((s) => s.run.includes('--shard='))
.filter((s) => !s.run.includes(BLOB_REPORTER))
.map((s) => s.step);

expect(
offenders,
'a sharded coverage leg no longer writes a blob report:\n' +
offenders.map((o) => ` - ${o}`).join('\n') +
`\n\nThe \`${UI_COVERAGE.report}\` job merges every shard's blob and enforces the configured thresholds over ` +
'the merged report — that is the whole reason the lane is sharded (objectui#5403). Restoring log ' +
'readability by dropping the blob would trade an unreadable failure for an unevaluated coverage ' +
'floor, which is the same defect wearing the other hat (objectui#9177).',
).toEqual([]);
});
});
121 changes: 121 additions & 0 deletions ui/scripts/__tests__/coverage-shard-width.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import { parse as parseYaml } from 'yaml';

import { UI_COVERAGE, rootWorkflow } from './helpers/root-ci';

/**
* The coverage lane's width, held to ONE number (objectui#12256, folded into
* objectui#12208).
*
* The lane runs in the root `.github/workflows/ui-coverage-nightly.yml`
* (objectstack#25924), where objectui's `ci.yml` jobs `test-coverage` and
* `coverage-report` became `ui-coverage` and `ui-coverage-report`.
*
* The lane spells its shard count in two jobs. `ui-coverage`
* declares the matrix and repeats the count in its job name, its step name and
* its `--shard=N/M` flag; `ui-coverage-report` counts the blob reports the shards
* wrote and refuses to merge a partial set. The blob's FILE NAME carries the
* count too (`blob-N-M.json`, written by vitest from the `--shard` flag), so a
* width moved in the matrix alone leaves the merge looking for blobs that no
* shard writes: the count step answers zero, the merge never runs, and the
* coverage gate is unevaluated on every night -- the lane's own worst failure,
* one edit away. Nothing about that is visible on a pull request, because the
* lane runs on `schedule` and `workflow_dispatch` only.
*
* So every spelling is read here and held to the matrix:
*
* - the matrix is the contiguous run 1..M;
* - the job name, the step name and `--shard=` all say M;
* - `ui-coverage-report` declares `COVERAGE_SHARDS: 'M'`, and its count step
* reads THAT variable for both the blob glob and the expected count, with
* no literal width left in the step;
* - the merge step is a `--merge-reports` that overrides no threshold, so
* the configured `coverage.thresholds` are what is enforced over the merge
* of all M blobs -- the one place the gate runs, since the shard legs
* zero the thresholds.
*/
const workflowSource = rootWorkflow(UI_COVERAGE.file);

interface Step {
name?: string;
run?: unknown;
id?: string;
}
interface Job {
name?: string;
env?: Record<string, unknown>;
strategy?: { matrix?: { shard?: unknown } };
steps?: Step[];
}

const workflow = parseYaml(workflowSource) as { jobs?: Record<string, Job> };

function job(key: string): Job {
const found = workflow.jobs?.[key];
expect(found, `${UI_COVERAGE.file} no longer defines a \`${key}\` job; this pin needs a new referent, not deletion`).toBeDefined();
return found as Job;
}

/** The matrix width, after checking the matrix is the contiguous run 1..M. */
function matrixWidth(): number {
const shards = job(UI_COVERAGE.shards).strategy?.matrix?.shard;
expect(Array.isArray(shards), `\`${UI_COVERAGE.shards}\` must declare a \`shard:\` matrix list`).toBe(true);
const list = shards as unknown[];
expect(list.length, 'a zero-wide matrix is a broken reader, not a lane').toBeGreaterThan(0);
expect(list, 'the coverage matrix must be the contiguous run 1..M').toEqual(list.map((_, i) => i + 1));
return list.length;
}

function runOf(step: Step | undefined): string {
return typeof step?.run === 'string' ? step.run : '';
}

describe("the ui coverage lane says its width once (objectui#12256)", () => {
const width = matrixWidth();

it('names each shard job with the matrix width', () => {
expect(job(UI_COVERAGE.shards).name).toBe(`Test UI (coverage shard \${{ matrix.shard }}/${width})`);
});

it('passes the matrix width to vitest, in the flag and in the step name', () => {
const steps = (job(UI_COVERAGE.shards).steps ?? []).filter((s) => runOf(s).includes('test:coverage'));
expect(steps.length, `no step in \`${UI_COVERAGE.shards}\` runs \`test:coverage\` any more`).toBeGreaterThan(0);
for (const step of steps) {
const flags = [...runOf(step).matchAll(/--shard=\$\{\{ matrix\.shard \}\}\/(\d+)/g)].map((m) => Number(m[1]));
expect(flags, `${step.name}: one --shard flag, carrying the matrix width`).toEqual([width]);
expect(step.name, `${step.name}: the step name states a different width`).toContain(`/${width})`);
}
});

it('declares the same width as the count the merge refuses to go below', () => {
expect(job(UI_COVERAGE.report).env?.COVERAGE_SHARDS, `\`${UI_COVERAGE.report}\` must declare \`COVERAGE_SHARDS\``).toBe(
String(width),
);
});

it('counts the blobs through that variable, with no literal width left in the step', () => {
const count = runOf((job(UI_COVERAGE.report).steps ?? []).find((s) => s.id === 'blobs'));
expect(count, `\`${UI_COVERAGE.report}\` must keep its \`blobs\` count step`).not.toBe('');
expect(count).toContain('-name "blob-*-${COVERAGE_SHARDS}.json"');
expect(count).toContain('-ne "$COVERAGE_SHARDS"');
// A digit in the blob glob or the comparison is a second spelling of the
// width, and the one that goes stale first.
expect(count).not.toMatch(/blob-\*-\d/);
expect(count).not.toMatch(/-ne\s+\d/);
});

it('enforces the configured thresholds over the merge of every blob, overriding none', () => {
const merge = runOf((job(UI_COVERAGE.report).steps ?? []).find((s) => s.id === 'merge'));
expect(merge, `\`${UI_COVERAGE.report}\` must keep its \`merge\` step`).toContain('--merge-reports');
expect(merge, 'the merge is where the thresholds are enforced; it must not override them').not.toContain(
'thresholds',
);
const legs = (job(UI_COVERAGE.shards).steps ?? []).filter((s) => runOf(s).includes('--shard='));
expect(legs.length, `no \`${UI_COVERAGE.shards}\` step passes \`--shard=\``).toBeGreaterThan(0);
for (const leg of legs) {
// The other half of the same contract: the legs zero the thresholds, so
// the merge above is the only evaluation, over all of them.
expect(runOf(leg)).toContain('--coverage.thresholds.lines=0');
}
});
});
24 changes: 18 additions & 6 deletions ui/scripts/__tests__/helpers/root-ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@
* the root `.github/workflows/` directory through `rootWorkflowFiles()`. It
* never reads a copy under `ui/.github/`: nothing runs one.
*
* What the port did not carry (the coverage lane, the docs job, the shard
* aggregate, the type-check-scope selector, the timings upload) has no job
* here, and the pins on those retired with a disposition beside them rather
* than being pointed at a job that does something else. The affected-test
* selector was wired into `test-ui` later (objectstack#25868), and its pins
* read that job.
* What the port did not carry (the docs job, the shard aggregate, the
* type-check-scope selector, the timings upload) has no job here, and the pins
* on those retired with a disposition beside them rather than being pointed at
* a job that does something else. The affected-test selector was wired into
* `test-ui` later (objectstack#25868), and its pins read that job. The
* coverage lane came back as a nightly workflow of its own
* (objectstack#25924), named by `UI_COVERAGE` below.
*/
import fs from 'node:fs';
import path from 'node:path';
Expand All @@ -32,6 +33,17 @@ export const UI_JOBS = {
buildE2e: 'build-e2e-ui',
} as const;

/**
* objectui's coverage lane, ported to the root as a nightly workflow
* (objectstack#25924): the file, the sharded job and the merge job that
* enforces the thresholds.
*/
export const UI_COVERAGE = {
file: 'ui-coverage-nightly.yml',
shards: 'ui-coverage',
report: 'ui-coverage-report',
} as const;

/** The directory GitHub runs: `.github/workflows/` at the workspace root. */
export const rootWorkflowsDir = path.join(workspaceRoot(), '.github/workflows');

Expand Down
Loading
Loading