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
116 changes: 116 additions & 0 deletions scripts/__tests__/e2e-type-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ import ts from 'typescript';
* spelling would be satisfied by a config that compiles nothing, which is the
* failure mode it exists to prevent.
*
* objectui#4477 is the same statement one directory UP. The four root
* `playwright*.config.ts` files sit beside `tsconfig.e2e.json` rather than under
* `e2e/`, so #4471's glob did not reach them and nothing else compiled them
* either — yet they decide which specs run, where they run, and what
* `webServer` command builds the app under test. Widening the include cost zero
* errors (measured on both sides of the twelve days between filing and landing),
* so what this half pins is the GATE, not a fix: `testIgnore` or a `projects`
* entry misspelled into `undefined` is invisible to Playwright, which transpiles
* the config and reads the result as intent.
*
* Both halves are asserted the same way, and it is the way that matters: by
* discovering what is on disk and asking whether the program resolves it, never
* by naming the files. A four-name list would pass on the day it is written and
* be wrong the day a fifth config lands — which is precisely the defect both
* cards are about.
*
* The one structural difference from `scripts-type-check.test.ts`: that gate is
* a direct step in ci.yml's `type-check` job, this one is a turbo ROOT TASK
* (`//#type-check:e2e`) that the `type-check` task `dependsOn` — the shape
Expand Down Expand Up @@ -80,6 +96,22 @@ function typeScriptSourcesOnDisk(): string[] {
return out.sort();
}

/**
* Every root-level `playwright*.config.ts` on disk, repo-relative.
*
* A faithful translation of the config's second `include` pattern: `*` in a
* tsconfig glob does not cross a path separator, so the pattern matches files
* directly at the repo root and this walk must not descend either. Discovered,
* not listed — see the header.
*/
function rootPlaywrightConfigsOnDisk(): string[] {
return fs
.readdirSync(repoRoot, { withFileTypes: true })
.filter((entry) => entry.isFile() && /^playwright.*\.config\.ts$/.test(entry.name))
.map((entry) => entry.name)
.sort();
}

/** Repo-relative, POSIX-separated program root files. */
function programFiles(): Set<string> {
return new Set(
Expand Down Expand Up @@ -112,6 +144,34 @@ function diagnosticsFor(source: string): string[] {
}
}

/**
* Parse the REAL config against a throwaway tree that holds one root config
* which does not exist in this repo, and return the program root set.
*
* This is how the glob-not-a-list doctrine is asked as a question rather than
* asserted as a spelling. An `include` of four literal filenames satisfies every
* on-disk sweep in this file on the day it is written; only a config that
* resolves a name nobody has typed yet can answer for the fifth one. The tree is
* throwaway rather than the repo root because a transient `.ts` file appearing
* at the real root would be visible to every other guard that enumerates it.
*/
function programFilesWithSyntheticRootConfig(name: string): Set<string> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'e2e-type-check-fifth-'));
try {
const copied = path.join(dir, path.basename(configPath));
fs.copyFileSync(configPath, copied);
fs.writeFileSync(path.join(dir, name), 'export default {};\n');
fs.mkdirSync(path.join(dir, 'e2e'));
fs.writeFileSync(path.join(dir, 'e2e', 'placeholder.spec.ts'), 'export {};\n');

const read = ts.readConfigFile(copied, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dir, undefined, copied);
return new Set(parsed.fileNames.map((f) => path.relative(dir, f).split(path.sep).join('/')));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}

