Skip to content

Commit bc79c8a

Browse files
committed
test(ui): the selector wiring test reads the root ci.yml Test UI job; nested-layout selector cases
Claude-Session: https://claude.ai/code/session_015oJuSyTy7Y3cm91KJMEW2m Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1248c83 commit bc79c8a

2 files changed

Lines changed: 201 additions & 38 deletions

File tree

‎ui/scripts/__tests__/select-affected-tests-wiring.test.ts‎

Lines changed: 83 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@ import os from 'node:os';
55
import path from 'node:path';
66
import { fileURLToPath } from 'node:url';
77

8+
import { workspaceRoot } from '../workspace-root.mjs';
9+
810
/**
9-
* objectui#13350 — the two steps of the `test` job that make the affected
10-
* speed, held together by RUNNING them.
11+
* objectui#13350 — the two steps of the `Test UI` job that make the affected
12+
* speed, held together by RUNNING them. Since objectstack#25868 the job is
13+
* `test-ui` in the objectstack monorepo's root `ci.yml`, the workflow that
14+
* runs this tree's suite; the objectui copy under `ui/.github/` runs nowhere.
1115
*
1216
* `Select the tests this change affects` (id `select`) appends the selector's
13-
* verdict line to `$GITHUB_OUTPUT`; `Run tests (shard N/16)` reads one output of
14-
* that step into an env var and branches on it. The runner splits each appended
17+
* verdict line to `$GITHUB_OUTPUT`; `Run the ui tests (shard N/16)` reads one
18+
* output of that step into an env var and branches on it. The runner splits each appended
1519
* line at its first `=`, so the line `mode=affected` is the output `mode` with
1620
* the value `affected`. From objectui#12214 until this card the run step
1721
* compared its env var with the whole line -- never true -- and every pull
@@ -22,27 +26,32 @@ import { fileURLToPath } from 'node:url';
2226
* compares with.
2327
*
2428
* So this file restates neither end. It extracts both steps' `env:` and `run:`
25-
* from `ci.yml`, runs the select step with bash against this checkout -- the
26-
* real selector, based on HEAD -- parses what it appended the way the runner
27-
* does, hands the run step whatever output its `env:` names, and reads which
28-
* command the run step chose off a `pnpm` stand-in that records its argv.
29+
* from `ci.yml`, runs the select step with bash at the workflow's working
30+
* directory (the monorepo root) against this checkout -- the real selector,
31+
* with the merge base a `git` stand-in answers as HEAD -- parses what it
32+
* appended the way the runner does, hands the run step whatever output its
33+
* `env:` names, and reads which command the run step chose off a `pnpm`
34+
* stand-in that records its argv.
2935
* Textual rather than YAML-parsed, for the reason `markdown-test-inputs.test.ts`
3036
* gives: what is under test is the bytes CI runs.
3137
*
3238
* affected a `pull_request` and a `merge_group` event hand vitest exactly the
3339
* filters the selector wrote.
34-
* full a `push` event, and a pull request with no base sha (the
35-
* fail-open path), run the whole suite.
40+
* full a `push` event, a pull request with no base ref and one whose
41+
* merge base cannot be computed (the fail-open paths), run the
42+
* whole suite.
3643
* controls the run step's comparison reverted to the whole line, and the
3744
* select step's output written under another key or value, each
3845
* ALONE, put a pull request on the full suite -- so this file
3946
* cannot pass while the two ends disagree.
4047
*/
4148
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
42-
const CI_YML = path.join(repoRoot, '.github', 'workflows', 'ci.yml');
49+
/** Where the workflow runs: its steps' working directory, and the home of `.github/`. */
50+
const workflowRoot = workspaceRoot(repoRoot);
51+
const CI_YML = path.join(workflowRoot, '.github', 'workflows', 'ci.yml');
4352

4453
const SELECT_HEADER = ' - name: Select the tests this change affects\n';
45-
const RUN_HEADER = ' - name: Run tests (shard ${{ matrix.shard }}/16)\n';
54+
const RUN_HEADER = ' - name: Run the ui tests (shard ${{ matrix.shard }}/16)\n';
4655
/** The output reference a run step's `env:` value is, when it reads the select step. */
4756
const SELECT_OUTPUT = /^\$\{\{ steps\.select\.outputs\.([A-Za-z0-9_-]+) \}\}$/;
4857

