|
1 | 1 | import { describe, expect, it } from 'vitest'; |
2 | | -import { uiTurboConfigPath } from '../workspace-root.mjs'; |
3 | 2 | import fs from 'node:fs'; |
4 | 3 | import os from 'node:os'; |
5 | 4 | import path from 'node:path'; |
6 | 5 | import { fileURLToPath } from 'node:url'; |
7 | 6 | import ts from 'typescript'; |
8 | | -import { globToRegExp, repoFilesOnDisk } from './helpers/turbo-inputs'; |
| 7 | +import { liveTurboConfig } from './helpers/turbo-inputs'; |
| 8 | +import { |
| 9 | + UI_JOBS, |
| 10 | + globMatches, |
| 11 | + repoPath, |
| 12 | + rootCiJob, |
| 13 | + rootCiPath, |
| 14 | + stepsRunning, |
| 15 | + uiFilterGlobs, |
| 16 | + rootCi, |
| 17 | + uiRun, |
| 18 | +} from './helpers/root-ci'; |
9 | 19 |
|
10 | 20 | /** |
11 | 21 | * objectui#4471 — the 30 Playwright specs under `e2e/` were compiled by nothing. |
@@ -61,9 +71,8 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../ |
61 | 71 | const configPath = path.join(repoRoot, 'tsconfig.e2e.json'); |
62 | 72 | const e2eDir = path.join(repoRoot, 'e2e'); |
63 | 73 |
|
64 | | -/** The root script a contributor runs, and the turbo root task that drives it. */ |
| 74 | +/** The root script a contributor runs, and the root `ci.yml` step runs. */ |
65 | 75 | const SCRIPT_NAME = 'type-check:e2e'; |
66 | | -const TURBO_TASK = `//#${SCRIPT_NAME}`; |
67 | 76 |
|
68 | 77 | /** |
69 | 78 | * The project as TypeScript itself resolves it: the same parse `tsc -p` does, |
@@ -375,116 +384,103 @@ describe('tsconfig.e2e.json — coverage of the root playwright configs (objectu |
375 | 384 | }); |
376 | 385 | }); |
377 | 386 |
|
378 | | -describe('the gate is actually wired up (objectui#4471)', () => { |
| 387 | +/** |
| 388 | + * objectui#4471's wiring, as the monorepo runs it (objectstack#27283). |
| 389 | + * |
| 390 | + * Standalone, objectui made this gate the turbo ROOT task `//#type-check:e2e` |
| 391 | + * and every package's `type-check` `dependsOn` it, so `pnpm type-check` reached |
| 392 | + * `e2e/` with no workflow entry of its own. Under the monorepo `//` is the |
| 393 | + * objectstack root package and no turbo task can name objectui's root manifest, |
| 394 | + * so the root `ci.yml` runs the script as a step of `type-check-ui` instead |
| 395 | + * (objectstack#25697). Each property the root task carried is pinned here |
| 396 | + * against that step, read from the workflow GitHub runs: |
| 397 | + * |
| 398 | + * - "a turbo root task, so `pnpm type-check` reaches e2e/" -> the step exists |
| 399 | + * in the job that type-checks the ui packages, and runs exactly the script; |
| 400 | + * - "runs as part of `pnpm type-check`" -> the step has no |
| 401 | + * `if:` of its own, and every file of the e2e program sets the `ui` filter |
| 402 | + * that job's `if:` reads, so an e2e-only change still runs it; |
| 403 | + * - "hashes its own program, not the whole repository" -> no root task's |
| 404 | + * hash is folded into any package's `type-check`: the live `type-check` |
| 405 | + * `dependsOn` names no `//#` task, the stronger form of the same property; |
| 406 | + * - "is uncached" -> the step is the |
| 407 | + * script itself, not a `turbo run`, so no cache can replay its verdict. |
| 408 | + */ |
| 409 | +describe('the gate is actually wired up (objectui#4471, objectstack#27283)', () => { |
379 | 410 | const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { |
380 | 411 | scripts?: Record<string, string>; |
381 | 412 | }; |
382 | | - const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')) as { |
383 | | - tasks: Record<string, { cache?: boolean; dependsOn?: string[]; inputs?: string[] }>; |
384 | | - }; |
| 413 | + const job = rootCiJob(UI_JOBS.typeCheck); |
| 414 | + const steps = stepsRunning(job, uiRun(SCRIPT_NAME)); |
385 | 415 |
|
386 | 416 | it('is runnable locally, by name', () => { |
387 | 417 | // A gate nobody can reproduce locally is a gate people learn to ignore. |
388 | 418 | expect(pkg.scripts?.[SCRIPT_NAME], `package.json must define "${SCRIPT_NAME}"`).toBeDefined(); |
389 | 419 | expect(pkg.scripts?.[SCRIPT_NAME]).toContain('tsconfig.e2e.json'); |
390 | 420 | }); |
391 | 421 |
|
392 | | - it('is a turbo root task, which is what makes `pnpm type-check` reach e2e/', () => { |
393 | | - // Turbo resolves `//#x` to the ROOT package.json's `x` script. Declaring the |
394 | | - // task without the script (or renaming the script out from under it) is a |
395 | | - // hard turbo error rather than a silent skip, but pin the pairing anyway: |
396 | | - // it is the whole reason the script above is not dead weight. |
| 422 | + it('is a step of the job that type-checks the ui packages, which is what makes CI reach e2e/', () => { |
397 | 423 | expect( |
398 | | - turbo.tasks[TURBO_TASK], |
399 | | - `turbo.json must declare the root task \`${TURBO_TASK}\`. Without it, ` + |
400 | | - `\`pnpm type-check\` (turbo run type-check) never reaches e2e/ and this config is ` + |
401 | | - 'CI-invisible unless someone adds a workflow step — which is the shape objectui#4456 ' + |
402 | | - 'moved away from.', |
403 | | - ).toBeDefined(); |
| 424 | + steps, |
| 425 | + `${rootCiPath}'s \`${UI_JOBS.typeCheck}\` job must run \`${uiRun(SCRIPT_NAME)}\` as a step. ` + |
| 426 | + 'Nothing else compiles e2e/: it is not a workspace package, so `turbo run type-check` cannot ' + |
| 427 | + 'reach it, and the monorepo has no turbo root task that could (`//` is the objectstack root).', |
| 428 | + ).toHaveLength(1); |
404 | 429 | expect( |
405 | | - pkg.scripts?.[SCRIPT_NAME], |
406 | | - `\`${TURBO_TASK}\` resolves to the root package.json script "${SCRIPT_NAME}"`, |
407 | | - ).toBeDefined(); |
| 430 | + job.steps.some((step) => /\bturbo run type-check\b/.test(step.run ?? '')), |
| 431 | + `the premise: \`${UI_JOBS.typeCheck}\` is the job that runs \`turbo run type-check\` over the ui packages`, |
| 432 | + ).toBe(true); |
408 | 433 | }); |
409 | 434 |
|
410 | | - it('runs as part of `pnpm type-check`', () => { |
| 435 | + it('runs on every type-check of the ui tree, an e2e-only change included', () => { |
411 | 436 | expect( |
412 | | - turbo.tasks['type-check']?.dependsOn ?? [], |
413 | | - `turbo.json's \`type-check\` task must \`dependsOn\` \`${TURBO_TASK}\`. That dependency is ` + |
414 | | - 'the entire wiring: ci.yml’s existing `Run type-check` step gains e2e/ coverage with ' + |
415 | | - 'no new workflow entry, exactly as `lint` gained `//#lint:root` in objectui#4456.', |
416 | | - ).toContain(TURBO_TASK); |
417 | | - }); |
418 | | - |
419 | | - /** |
420 | | - * objectui#12222. Turbo folds a dependency's hash into its dependent's, and |
421 | | - * every package's `type-check` depends on this task — so this task's hash is |
422 | | - * part of EVERY package's type-check hash. With no `inputs`, a root task |
423 | | - * hashes every tracked file in the repository (turbo's default for the root |
424 | | - * package), so any commit anywhere moved all of those hashes and the restored |
425 | | - * `.turbo/cache` replayed nothing: `Type Check` re-ran every package on every |
426 | | - * pull request and queue build. Measured on that card, before the fix: one |
427 | | - * untracked file under `docs/` moved every `type-check` hash and no `build` |
428 | | - * hash. |
429 | | - * |
430 | | - * The task stays uncached (below), so its `inputs` decide nothing but that |
431 | | - * hash. They name its own program — what `tsconfig.e2e.json` includes, and |
432 | | - * the config itself — so the hash moves when the e2e program does and not |
433 | | - * when anything else in the repository does. Turbo adds the root |
434 | | - * `package.json` and `turbo.json` on its own. |
435 | | - */ |
436 | | - it('hashes its own program, not the whole repository, because every type-check inherits its hash', () => { |
437 | | - const inputs = turbo.tasks[TURBO_TASK]?.inputs ?? []; |
438 | | - expect( |
439 | | - inputs, |
440 | | - `\`${TURBO_TASK}\` declares no \`inputs\`, so turbo hashes every tracked file in the ` + |
441 | | - 'repository for it — and every package’s `type-check` inherits that hash through ' + |
442 | | - '`dependsOn`, so no commit can ever replay a cached type-check (objectui#12222).', |
443 | | - ).not.toHaveLength(0); |
444 | | - expect(inputs, '`$TURBO_DEFAULT$` on a root task is the whole repository again').not.toContain( |
445 | | - '$TURBO_DEFAULT$', |
| 437 | + steps[0]?.if, |
| 438 | + `the \`${uiRun(SCRIPT_NAME)}\` step must carry no \`if:\` of its own: as objectui's root task it ` + |
| 439 | + 'ran on every `pnpm type-check`, and a condition here would let a run type-check the packages ' + |
| 440 | + 'and skip e2e/.', |
| 441 | + ).toBeUndefined(); |
| 442 | + expect(job.if ?? '', 'the premise: the job runs when the `ui` paths filter is set').toMatch( |
| 443 | + /needs\.filter\.outputs\.ui\b/, |
446 | 444 | ); |
447 | | - |
448 | | - const matchers = inputs.map((glob) => globToRegExp(glob)); |
449 | | - const program = [...programFiles(), 'tsconfig.e2e.json']; |
450 | | - const unhashed = program.filter((file) => !matchers.some((re) => re.test(file))); |
| 445 | + const globs = uiFilterGlobs(rootCi()); |
| 446 | + const program = [...programFiles(), 'tsconfig.e2e.json'].map(repoPath); |
| 447 | + expect(program.length, 'the e2e program resolved implausibly few files').toBeGreaterThan(5); |
| 448 | + const unfiltered = program.filter((file) => !globs.some((glob) => globMatches(glob, file))); |
451 | 449 | expect( |
452 | | - unhashed, |
453 | | - `files of the e2e program that \`${TURBO_TASK}\`'s inputs do not name — its hash would not ` + |
454 | | - 'move when they change:\n' + |
455 | | - unhashed.map((f) => ` - ${f}`).join('\n'), |
| 450 | + unfiltered, |
| 451 | + 'files of the e2e program the `ui` paths filter does not match — a change to only these would ' + |
| 452 | + `not run \`${UI_JOBS.typeCheck}\`, so nothing would type-check it:\n` + |
| 453 | + unfiltered.map((f) => ` - ${f}`).join('\n'), |
456 | 454 | ).toEqual([]); |
| 455 | + }); |
457 | 456 |
|
458 | | - const ownProgram = /^(?:e2e\/|playwright[^/]*\.config\.ts$|tsconfig\.e2e\.json$)/; |
459 | | - const beyond = [...repoFilesOnDisk()].filter( |
460 | | - (file) => matchers.some((re) => re.test(file)) && !ownProgram.test(file), |
461 | | - ); |
| 457 | + /** |
| 458 | + * objectui#12222. Turbo folds a dependency's hash into its dependent's, so |
| 459 | + * when every package's `type-check` depended on this root task, its hash was |
| 460 | + * part of every one of theirs, and with no `inputs` any commit anywhere moved |
| 461 | + * all of them. Measured on that card, before the fix: one untracked file under |
| 462 | + * `docs/` moved every `type-check` hash and no `build` hash. objectui scoped |
| 463 | + * the root task's inputs to its own program. Run as a step, the program |
| 464 | + * enters no turbo hash at all — provided no root task comes back into the |
| 465 | + * `type-check` graph, which is what this holds. |
| 466 | + */ |
| 467 | + it('folds no root-task hash into any package type-check, because every type-check would inherit it', () => { |
| 468 | + const typeCheck = liveTurboConfig().tasks?.['type-check'] as { dependsOn?: string[] } | undefined; |
| 469 | + expect(typeCheck, 'the root turbo.json must define `type-check`').toBeDefined(); |
462 | 470 | expect( |
463 | | - beyond, |
464 | | - `\`${TURBO_TASK}\`'s inputs reach files outside its own program, and each one moves every ` + |
465 | | - 'package’s type-check hash when it changes:\n' + |
466 | | - beyond.map((f) => ` - ${f}`).join('\n'), |
| 471 | + (typeCheck?.dependsOn ?? []).filter((dep) => dep.startsWith('//#')), |
| 472 | + 'the live `type-check` depends on a root task: its hash is now part of every package ' + |
| 473 | + 'type-check hash, so it must declare inputs scoped to its own program (objectui#12222), ' + |
| 474 | + 'and the step this file pins becomes a second run of the same program.', |
467 | 475 | ).toEqual([]); |
468 | 476 | }); |
469 | 477 |
|
470 | | - /** |
471 | | - * The partition rule from `turbo-task-guard-coverage.test.ts`, restated where |
472 | | - * it bites. That file requires every CACHEABLE task to have a |
473 | | - * `turbo-<task>-inputs.test.ts` deriving its out-of-package reads — and for a |
474 | | - * `//#`-prefixed task the derived name is not even a legal path, so a cached |
475 | | - * root task cannot satisfy the family at all. The substantive reason is the |
476 | | - * same one: tsc's real read set is the import closure (`@playwright/test`'s |
477 | | - * declarations, `@types/node`), and the sibling guards deliberately narrow to |
478 | | - * config root files. A cached verdict over that is replayable in a way nobody |
479 | | - * derives, which is objectui#3514 with a type gate attached. |
480 | | - */ |
481 | | - it('is uncached, the side of the guard-coverage partition it belongs on', () => { |
| 478 | + it('is uncached: the step is the script itself, which no turbo cache stores', () => { |
482 | 479 | expect( |
483 | | - turbo.tasks[TURBO_TASK]?.cache, |
484 | | - `\`${TURBO_TASK}\` must stay "cache": false. If it becomes cacheable, ` + |
485 | | - 'turbo-task-guard-coverage.test.ts starts demanding a derived-inputs guard for it — and ' + |
486 | | - 'the name that guard would have to carry (turbo-//#type-check:e2e-inputs.test.ts) is not ' + |
487 | | - 'a path. It costs a few seconds; leave it uncached.', |
488 | | - ).toBe(false); |
| 480 | + steps[0]?.run?.trim(), |
| 481 | + `the step must run \`${uiRun(SCRIPT_NAME)}\` directly. Routed through \`turbo run\` it would be ` + |
| 482 | + 'cacheable, and a tsc program\'s real read set is its import closure (`@playwright/test`\'s ' + |
| 483 | + 'declarations, `@types/node`), which no derived-inputs guard in this family narrows soundly.', |
| 484 | + ).toBe(uiRun(SCRIPT_NAME)); |
489 | 485 | }); |
490 | 486 | }); |
0 commit comments