@@ -5,13 +5,17 @@ import os from 'node:os';
55import path from 'node:path' ;
66import { 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 */
4148const 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
4453const 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. */
4756const SELECT_OUTPUT = / ^ \$ \{ \{ s t e p s \. s e l e c t \. o u t p u t s \. ( [ A - Z a - z 0 - 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. */
101111type 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 {
147157type 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
171182type 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. */
174189function 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
191206let 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. */
193213let 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 {
201221beforeAll ( ( ) => {
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