@@ -98,6 +107,7 @@ function modeBinding(): { variable: string; key: string } {
98107

99108
/* ── the runner, as far as these two steps need it ──────────────────────── */
100109

110+
/** `base` is the pull request's base REF, or the merge group's base sha. */
101111
type Event = { name: 'pull_request' | 'merge_group' | 'push'; base: string };
102112

103113
/** `${{ … }}` resolved the way the runner would for this event, shard 1. */
@@ -108,7 +118,7 @@ function expand(text: string, event: Event, temp: string, outputs: Record<string
108118
switch (expr) {
109119
case 'github.event_name':
110120
return event.name;
111-
case 'github.event.pull_request.base.sha':
121+
case 'github.event.pull_request.base.ref':
112122
return event.name === 'pull_request' ? event.base : '';
113123
case 'github.event.merge_group.base_sha':
114124
return event.name === 'merge_group' ? event.base : '';
@@ -147,21 +157,22 @@ function standIn(name: string, body: string): string {
147157
type Selected = { outputs: Record<string, string>; log: string };
148158

149159
/** Run the select step, with `bash -e` as the runner does, in a fresh `RUNNER_TEMP`. */
150-
function runSelect(event: Event, temp: string, bin?: string): Selected {
160+
function runSelect(event: Event, temp: string, bin?: string, extraEnv: Record<string, string> = {}): Selected {
151161
const env = Object.fromEntries(SELECT.env.map(([name, value]) => [name, expand(value, event, temp)]));
152162
const script = path.join(temp, 'select.sh');
153163
fs.writeFileSync(script, expand(SELECT.run, event, temp));
154164
const githubOutput = path.join(temp, 'github-output-select');
155165
fs.writeFileSync(githubOutput, '');
156166
const run = spawnSync('bash', ['-e', script], {
157-
cwd: repoRoot,
167+
cwd: workflowRoot,
158168
encoding: 'utf8',
159169
env: {
160170
...process.env,
161171
...env,
172+
...extraEnv,
162173
RUNNER_TEMP: temp,
163174
GITHUB_OUTPUT: githubOutput,
164-
PATH: [bin, nodeDir, process.env.PATH].filter(Boolean).join(path.delimiter),
175+
PATH: [bin, gitBin, nodeDir, process.env.PATH].filter(Boolean).join(path.delimiter),
165176
},
166177
});
167178
if (run.status !== 0) throw new Error(`the select step exited ${run.status}:\n${run.stdout}${run.stderr}`);
@@ -170,6 +181,10 @@ function runSelect(event: Event, temp: string, bin?: string): Selected {
170181

171182
type Shard = { argv: string[]; log: string };
172183

184+
/** How the run step's `pnpm` argv starts on each speed: the suite runs from ui/, the vitest root. */
185+
const FULL_ARGV = ['--dir', 'ui', 'run', 'test'];
186+
const AFFECTED_ARGV = ['--dir', 'ui', 'exec', 'vitest', 'run'];
187+
173188
/** Run the shard step with the outputs the select step left, `pnpm` recording its argv. */
174189
function runShard(event: Event, temp: string, outputs: Record<string, string>, run = RUN.run): Shard {
175190
const env = Object.fromEntries(RUN.env.map(([name, value]) => [name, expand(value, event, temp, outputs)]));
@@ -178,7 +193,7 @@ function runShard(event: Event, temp: string, outputs: Record<string, string>, r
178193
const argvFile = path.join(temp, 'pnpm-argv');
179194
const bin = standIn('pnpm', `printf '%s\\n' "$@" > ${JSON.stringify(argvFile)}\n`);
180195
const result = spawnSync('bash', ['-e', script], {
181-
cwd: repoRoot,
196+
cwd: workflowRoot,
182197
encoding: 'utf8',
183198
env: { ...process.env, ...env, RUNNER_TEMP: temp, PATH: `${bin}${path.delimiter}${process.env.PATH}` },
184199
});
@@ -189,6 +204,11 @@ function runShard(event: Event, temp: string, outputs: Record<string, string>, r
189204
/* ── one real selector run, replayed where only the wiring is in question ── */
190205

191206
let head = '';
207+
/** The base ref every pull_request leg names; the `git` stand-in answers its merge base as HEAD. */
208+
const BASE_REF = 'main';
209+
/** A `git` that answers `merge-base` with HEAD and records what it was asked; every other call is the real git. */
210+
let gitBin = '';
211+
let mergeBaseArgv = '';
192212
/** The real selector's answer on this checkout, taken through the real select step. */
193213
let real: Selected & { filters: string[]; temp: string } = { outputs: {}, log: '', filters: [], temp: '' };
194214
/** A `node` that replays `real` for the select step, so each leg does not re-walk the tree. */
@@ -201,11 +221,25 @@ function freshTemp(leg: string): string {
201221
beforeAll(() => {
202222
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'select-affected-wiring-13350-'));
203223
head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim();
224+
const realGit = execFileSync('bash', ['-c', 'command -v git'], { encoding: 'utf8' }).trim();
225+
mergeBaseArgv = path.join(scratch, 'merge-base-argv');
226+
gitBin = standIn(
227+
'git',
228+
[
229+
'if [ "$1" = merge-base ]; then',
230+
` printf '%s\\n' "$@" > ${JSON.stringify(mergeBaseArgv)}`,
231+
' if [ -n "$OS_TEST_MERGE_BASE_FAILS" ]; then exit 1; fi',
232+
` exec ${JSON.stringify(realGit)} rev-parse HEAD`,
233+
'fi',
234+
`exec ${JSON.stringify(realGit)} "$@"`,
235+
'',
236+
].join('\n'),
237+
);
204238
const temp = freshTemp('real');
205239
// The select step itself names these two files under `$RUNNER_TEMP`.
206240
const filters = path.join(temp, 'affected-tests.txt');
207241
const stdout = path.join(temp, 'select.out');
208-
real = { ...runSelect({ name: 'pull_request', base: head }, temp), filters: [], temp };
242+
real = { ...runSelect({ name: 'pull_request', base: BASE_REF }, temp), filters: [], temp };
209243
real.filters = fs.readFileSync(filters, 'utf8').split('\n').filter(Boolean);
210244
replay = standIn(
211245
'node',
@@ -238,10 +272,21 @@ describe('the affected speed, both steps run as ci.yml writes them (objectui#133
238272
expect(modeBinding().key).not.toBe('');
239273
});
240274

275+
it('a pull_request diffs from the merge base of origin/<base ref> and HEAD, never the frozen payload sha', () => {
276+
expect(fs.readFileSync(mergeBaseArgv, 'utf8').split('\n').filter(Boolean)).toEqual([
277+
'merge-base',
278+
`refs/remotes/origin/${BASE_REF}`,
279+
'HEAD',
280+
]);
281+
expect(real.log).toContain(`Diff base: ${head}`);
282+
expect(SELECT.env.map(([name]) => name)).not.toContain('PR_BASE');
283+
});
284+
241285
it('a pull_request hands vitest exactly the filters the real selector wrote', () => {
242286
expect(real.filters.length).toBeGreaterThan(0);
243287
// The same `RUNNER_TEMP` the select step wrote its filters into, as one job's steps share it.
244-
const shard = runShard({ name: 'pull_request', base: head }, real.temp, real.outputs);
288+
const shard = runShard({ name: 'pull_request', base: BASE_REF }, real.temp, real.outputs);
289+
expect(shard.argv.slice(0, AFFECTED_ARGV.length)).toEqual(AFFECTED_ARGV);
245290
expect(shard.argv.slice(-real.filters.length)).toEqual(real.filters);
246291
expect(shard.argv).toContain('--shard=1/16');
247292
expect(shard.log).toContain(`Affected speed: ${real.filters.length} vitest filter(s), shard 1 of 16.`);
@@ -260,18 +305,28 @@ describe('the affected speed, both steps run as ci.yml writes them (objectui#133
260305
const temp = freshTemp('push');
261306
const event: Event = { name: 'push', base: '' };
262307
const shard = runShard(event, temp, runSelect(event, temp, replay).outputs);
263-
expect(shard.argv[0]).toBe('test');
308+
expect(shard.argv.slice(0, FULL_ARGV.length)).toEqual(FULL_ARGV);
264309
expect(shard.argv).toContain('--shard=1/16');
265310
expect(shard.argv.filter((arg) => real.filters.includes(arg))).toEqual([]);
266311
});
267312

268-
it('a pull request with no base sha runs the whole suite (the step fails open)', () => {
313+
it('a pull request with no base ref runs the whole suite (the step fails open)', () => {
269314
const temp = freshTemp('no-base');
270315
const event: Event = { name: 'pull_request', base: '' };
271316
const selected = runSelect(event, temp, replay);
272317
expect(selected.log).toContain('running the full suite rather than guessing');
273318
const shard = runShard(event, temp, selected.outputs);
274-
expect(shard.argv[0]).toBe('test');
319+
expect(shard.argv.slice(0, FULL_ARGV.length)).toEqual(FULL_ARGV);
320+
});
321+
322+
it('a pull request whose merge base cannot be computed runs the whole suite (the step fails open)', () => {
323+
const temp = freshTemp('no-merge-base');
324+
const event: Event = { name: 'pull_request', base: BASE_REF };
325+
const selected = runSelect(event, temp, replay, { OS_TEST_MERGE_BASE_FAILS: '1' });
326+
expect(selected.log).toContain('running the full suite rather than guessing');
327+
expect(selected.outputs[modeBinding().key]).toBe('full');
328+
const shard = runShard(event, temp, selected.outputs);
329+
expect(shard.argv.slice(0, FULL_ARGV.length)).toEqual(FULL_ARGV);
275330
});
276331
});
277332

@@ -283,21 +338,21 @@ describe('controls: either end edited alone is a full run, so the legs above can
283338
expect(found, `the run step no longer compares $${variable} with a quoted literal; rewrite this control`).not.toBeNull();
284339
const reverted = RUN.run.replace(comparison, `[ "$${variable}" = '${key}=${found![1]}' ]`);
285340
expect(reverted).not.toBe(RUN.run);
286-
const shard = runShard({ name: 'pull_request', base: head }, freshTemp('reverted'), real.outputs, reverted);
287-
expect(shard.argv[0]).toBe('test');
341+
const shard = runShard({ name: 'pull_request', base: BASE_REF }, freshTemp('reverted'), real.outputs, reverted);
342+
expect(shard.argv.slice(0, FULL_ARGV.length)).toEqual(FULL_ARGV);
288343
});
289344

290345
it('the select step writing its verdict under another key runs the whole suite', () => {
291346
const renamed = Object.fromEntries(Object.entries(real.outputs).map(([key, value]) => [`${key}_renamed`, value]));
292-
const shard = runShard({ name: 'pull_request', base: head }, freshTemp('renamed-key'), renamed);
293-
expect(shard.argv[0]).toBe('test');
347+
const shard = runShard({ name: 'pull_request', base: BASE_REF }, freshTemp('renamed-key'), renamed);
348+
expect(shard.argv.slice(0, FULL_ARGV.length)).toEqual(FULL_ARGV);
294349
});
295350

296351
it('the select step writing another value under the same key runs the whole suite', () => {
297352
const { key } = modeBinding();
298353
expect(real.outputs[key], `the select step wrote no \`${key}\` output`).toBeDefined();
299354
const changed = { ...real.outputs, [key]: `${real.outputs[key]}-renamed` };
300-
const shard = runShard({ name: 'pull_request', base: head }, freshTemp('renamed-value'), changed);
301-
expect(shard.argv[0]).toBe('test');
355+
const shard = runShard({ name: 'pull_request', base: BASE_REF }, freshTemp('renamed-value'), changed);
356+
expect(shard.argv.slice(0, FULL_ARGV.length)).toEqual(FULL_ARGV);
302357
});
303358
});

0 commit comments

Comments
 (0)