describe('tsconfig.e2e.json — the project itself (objectui#4471)', () => {
/**
* A tsconfig that fails to parse does not fail loudly: TypeScript falls back
Expand Down Expand Up @@ -257,6 +317,62 @@ describe('tsconfig.e2e.json — coverage of e2e/ (objectui#4471)', () => {
});
});

describe('tsconfig.e2e.json — coverage of the root playwright configs (objectui#4477)', () => {
it('resolves every root playwright*.config.ts on disk, with none left out', () => {
const onDisk = rootPlaywrightConfigsOnDisk();

// Non-vacuity first, for the same reason as the sweep above: the set
// difference below is satisfied by a discovery that finds nothing. Four
// configs exist today. If one is genuinely retired, lower this floor — but
// never satisfy it by narrowing the pattern in `rootPlaywrightConfigsOnDisk`,
// which would be this gate reporting green about files it stopped looking at.
expect(
onDisk.length,
'no playwright*.config.ts found at the repo root — the discovery is broken',
).toBeGreaterThanOrEqual(4);

const inProject = programFiles();
const uncovered = onDisk.filter((f) => !inProject.has(f));

expect(
uncovered,
'These root Playwright configs are not in tsconfig.e2e.json’s program, so nothing ' +
'type-checks them:\n' +
uncovered.map((f) => ` - ${f}`).join('\n') +
'\n\nWiden the "include" globs in tsconfig.e2e.json. These files decide which specs ' +
'run, where they run, and what `webServer` command builds the app under test — and ' +
'Playwright transpiles a config rather than checking it, so a `defineConfig` option ' +
'misspelled into `undefined` is read as intent and surfaces only as a confusing ' +
'runtime failure in the e2e job (objectui#4477).',
).toEqual([]);
});

it('would resolve a FIFTH root config that nobody has written yet', () => {
// The assertion above is a set difference over what exists. It would stay
// green under an `include` that names today's four files outright, and then
// the next config to land would sit outside every program again — the exact
// shape of objectui#4471 and objectui#4477. So ask the real config about a
// name it cannot have been written against.
const resolved = programFilesWithSyntheticRootConfig('playwright.not-yet-written.config.ts');

expect(
[...resolved].sort(),
'tsconfig.e2e.json no longer picks up a NEW root playwright config. Its second ' +
'"include" entry must stay a glob (`playwright*.config.ts`); an enumeration of the ' +
'configs that exist today passes every other assertion in this file and still lets ' +
'the next one land in no tsc program at all.',
).toContain('playwright.not-yet-written.config.ts');

// …and that the throwaway tree really was parsed like the real project,
// rather than the assertion above passing on an empty or defaulted program.
expect(
[...resolved].sort(),
'the throwaway tree did not resolve like the real project — the probe is broken, ' +
'not the config',
).toContain('e2e/placeholder.spec.ts');
});
});

describe('the gate is actually wired up (objectui#4471)', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as {
scripts?: Record<string, string>;
Expand Down
37 changes: 27 additions & 10 deletions tsconfig.e2e.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// Type-checks the `.ts` files under `e2e/` — the Playwright specs, their shared
// helpers, and `e2e/live/global-setup.ts`, which runs before every live-suite run.
// helpers, and `e2e/live/global-setup.ts`, which runs before every live-suite run
// — plus, since objectui#4477, the four root `playwright*.config.ts` files that
// decide which of those specs run, where they run, and what `webServer` command
// builds the app under test.
//
// objectui#4471: nothing compiled them. `e2e/` is not a workspace package, so
// `turbo run type-check` — which is driven by package.json `scripts` — cannot
Expand All @@ -20,7 +23,7 @@
// true, for its whole life. Playwright would never have told anyone.
//
// This project is standalone and cheap: it imports no workspace package — every
// e2e import is `@playwright/test`, `./helpers`, or a `node:` builtin — so unlike
// import in it is `@playwright/test`, `./helpers`, or a `node:` builtin — so unlike
// `tsconfig.vitest-setup.json` it needs no built declarations and can run before,
// or independently of, any build. `scripts/__tests__/e2e-type-check.test.ts` pins
// that premise, so if a spec ever imports `@object-ui/*` the wiring has to be
Expand All @@ -38,11 +41,17 @@
// for `//#lint:root`, and it is why this file needs no ci.yml entry of its own
// the way `type-check:scripts` and `type-check:vitest-setup` do.
//
// NOT covered here, deliberately: the four root `playwright*.config.ts` files,
// which sit beside this file rather than under `e2e/` and are in no tsc program
// either. They compile clean against this exact option set today (measured,
// exit 0), so it is a dormant gap rather than a live defect — filed as
// objectui#4477 instead of widened into objectui#4471's surface.
// The four root `playwright*.config.ts` files were the deliberate remainder of
// objectui#4471 — they sit beside this file rather than under `e2e/`, so the
// `e2e/**` glob did not reach them and no other tsc program did either.
// objectui#4477 closed that: the second `include` pattern below brings them in.
// It cost zero errors, which was the point rather than a disappointment — they
// compiled clean against this exact option set when #4477 was filed and again
// when it landed (measured both times, `tsc -p tsconfig.e2e.json`, exit 0). What
// was missing was never a fix, it was the gate: `webServer.command`, `testIgnore`
// and `projects` are read by no compiler, so a `defineConfig` option that stops
// existing — or is misspelled into `undefined` — surfaces only as a confusing
// Playwright runtime failure in the `e2e` CI job.
{
"compilerOptions": {
// Node 22 (`engines.node: ">=22"`) runs the Playwright worker that executes
Expand Down Expand Up @@ -116,7 +125,15 @@
// The whole directory, by glob. Not a file list: a list is a thing to forget,
// and the point of objectui#4471 is that a NEW spec under `e2e/` must not be
// able to land outside every program again.
// `scripts/__tests__/e2e-type-check.test.ts` pins that this glob really does
// reach every `.ts` file on disk under `e2e/`.
"include": ["e2e/**/*.ts"]
//
// The second pattern is the same doctrine for the root configs (objectui#4477)
// rather than the four names that exist today, so a FIFTH root config cannot
// land outside every program either. It carries no `**`, so `*` stops at the
// path separator and it matches root-level files only — which is where all
// four live.
//
// `scripts/__tests__/e2e-type-check.test.ts` pins that both patterns really do
// reach every matching file on disk, and that the second one would pick up a
// root config that does not exist yet.
"include": ["e2e/**/*.ts", "playwright*.config.ts"]
}
Loading