From 8c558aac1214e31a271386d49f2adf6d843cd0d7 Mon Sep 17 00:00:00 2001 From: Igor Lukanin Date: Wed, 5 Aug 2026 17:02:24 +0200 Subject: [PATCH 1/2] Test the compiled output in cubejs-api-gateway, and enforce it --- packages/cubejs-api-gateway/jest.config.js | 7 ++ packages/cubejs-api-gateway/package.json | 1 + ...ate-parser.test.js => date-parser.test.ts} | 38 +++---- ... => normalize-query-filters-dates.test.ts} | 44 +++++--- .../test/test-collection.test.ts | 101 ++++++++++++++++++ 5 files changed, 159 insertions(+), 32 deletions(-) rename packages/cubejs-api-gateway/test/{date-parser.test.js => date-parser.test.ts} (82%) rename packages/cubejs-api-gateway/test/{normalize-query-filters-dates.test.js => normalize-query-filters-dates.test.ts} (94%) create mode 100644 packages/cubejs-api-gateway/test/test-collection.test.ts diff --git a/packages/cubejs-api-gateway/jest.config.js b/packages/cubejs-api-gateway/jest.config.js index 469aed233fcac..d0e36656d6a64 100644 --- a/packages/cubejs-api-gateway/jest.config.js +++ b/packages/cubejs-api-gateway/jest.config.js @@ -4,5 +4,12 @@ const base = require('../../jest.base.config'); module.exports = { ...base, rootDir: '.', + // Tests run from compiled output: there is no transform, so the sources under + // test/ cannot execute. Run `yarn tsc` first, and name a suite without its + // extension — `jest date-parser` — since the runtime path is + // dist/test/date-parser.test.js. `.spec.` is included because jest's default + // testMatch accepted it, and pinning this must not quietly drop a suite. + // test/test-collection.test.ts enforces that dist is what gets collected. + testMatch: ['/dist/test/**/*.{test,spec}.js'], snapshotResolver: '/test/snapshotResolver.js', }; diff --git a/packages/cubejs-api-gateway/package.json b/packages/cubejs-api-gateway/package.json index d72a52c0fb2dc..a8a244437009a 100644 --- a/packages/cubejs-api-gateway/package.json +++ b/packages/cubejs-api-gateway/package.json @@ -64,6 +64,7 @@ "@types/supertest": "^2.0.10", "@types/uuid": "^8.3.1", "jest": "^29", + "micromatch": "^4.0.8", "mysql": "^2.18.1", "should": "^13.2.3", "supertest": "^4.0.2", diff --git a/packages/cubejs-api-gateway/test/date-parser.test.js b/packages/cubejs-api-gateway/test/date-parser.test.ts similarity index 82% rename from packages/cubejs-api-gateway/test/date-parser.test.js rename to packages/cubejs-api-gateway/test/date-parser.test.ts index 3ce1710e8ede4..0456adc01064b 100644 --- a/packages/cubejs-api-gateway/test/date-parser.test.js +++ b/packages/cubejs-api-gateway/test/date-parser.test.ts @@ -1,5 +1,3 @@ -/* globals describe,test,expect,jest */ - import { dateParser } from '../src/date-parser'; describe('dateParser', () => { @@ -60,7 +58,9 @@ describe('dateParser', () => { test('from 1 hour ago to now LA', () => { // 'Z' stands for Zulu time, which is also GMT and UTC. - const now = '2020-09-22T13:03:20.518Z'; + // `now` reaches the parser only as `moment(now)`, which takes a string as + // readily as a Date; the cast states that, since the source is untyped JS. + const now = '2020-09-22T13:03:20.518Z' as unknown as Date; // LA is GMT-0700, 7 hours diff const tz = 'America/Los_Angeles'; @@ -74,13 +74,13 @@ describe('dateParser', () => { test('from 1 quarter ago to now', () => { const now = new Date(2021, 4, 3, 12, 0, 0, 0); - Date.now = jest.fn().mockReturnValue(now); + jest.spyOn(Date, 'now').mockReturnValue(now.getTime()); expect(dateParser('from 1 quarter ago to now', 'UTC', now)).toStrictEqual( ['2021-02-03T00:00:00.000', '2021-05-03T23:59:59.999'] ); - Date.now.mockRestore(); + jest.restoreAllMocks(); }); test('from 7 days ago to now', () => { @@ -103,50 +103,50 @@ describe('dateParser', () => { test('last 2 quarters', () => { const now = new Date(2021, 1, 15, 13, 0, 0, 0); - Date.now = jest.fn().mockReturnValue(now); + jest.spyOn(Date, 'now').mockReturnValue(now.getTime()); expect(dateParser('last 2 quarters', 'UTC', now)).toStrictEqual([ '2020-07-01T00:00:00.000', '2020-12-31T23:59:59.999', ]); - Date.now.mockRestore(); + jest.restoreAllMocks(); }); test('last 6 months from month with less days than previous month', () => { - Date.now = jest.fn().mockReturnValue(new Date(2021, 1, 15, 13, 0, 0, 0)); + jest.spyOn(Date, 'now').mockReturnValue(new Date(2021, 1, 15, 13, 0, 0, 0).getTime()); expect(dateParser('last 6 months', 'UTC', new Date(2021, 1, 15, 13, 0, 0, 0))).toStrictEqual([ '2020-08-01T00:00:00.000', '2021-01-31T23:59:59.999', ]); - Date.now.mockRestore(); + jest.restoreAllMocks(); }); test('last 6 months from month with more days than previous month', () => { - Date.now = jest.fn().mockReturnValue(new Date(2021, 2, 15, 13, 0, 0, 0)); + jest.spyOn(Date, 'now').mockReturnValue(new Date(2021, 2, 15, 13, 0, 0, 0).getTime()); expect(dateParser('last 6 months', 'UTC', new Date(2021, 1, 15, 13, 0, 0, 0))).toStrictEqual([ '2020-09-01T00:00:00.000', '2021-02-28T23:59:59.999', ]); - Date.now.mockRestore(); + jest.restoreAllMocks(); }); test('next 6 months', () => { - Date.now = jest.fn().mockReturnValue(new Date(2021, 1, 20, 13, 0, 0, 0)); + jest.spyOn(Date, 'now').mockReturnValue(new Date(2021, 1, 20, 13, 0, 0, 0).getTime()); expect(dateParser('next 6 months', 'UTC', new Date(2021, 1, 20, 13, 0, 0, 0))).toStrictEqual([ '2021-03-01T00:00:00.000', '2021-08-31T23:59:59.999', ]); - Date.now.mockRestore(); + jest.restoreAllMocks(); }); test('next month', () => { - Date.now = jest.fn().mockReturnValue(new Date(2021, 2, 5, 13, 0, 0, 0)); + jest.spyOn(Date, 'now').mockReturnValue(new Date(2021, 2, 5, 13, 0, 0, 0).getTime()); expect(dateParser('next month', 'UTC', new Date(2021, 2, 5, 13, 0, 0, 0))).toStrictEqual( [ '2021-04-01T00:00:00.000', @@ -154,11 +154,11 @@ describe('dateParser', () => { ] ); - Date.now.mockRestore(); + jest.restoreAllMocks(); }); test('next 5 days', () => { - Date.now = jest.fn().mockReturnValue(new Date(2021, 2, 5, 13, 0, 0, 0)); + jest.spyOn(Date, 'now').mockReturnValue(new Date(2021, 2, 5, 13, 0, 0, 0).getTime()); expect(dateParser('next 5 days', 'UTC', new Date(2021, 2, 5, 13, 0, 0, 0))).toStrictEqual( [ '2021-03-06T00:00:00.000', @@ -166,7 +166,7 @@ describe('dateParser', () => { ] ); - Date.now.mockRestore(); + jest.restoreAllMocks(); }); test('throws error on from invalid date to date', () => { @@ -182,7 +182,7 @@ describe('dateParser', () => { }); test('from 12AM till now by hour', () => { - Date.now = jest.fn().mockReturnValue(new Date(2021, 2, 5, 13, 0, 0, 0)); + jest.spyOn(Date, 'now').mockReturnValue(new Date(2021, 2, 5, 13, 0, 0, 0).getTime()); expect(dateParser('2 weeks ago by hour', 'UTC', new Date(Date.UTC(2021, 2, 5, 13, 0, 0, 0)))).toStrictEqual( [ '2021-02-19T13:00:00.000', @@ -190,6 +190,6 @@ describe('dateParser', () => { ] ); - Date.now.mockRestore(); + jest.restoreAllMocks(); }); }); diff --git a/packages/cubejs-api-gateway/test/normalize-query-filters-dates.test.js b/packages/cubejs-api-gateway/test/normalize-query-filters-dates.test.ts similarity index 94% rename from packages/cubejs-api-gateway/test/normalize-query-filters-dates.test.js rename to packages/cubejs-api-gateway/test/normalize-query-filters-dates.test.ts index 8501b77892d48..ddb7389ba5312 100644 --- a/packages/cubejs-api-gateway/test/normalize-query-filters-dates.test.js +++ b/packages/cubejs-api-gateway/test/normalize-query-filters-dates.test.ts @@ -1,7 +1,25 @@ -/* globals describe,test,expect,jest,beforeEach,afterEach */ - import { normalizeQuery, normalizeDateFilterValues, resolveDateRange } from '../src/query'; +// The tests below walk the normalized filter tree, including the OR/AND group +// nodes that `NormalizedQuery.filters` (typed as a flat leaf array) does not +// model. This mirrors what the untyped resolver in src/query.js really returns. +// Fields are non-optional on purpose: a test that reaches for `.or` on a node +// that has none should fail on the assertion, not be nudged into a guard here. +type FilterNode = { + values: any[]; + or: FilterNode[]; + and: FilterNode[]; +}; + +type NormalizedResult = { + filters: FilterNode[]; + timeDimensions: { dateRange: string[] }[]; +}; + +function normalized(query: any, persistent = false): NormalizedResult { + return normalizeQuery(query, persistent) as unknown as NormalizedResult; +} + // Tests for filter-leaf date-range resolution at the gateway. This mirrors // what `timeDimensions.dateRange` has always done: a single relative string // (e.g. "last 2 weeks") resolves to an absolute [start, end] pair before the @@ -285,7 +303,7 @@ describe('normalizeQuery: date-range filter resolution', () => { test('top-level inDateRange filter with relative string is resolved', () => { // Why: even without an OR wrapper, a filter leaf with a relative date // value must be resolved at the gateway. - const result = normalizeQuery({ + const result = normalized({ ...baseQuery, filters: [ { member: 'Orders.createdAt', operator: 'inDateRange', values: ['last 2 weeks'] }, @@ -301,7 +319,7 @@ describe('normalizeQuery: date-range filter resolution', () => { test('inDateRange leaf nested inside OR is resolved (the actual feature)', () => { // Why: this is the whole point. The recursive walker must reach leaves // inside groups and apply the helper there too. - const result = normalizeQuery({ + const result = normalized({ ...baseQuery, filters: [{ or: [ @@ -319,7 +337,7 @@ describe('normalizeQuery: date-range filter resolution', () => { test('inDateRange leaf nested inside AND is resolved', () => { // Why: AND must work symmetrically with OR. - const result = normalizeQuery({ + const result = normalized({ ...baseQuery, filters: [{ and: [ @@ -334,7 +352,7 @@ describe('normalizeQuery: date-range filter resolution', () => { test('deeply nested date filter (OR inside AND) is resolved', () => { // Why: the walker must recurse to arbitrary depth, not just one level. - const result = normalizeQuery({ + const result = normalized({ ...baseQuery, filters: [{ and: [ @@ -358,7 +376,7 @@ describe('normalizeQuery: date-range filter resolution', () => { // Why: invariant — existing queries that only use top-level timeDimensions // must produce the same shape they always did. Both paths share // resolveDateRange so they cannot diverge. - const result = normalizeQuery({ + const result = normalized({ ...baseQuery, timeDimensions: [ { dimension: 'Orders.createdAt', dateRange: 'last 2 weeks' }, @@ -374,7 +392,7 @@ describe('normalizeQuery: date-range filter resolution', () => { test('invalid relative date inside OR raises UserError at gateway', () => { // Why: a malformed relative date must fail at the API boundary with a // clear message, not deep in the SQL planner. - expect(() => normalizeQuery({ + expect(() => normalized({ ...baseQuery, filters: [{ or: [ @@ -391,20 +409,20 @@ describe('normalizeQuery: date-range filter resolution', () => { const dayBoundaryNow = new Date(Date.UTC(2026, 5, 25, 2, 0, 0, 0)); jest.spyOn(Date, 'now').mockReturnValue(dayBoundaryNow.getTime()); - const utc = normalizeQuery({ + const utc = normalized({ ...baseQuery, timezone: 'UTC', filters: [{ or: [ { member: 'Orders.createdAt', operator: 'inDateRange', values: ['today'] }, - ]}], + ] }], }, false); - const la = normalizeQuery({ + const la = normalized({ ...baseQuery, timezone: 'America/Los_Angeles', filters: [{ or: [ { member: 'Orders.createdAt', operator: 'inDateRange', values: ['today'] }, - ]}], + ] }], }, false); expect(utc.filters[0].or[0].values[0]).toMatch(/^2026-06-25T/); @@ -413,7 +431,7 @@ describe('normalizeQuery: date-range filter resolution', () => { test('non-date filters inside OR are untouched', () => { // Why: regression guard — equals/contains/etc. must not be modified. - const result = normalizeQuery({ + const result = normalized({ ...baseQuery, filters: [{ or: [ diff --git a/packages/cubejs-api-gateway/test/test-collection.test.ts b/packages/cubejs-api-gateway/test/test-collection.test.ts new file mode 100644 index 0000000000000..e39a373ff89ff --- /dev/null +++ b/packages/cubejs-api-gateway/test/test-collection.test.ts @@ -0,0 +1,101 @@ +import fs from 'fs'; +import path from 'path'; +// No @types/micromatch in the tree, and this package sets `noImplicitAny: false` +// — require it rather than rely on that staying true. +// eslint-disable-next-line global-require +const micromatch = require('micromatch'); + +// This file executes from dist/test, so the package root is two levels up and +// the jest config must be required by absolute path rather than a relative one. +const PACKAGE_ROOT = path.join(__dirname, '..', '..'); + +// eslint-disable-next-line import/no-dynamic-require, global-require +const jestConfig = require(path.join(PACKAGE_ROOT, 'jest.config.js')); + +const IGNORED_DIRS = new Set(['node_modules', 'dist', 'coverage']); + +/** + * Every test file in the package must have a compiled counterpart that + * `testMatch` actually collects. This package runs its tests from `dist/test`, + * so a source file the build never emits — or emits somewhere `testMatch` does + * not look — is a test that silently stops running. That is how + * `date-parser.test.js` and `normalize-query-filters-dates.test.js` came to be + * editable but inert. + * + * The walk covers the whole package rather than just `test/`, so a test added + * under `src/` (which compiles to `dist/src/`, outside `testMatch`) is caught + * too, and it matches any `.test.*` extension so a novel one cannot slip past. + */ +function sourceTestFiles(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + return IGNORED_DIRS.has(entry.name) ? [] : sourceTestFiles(full); + } + // `.spec.` as well as `.test.`: jest's default testMatch collected both, so + // a spec file must not become invisible just because the pattern is pinned. + return /\.(test|spec)\.[^.]+$/.test(entry.name) ? [full] : []; + }); +} + +const SOURCE_TEST_FILES = sourceTestFiles(PACKAGE_ROOT); + +/** + * Matches a path against the real `testMatch`, via the same glob library jest + * uses. Deriving from the config rather than restating the pattern keeps this + * honest when someone edits it; using micromatch rather than a hand-rolled + * translation means the full glob grammar keeps working — sibling packages + * already use brace and choice patterns this file would otherwise have to + * reimplement. + */ +function isCollected(file: string): boolean { + const patterns: string[] = jestConfig.testMatch; + return micromatch.isMatch(file, patterns.map(p => p.replace('', PACKAGE_ROOT))); +} + +/** `test/foo/bar.test.ts` -> `dist/test/foo/bar.test.js` */ +function compiledCounterpart(sourceFile: string): string { + const relative = path.relative(PACKAGE_ROOT, sourceFile); + return path.join(PACKAGE_ROOT, 'dist', relative).replace(/\.[^.]+$/, '.js'); +} + +const relativeToPackage = (file: string) => path.relative(PACKAGE_ROOT, file); + +describe('test collection', () => { + test('testMatch is configured', () => { + // Without it jest falls back to its default, which collects the sources — + // and there is no transform, so each one dies with + // `SyntaxError: Cannot use import statement outside a module`. + expect(Array.isArray(jestConfig.testMatch)).toBe(true); + }); + + test('nothing narrows collection behind testMatch', () => { + // The assertions below reason from `testMatch` alone, so a key that skips a + // file jest would otherwise collect — the usual "temporarily ignore the + // flaky suite" edit — would slip a silently-inert test past them. + expect(jestConfig.testPathIgnorePatterns).toBeUndefined(); + expect(jestConfig.modulePathIgnorePatterns).toBeUndefined(); + expect(jestConfig.roots).toBeUndefined(); + }); + + test('testMatch targets the compiled output, not the sources', () => { + const collectedSources = SOURCE_TEST_FILES.filter(isCollected).map(relativeToPackage); + + expect(collectedSources).toEqual([]); + }); + + test('every test file has a compiled counterpart that is collected', () => { + // Guards the guard: an empty walk would make the assertion below vacuous + // and keep passing after the suite was deleted. + expect(SOURCE_TEST_FILES.length).toBeGreaterThan(5); + + const uncollected = SOURCE_TEST_FILES + .filter(file => { + const compiled = compiledCounterpart(file); + return !fs.existsSync(compiled) || !isCollected(compiled); + }) + .map(relativeToPackage); + + expect(uncollected).toEqual([]); + }); +}); From f08972fbbc447cc281b345c64913e9974dadb7d1 Mon Sep 17 00:00:00 2001 From: Igor Lukanin Date: Wed, 5 Aug 2026 18:46:43 +0200 Subject: [PATCH 2/2] Close the guard's reverse direction and stop the mock leak cascading --- .../test/date-parser.test.ts | 28 ++++------ .../test/test-collection.test.ts | 52 ++++++++++++++++--- 2 files changed, 53 insertions(+), 27 deletions(-) diff --git a/packages/cubejs-api-gateway/test/date-parser.test.ts b/packages/cubejs-api-gateway/test/date-parser.test.ts index 0456adc01064b..f7082603a4b03 100644 --- a/packages/cubejs-api-gateway/test/date-parser.test.ts +++ b/packages/cubejs-api-gateway/test/date-parser.test.ts @@ -1,6 +1,14 @@ import { dateParser } from '../src/date-parser'; describe('dateParser', () => { + // Restoring inside each test body only runs when the assertion passes, so one + // genuine failure would leave `Date.now` mocked for every test after it — and + // this file interleaves clock-mocking tests with ones that read the real + // clock, so a single red test would cascade into unrelated ones. + afterEach(() => { + jest.restoreAllMocks(); + }); + test('custom daily ranges returns day aligned dateRange', () => { expect(dateParser('from 1 days ago to now', 'UTC')).toStrictEqual( [dateParser('yesterday', 'UTC')[0], dateParser('today', 'UTC')[1]] @@ -58,9 +66,7 @@ describe('dateParser', () => { test('from 1 hour ago to now LA', () => { // 'Z' stands for Zulu time, which is also GMT and UTC. - // `now` reaches the parser only as `moment(now)`, which takes a string as - // readily as a Date; the cast states that, since the source is untyped JS. - const now = '2020-09-22T13:03:20.518Z' as unknown as Date; + const now = new Date('2020-09-22T13:03:20.518Z'); // LA is GMT-0700, 7 hours diff const tz = 'America/Los_Angeles'; @@ -79,8 +85,6 @@ describe('dateParser', () => { expect(dateParser('from 1 quarter ago to now', 'UTC', now)).toStrictEqual( ['2021-02-03T00:00:00.000', '2021-05-03T23:59:59.999'] ); - - jest.restoreAllMocks(); }); test('from 7 days ago to now', () => { @@ -109,8 +113,6 @@ describe('dateParser', () => { '2020-07-01T00:00:00.000', '2020-12-31T23:59:59.999', ]); - - jest.restoreAllMocks(); }); test('last 6 months from month with less days than previous month', () => { @@ -120,8 +122,6 @@ describe('dateParser', () => { '2020-08-01T00:00:00.000', '2021-01-31T23:59:59.999', ]); - - jest.restoreAllMocks(); }); test('last 6 months from month with more days than previous month', () => { @@ -131,8 +131,6 @@ describe('dateParser', () => { '2020-09-01T00:00:00.000', '2021-02-28T23:59:59.999', ]); - - jest.restoreAllMocks(); }); test('next 6 months', () => { @@ -141,8 +139,6 @@ describe('dateParser', () => { '2021-03-01T00:00:00.000', '2021-08-31T23:59:59.999', ]); - - jest.restoreAllMocks(); }); test('next month', () => { @@ -153,8 +149,6 @@ describe('dateParser', () => { '2021-04-30T23:59:59.999' ] ); - - jest.restoreAllMocks(); }); test('next 5 days', () => { @@ -165,8 +159,6 @@ describe('dateParser', () => { '2021-03-10T23:59:59.999' ] ); - - jest.restoreAllMocks(); }); test('throws error on from invalid date to date', () => { @@ -189,7 +181,5 @@ describe('dateParser', () => { '2021-02-19T13:59:59.999' ] ); - - jest.restoreAllMocks(); }); }); diff --git a/packages/cubejs-api-gateway/test/test-collection.test.ts b/packages/cubejs-api-gateway/test/test-collection.test.ts index e39a373ff89ff..ba4d0da24f7e2 100644 --- a/packages/cubejs-api-gateway/test/test-collection.test.ts +++ b/packages/cubejs-api-gateway/test/test-collection.test.ts @@ -12,7 +12,10 @@ const PACKAGE_ROOT = path.join(__dirname, '..', '..'); // eslint-disable-next-line import/no-dynamic-require, global-require const jestConfig = require(path.join(PACKAGE_ROOT, 'jest.config.js')); -const IGNORED_DIRS = new Set(['node_modules', 'dist', 'coverage']); +// `__snapshots__` is listed rather than left to the filename filter below: a +// snapshot always carries two extensions (`foo.test.ts.snap`), so it happens not +// to match today, but that is an accident of naming rather than an intent. +const IGNORED_DIRS = new Set(['node_modules', 'dist', 'coverage', '__snapshots__']); /** * Every test file in the package must have a compiled counterpart that @@ -40,6 +43,10 @@ function sourceTestFiles(dir: string): string[] { const SOURCE_TEST_FILES = sourceTestFiles(PACKAGE_ROOT); +const relativeToPackage = (file: string) => path.relative(PACKAGE_ROOT, file); + +const toPosix = (p: string) => p.split(path.sep).join('/'); + /** * Matches a path against the real `testMatch`, via the same glob library jest * uses. Deriving from the config rather than restating the pattern keeps this @@ -50,7 +57,16 @@ const SOURCE_TEST_FILES = sourceTestFiles(PACKAGE_ROOT); */ function isCollected(file: string): boolean { const patterns: string[] = jestConfig.testMatch; - return micromatch.isMatch(file, patterns.map(p => p.replace('', PACKAGE_ROOT))); + // Matched package-relative with posix separators, never as absolute paths. + // micromatch reads `\` as an escape, so on win32 an absolute path would match + // nothing at all — which fails asymmetrically: the "no source is collected" + // assertion would pass vacuously while the counterpart one named every file. + // Staying relative also keeps a glob metacharacter in the checkout path + // (`/Users/me/cube (fork)/…`) from turning the pattern into a choice group. + return micromatch.isMatch( + toPosix(relativeToPackage(file)), + patterns.map(p => toPosix(p.replace('/', ''))) + ); } /** `test/foo/bar.test.ts` -> `dist/test/foo/bar.test.js` */ @@ -59,8 +75,6 @@ function compiledCounterpart(sourceFile: string): string { return path.join(PACKAGE_ROOT, 'dist', relative).replace(/\.[^.]+$/, '.js'); } -const relativeToPackage = (file: string) => path.relative(PACKAGE_ROOT, file); - describe('test collection', () => { test('testMatch is configured', () => { // Without it jest falls back to its default, which collects the sources — @@ -69,13 +83,21 @@ describe('test collection', () => { expect(Array.isArray(jestConfig.testMatch)).toBe(true); }); - test('nothing narrows collection behind testMatch', () => { + test('no config key narrows collection behind testMatch', () => { // The assertions below reason from `testMatch` alone, so a key that skips a // file jest would otherwise collect — the usual "temporarily ignore the // flaky suite" edit — would slip a silently-inert test past them. - expect(jestConfig.testPathIgnorePatterns).toBeUndefined(); - expect(jestConfig.modulePathIgnorePatterns).toBeUndefined(); - expect(jestConfig.roots).toBeUndefined(); + // `testRegex` is included because it is mutually exclusive with `testMatch`: + // setting it makes jest throw rather than narrow, which is a different + // failure to reason about and better named here than discovered. + // + // This reaches config only. A positional `testPathPattern` on the command + // line narrows collection the same way and is invisible from here — see the + // `unit` script — so this guard bounds the config, not every route in. + const narrowingKeys = ['testPathIgnorePatterns', 'modulePathIgnorePatterns', 'roots', 'testRegex'] + .filter(key => jestConfig[key] !== undefined); + + expect(narrowingKeys).toEqual([]); }); test('testMatch targets the compiled output, not the sources', () => { @@ -98,4 +120,18 @@ describe('test collection', () => { expect(uncollected).toEqual([]); }); + + test('no collected test has lost its source', () => { + // The reverse direction, and the likelier one day to day: `tsc` is + // incremental and only `build` does `rm -rf dist`, so renaming or deleting a + // test leaves its old compiled copy behind, collected forever, running code + // whose source no longer exists. That is this file's own subject mirrored — + // a test running that nobody can see. + const expected = new Set(SOURCE_TEST_FILES.map(compiledCounterpart)); + const orphans = sourceTestFiles(path.join(PACKAGE_ROOT, 'dist')) + .filter(file => isCollected(file) && !expected.has(file)) + .map(relativeToPackage); + + expect(orphans).toEqual([]); + }); });