pipework 0.287.0 → 0.289.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +2 -0
- package/REFERENCE.md +6 -0
- package/dist/cli/commands/migrate.d.ts.map +1 -1
- package/dist/cli/commands/migrate.js +4 -0
- package/dist/cli/commands/migrate.js.map +1 -1
- package/dist/cli/commands/test.d.ts.map +1 -1
- package/dist/cli/commands/test.js +13 -0
- package/dist/cli/commands/test.js.map +1 -1
- package/dist/core/config/discover.d.ts +2 -1
- package/dist/core/config/discover.d.ts.map +1 -1
- package/dist/core/config/discover.js +2 -2
- package/dist/core/config/discover.js.map +1 -1
- package/dist/core/config/import-config.d.ts +2 -1
- package/dist/core/config/import-config.d.ts.map +1 -1
- package/dist/core/config/import-config.js +3 -7
- package/dist/core/config/import-config.js.map +1 -1
- package/dist/core/config/schema.d.ts.map +1 -1
- package/dist/core/config/schema.js +3 -1
- package/dist/core/config/schema.js.map +1 -1
- package/dist/data/migrate/apply.d.ts +16 -0
- package/dist/data/migrate/apply.d.ts.map +1 -1
- package/dist/data/migrate/apply.js +49 -2
- package/dist/data/migrate/apply.js.map +1 -1
- package/dist/data/migrate/run.d.ts +9 -0
- package/dist/data/migrate/run.d.ts.map +1 -1
- package/dist/data/migrate/run.js +3 -0
- package/dist/data/migrate/run.js.map +1 -1
- package/dist/test/auto-setup.js +4 -1
- package/dist/test/auto-setup.js.map +1 -1
- package/dist/test/discover-fork.d.ts +6 -1
- package/dist/test/discover-fork.d.ts.map +1 -1
- package/dist/test/discover-fork.js +6 -2
- package/dist/test/discover-fork.js.map +1 -1
- package/dist/test/shared-registry-guard.d.ts +2 -0
- package/dist/test/shared-registry-guard.d.ts.map +1 -0
- package/dist/test/shared-registry-guard.js +35 -0
- package/dist/test/shared-registry-guard.js.map +1 -0
- package/dist/test/shared-registry.d.ts +16 -0
- package/dist/test/shared-registry.d.ts.map +1 -0
- package/dist/test/shared-registry.js +63 -0
- package/dist/test/shared-registry.js.map +1 -0
- package/dist/test/vitest-workspace.d.ts.map +1 -1
- package/dist/test/vitest-workspace.js +63 -13
- package/dist/test/vitest-workspace.js.map +1 -1
- package/package.json +6 -2
- package/src/cli/commands/migrate.ts +6 -0
- package/src/cli/commands/test.ts +15 -0
- package/src/core/config/discover.ts +3 -3
- package/src/core/config/import-config.ts +9 -2
- package/src/core/config/schema.ts +3 -1
- package/src/data/migrate/apply.ts +62 -2
- package/src/data/migrate/run.ts +13 -0
- package/src/test/auto-setup.ts +4 -1
- package/src/test/discover-fork.ts +7 -2
- package/src/test/shared-registry-guard.ts +41 -0
- package/src/test/shared-registry.ts +71 -0
- package/src/test/vitest-workspace.ts +66 -13
|
@@ -9,10 +9,17 @@ import { setConfigBaseDir, clearConfigBaseDir } from './base-dir.js'
|
|
|
9
9
|
// resolver) goes through here so relative `migrations`/`schema` paths always
|
|
10
10
|
// resolve against the config file's directory, whichever entry path loads the
|
|
11
11
|
// config first in a process.
|
|
12
|
-
|
|
12
|
+
//
|
|
13
|
+
// `load` replaces the module loader, which is Node's `import()` by default. A test
|
|
14
|
+
// fork passes the vitest module runner's (#1006): the app's modules then evaluate
|
|
15
|
+
// once, in the same runner as the test files' imports, instead of once under Node.
|
|
16
|
+
export type ConfigLoader = (url: string) => Promise<Record<string, unknown>>
|
|
17
|
+
|
|
18
|
+
export async function importConfig(configPath: string, load?: ConfigLoader): Promise<Manifold> {
|
|
13
19
|
setConfigBaseDir(dirname(configPath))
|
|
14
20
|
try {
|
|
15
|
-
const
|
|
21
|
+
const url = pathToFileURL(configPath).href
|
|
22
|
+
const mod: Record<string, unknown> = load !== undefined ? await load(url) : await import(url)
|
|
16
23
|
const exported: unknown = mod['default'] ?? mod
|
|
17
24
|
|
|
18
25
|
if (!isManifold(exported)) {
|
|
@@ -274,7 +274,9 @@ const vitestProfileSchema = z.object({
|
|
|
274
274
|
isolate: z.boolean().optional().describe(
|
|
275
275
|
'Run each test file in a fresh worker. Pipework defaults to `false` — pipework\'s own ' +
|
|
276
276
|
'per-test database isolation already separates test state, and re-isolating the worker ' +
|
|
277
|
-
'costs a process spawn per file. Set `true` for a suite that relies on vitest\'s default.'
|
|
277
|
+
'costs a process spawn per file. Set `true` for a suite that relies on vitest\'s default. ' +
|
|
278
|
+
'In a project left at `false`, a file whose text calls vi.resetModules, vi.mock, vi.doMock, ' +
|
|
279
|
+
'vi.unmock or vi.doUnmock runs in a generated `<name>:registry-isolated` sibling.',
|
|
278
280
|
),
|
|
279
281
|
database: z.boolean().optional(),
|
|
280
282
|
setupFiles: z.array(z.string()).optional(),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
|
-
import postgres from 'postgres'
|
|
4
|
-
import { scanStatements } from './statements.js'
|
|
3
|
+
import postgres, { type Sql } from 'postgres'
|
|
4
|
+
import { scanStatements, stripComments } from './statements.js'
|
|
5
5
|
import { stripPragmaLines } from './pragma.js'
|
|
6
6
|
import { listMigrations } from './history.js'
|
|
7
7
|
|
|
@@ -52,6 +52,13 @@ export interface ApplyOptions {
|
|
|
52
52
|
* Issue #378.
|
|
53
53
|
*/
|
|
54
54
|
onResume?: (tag: string, fromStatement: number, total: number) => void
|
|
55
|
+
/**
|
|
56
|
+
* Called when a resumed no-transaction file's first statement is a
|
|
57
|
+
* CREATE [UNIQUE] INDEX CONCURRENTLY whose index was left INVALID by the cut
|
|
58
|
+
* build, after it was dropped and before the statement reruns. `index` is
|
|
59
|
+
* the schema-qualified name. Issue #1003.
|
|
60
|
+
*/
|
|
61
|
+
onDropInvalidIndex?: (tag: string, statement: number, index: string) => void
|
|
55
62
|
/**
|
|
56
63
|
* Called for each notice or warning a migration file's own statement raises,
|
|
57
64
|
* with the file's tag, the 1-based statement and the file's statement count.
|
|
@@ -193,6 +200,15 @@ export async function applyMigrations(options: ApplyOptions): Promise<number> {
|
|
|
193
200
|
if (from > 0) options.onResume?.(migration.tag, from + 1, migration.statements.length)
|
|
194
201
|
|
|
195
202
|
for (let i = from; i < migration.statements.length; i++) {
|
|
203
|
+
// Only the statement the cut interrupted (the first one run on a
|
|
204
|
+
// resume), and only its own index: a cut CREATE INDEX CONCURRENTLY
|
|
205
|
+
// leaves the name held by an INVALID index, which the plain form
|
|
206
|
+
// fails on ("already exists") and IF NOT EXISTS silently keeps.
|
|
207
|
+
// A valid index of that name is left alone. Issue #1003.
|
|
208
|
+
if (i === from && from > 0) {
|
|
209
|
+
const dropped = await dropInvalidIndexOf(client, migration.statements[i]!)
|
|
210
|
+
if (dropped !== null) options.onDropInvalidIndex?.(migration.tag, i + 1, dropped)
|
|
211
|
+
}
|
|
196
212
|
await inStatement(migration.tag, i + 1, migration.statements.length, () => client.unsafe(migration.statements[i]!))
|
|
197
213
|
// Recorded AFTER the statement it describes: marking first would let
|
|
198
214
|
// a death between the mark and the statement skip work silently,
|
|
@@ -327,6 +343,50 @@ export function resumePoint(
|
|
|
327
343
|
return Math.min(recorded.done, migration.statements.length)
|
|
328
344
|
}
|
|
329
345
|
|
|
346
|
+
const IDENT = String.raw`"(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*`
|
|
347
|
+
const CREATE_INDEX_CONCURRENTLY_RE = new RegExp(
|
|
348
|
+
String.raw`^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\s+(?:IF\s+NOT\s+EXISTS\s+)?(${IDENT})\s+ON\s+(?:ONLY\s+)?((?:${IDENT})(?:\s*\.\s*(?:${IDENT}))?)`,
|
|
349
|
+
'i',
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
function unquoteIdent(token: string): string {
|
|
353
|
+
return token.startsWith('"') ? token.slice(1, -1).replace(/""/g, '"') : token.toLowerCase()
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The index name and table a CREATE [UNIQUE] INDEX CONCURRENTLY statement
|
|
358
|
+
* names, or null for any other statement (or an unnamed index). The table is
|
|
359
|
+
* kept as written so Postgres resolves it against the search path itself.
|
|
360
|
+
*/
|
|
361
|
+
export function parseCreateIndexConcurrently(statement: string): { index: string; table: string } | null {
|
|
362
|
+
const match = CREATE_INDEX_CONCURRENTLY_RE.exec(stripComments(statement))
|
|
363
|
+
if (match === null) return null
|
|
364
|
+
return { index: unquoteIdent(match[1]!), table: match[2]! }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Drop the INVALID index a cut CREATE INDEX CONCURRENTLY left under the name
|
|
369
|
+
* this statement is about to create, on the statement's own table. Returns the
|
|
370
|
+
* schema-qualified name dropped, or null when the statement is not such a
|
|
371
|
+
* create or the index is absent or valid. Issue #1003.
|
|
372
|
+
*/
|
|
373
|
+
async function dropInvalidIndexOf(client: Sql, statement: string): Promise<string | null> {
|
|
374
|
+
const target = parseCreateIndexConcurrently(statement)
|
|
375
|
+
if (target === null) return null
|
|
376
|
+
const found = await client.unsafe<{ schema: string; name: string }[]>(
|
|
377
|
+
`SELECT n.nspname AS schema, c.relname AS name
|
|
378
|
+
FROM pg_index i
|
|
379
|
+
JOIN pg_class c ON c.oid = i.indexrelid
|
|
380
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
381
|
+
WHERE c.relname = $1 AND NOT i.indisvalid AND i.indrelid = to_regclass($2)`,
|
|
382
|
+
[target.index, target.table],
|
|
383
|
+
)
|
|
384
|
+
if (found.length === 0) return null
|
|
385
|
+
const qualified = `${quoteIdent(found[0]!.schema)}.${quoteIdent(found[0]!.name)}`
|
|
386
|
+
await client.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS ${qualified}`)
|
|
387
|
+
return qualified
|
|
388
|
+
}
|
|
389
|
+
|
|
330
390
|
function quoteIdent(name: string): string {
|
|
331
391
|
return `"${name.replace(/"/g, '""')}"`
|
|
332
392
|
}
|
package/src/data/migrate/run.ts
CHANGED
|
@@ -24,12 +24,22 @@ export interface MigrateResult {
|
|
|
24
24
|
safetyCheck: MigrationCheckResult
|
|
25
25
|
/** No-transaction files picked up half applied and finished. Issue #378. */
|
|
26
26
|
resumed: ResumedMigration[]
|
|
27
|
+
/** INVALID indexes a cut CREATE INDEX CONCURRENTLY left, dropped before the resumed statement reran. Issue #1003. */
|
|
28
|
+
droppedInvalidIndexes: DroppedInvalidIndex[]
|
|
27
29
|
/** Notices and warnings raised by migration statements, in arrival order. Issue #970. */
|
|
28
30
|
notices: MigrateNotice[]
|
|
29
31
|
/** Trace/audit tables, columns and day partitions this run provisioned. */
|
|
30
32
|
frameworkTables: FrameworkTablesResult
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
export interface DroppedInvalidIndex {
|
|
36
|
+
tag: string
|
|
37
|
+
/** 1-based statement that rebuilt it. */
|
|
38
|
+
statement: number
|
|
39
|
+
/** Schema-qualified, quoted. */
|
|
40
|
+
index: string
|
|
41
|
+
}
|
|
42
|
+
|
|
33
43
|
export interface ResumedMigration {
|
|
34
44
|
tag: string
|
|
35
45
|
/** 1-based statement the rerun started from. */
|
|
@@ -99,12 +109,14 @@ export async function migrateOne(instance: Manifold, dbConfig: ResolvedDatabase,
|
|
|
99
109
|
}
|
|
100
110
|
|
|
101
111
|
const resumed: ResumedMigration[] = []
|
|
112
|
+
const droppedInvalidIndexes: DroppedInvalidIndex[] = []
|
|
102
113
|
const notices: MigrateNotice[] = []
|
|
103
114
|
const migrationsRun = await applyMigrations({
|
|
104
115
|
migrationsFolder: dbConfig.migrations!,
|
|
105
116
|
migrationsTable: dbConfig.migrationTable,
|
|
106
117
|
connectionUrl: dbConfig.url,
|
|
107
118
|
onResume: (tag, fromStatement, totalStatements) => resumed.push({ tag, fromStatement, totalStatements }),
|
|
119
|
+
onDropInvalidIndex: (tag, statement, index) => droppedInvalidIndexes.push({ tag, statement, index }),
|
|
108
120
|
onNotice: (tag, statement, statements, notice) => {
|
|
109
121
|
const full: MigrateNotice = { tag, statement, statements, ...notice }
|
|
110
122
|
notices.push(full)
|
|
@@ -133,6 +145,7 @@ export async function migrateOne(instance: Manifold, dbConfig: ResolvedDatabase,
|
|
|
133
145
|
extensions: extensionsCreated,
|
|
134
146
|
safetyCheck,
|
|
135
147
|
resumed,
|
|
148
|
+
droppedInvalidIndexes,
|
|
136
149
|
notices,
|
|
137
150
|
frameworkTables,
|
|
138
151
|
}
|
package/src/test/auto-setup.ts
CHANGED
|
@@ -11,7 +11,10 @@ loadEnvFiles()
|
|
|
11
11
|
|
|
12
12
|
let _testContext: TestContext | null = null
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// Loaded through this file's own `import()`, which vitest routes through its module runner
|
|
15
|
+
// (setup files always run there), not through Node: the config's app modules must be the
|
|
16
|
+
// instances the test files import (#1006).
|
|
17
|
+
const instance = await discoverForTestFork(url => import(/* @vite-ignore */ url) as Promise<Record<string, unknown>>)
|
|
15
18
|
|
|
16
19
|
_setTestState(instance)
|
|
17
20
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Manifold } from '../core/pipework.js'
|
|
2
2
|
import { discoverInstance } from '../core/config/discover.js'
|
|
3
|
+
import type { ConfigLoader } from '../core/config/import-config.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Load this test fork's manifold and consume `PIPEWORK_CONFIG`.
|
|
@@ -9,9 +10,13 @@ import { discoverInstance } from '../core/config/discover.js'
|
|
|
9
10
|
* migrate`, a CLI under test — inherits it and migrates the wrong package
|
|
10
11
|
* against the wrong manifold. Discovery failing leaves it set: the variable is
|
|
11
12
|
* part of the diagnosis.
|
|
13
|
+
*
|
|
14
|
+
* `load` is the test runner's own `import()` (#1006): the config's module graph is then
|
|
15
|
+
* evaluated by the same module runner as the test files' imports, so an app module
|
|
16
|
+
* (and its `pipe.define(...)`) runs once whether or not pipework is inlined.
|
|
12
17
|
*/
|
|
13
|
-
export async function discoverForTestFork(): Promise<Manifold> {
|
|
14
|
-
const instance = await discoverInstance()
|
|
18
|
+
export async function discoverForTestFork(load?: ConfigLoader): Promise<Manifold> {
|
|
19
|
+
const instance = await discoverInstance(undefined, load)
|
|
15
20
|
delete process.env['PIPEWORK_CONFIG']
|
|
16
21
|
return instance
|
|
17
22
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { vi } from 'vitest'
|
|
2
|
+
import { REGISTRY_CALLS } from './shared-registry.js'
|
|
3
|
+
|
|
4
|
+
// Setup file for projects that share one module registry per worker (`isolate: false`,
|
|
5
|
+
// non-vm pool). It replaces the `vi` calls that change the registry with a throw naming
|
|
6
|
+
// the test file and the fix. The workspace already routes a file whose own text calls
|
|
7
|
+
// them to an isolated sibling project; this catches a call it could not see, such as one
|
|
8
|
+
// reached through a helper (#1001). Idempotent: setup files re-run for every test file.
|
|
9
|
+
// Listed after the project's own setup files, so a mock a setup file registers on
|
|
10
|
+
// purpose still goes through.
|
|
11
|
+
|
|
12
|
+
const GUARDED = Symbol.for('pipework.sharedRegistryGuard')
|
|
13
|
+
|
|
14
|
+
function currentFile(): string {
|
|
15
|
+
const worker = (globalThis as { __vitest_worker__?: { filepath?: string } }).__vitest_worker__
|
|
16
|
+
return worker?.filepath ?? 'the running test file'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function refuse(call: string, file: string): Error {
|
|
20
|
+
return new Error(
|
|
21
|
+
`[pipework] vi.${call}() is refused in a shared-registry test project (called from ${file}).\n\n` +
|
|
22
|
+
' This project runs with isolate: false: every file in a worker shares one module\n' +
|
|
23
|
+
' registry, and pipework itself is not re-evaluated by vi.resetModules() or vi.mock().\n' +
|
|
24
|
+
' A reset re-runs the app\'s pipe.define(...) calls against the same registry, so the\n' +
|
|
25
|
+
' next file in the worker fails with "Duplicate domain definition", and a mock binds\n' +
|
|
26
|
+
' the importers of the file after it.\n' +
|
|
27
|
+
' The workspace routes a file to an isolated sibling project when its own text calls\n' +
|
|
28
|
+
' vi.resetModules, vi.mock, vi.doMock, vi.unmock or vi.doUnmock. This call was not found in\n' +
|
|
29
|
+
` the text of ${file}, so it was not routed.\n` +
|
|
30
|
+
' Fix: call it directly in the test file that needs it (not through a helper), or set\n' +
|
|
31
|
+
' `isolate: true` on this project or profile in pipework.config.ts.\n',
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const target = vi as unknown as Record<PropertyKey, unknown>
|
|
36
|
+
if (target[GUARDED] !== true) {
|
|
37
|
+
for (const call of REGISTRY_CALLS) {
|
|
38
|
+
target[call] = (): never => { throw refuse(call, currentFile()) }
|
|
39
|
+
}
|
|
40
|
+
target[GUARDED] = true
|
|
41
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFileSync, globSync } from 'node:fs'
|
|
2
|
+
import { matchesGlob, resolve, sep } from 'node:path'
|
|
3
|
+
|
|
4
|
+
// A project with `isolate: false` (and a non-vm pool) keeps one module registry per
|
|
5
|
+
// worker. A test file that resets or mocks modules changes that registry for itself
|
|
6
|
+
// and for every file after it: `vi.resetModules()` re-evaluates the app's modules but
|
|
7
|
+
// not pipework (Node's own cache), so each `pipe.define(...)` runs twice against the
|
|
8
|
+
// one registry and the file after it fails at load (#1001). Such files are routed to an
|
|
9
|
+
// isolated sibling project from their own text; the guard setup file refuses the same
|
|
10
|
+
// calls in shared-registry projects for anything the text scan misses.
|
|
11
|
+
|
|
12
|
+
/** The `vi` calls that change a worker's module registry or the next file's importers. */
|
|
13
|
+
export const REGISTRY_CALLS = ['resetModules', 'mock', 'doMock', 'unmock', 'doUnmock'] as const
|
|
14
|
+
|
|
15
|
+
// `vi.mock(`, `vi.mock<T>(`, `vitest.doMock (` and `vi?.resetModules(`. Decided from the
|
|
16
|
+
// file's own text, comments and strings included: a miss is caught by the guard, a
|
|
17
|
+
// false hit only costs one isolated worker.
|
|
18
|
+
const REGISTRY_CALL_PATTERN = new RegExp(
|
|
19
|
+
`\\b(?:vi|vitest)\\s*\\??\\.\\s*(?:${REGISTRY_CALLS.join('|')})\\s*(?:<[^>]*>)?\\s*\\(`,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
export function callsRegistryApi(text: string): boolean {
|
|
23
|
+
return REGISTRY_CALL_PATTERN.test(text)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Name of the isolated sibling that takes the registry-changing files of `project`. */
|
|
27
|
+
export function isolatedSiblingName(project: string): string {
|
|
28
|
+
return `${project}:registry-isolated`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Glob-escape a literal path, so `[id].test.ts` matches only itself. */
|
|
32
|
+
export function escapeGlob(path: string): string {
|
|
33
|
+
return path.replace(/[\\*?[\]{}()!|+@]/g, '\\$&')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The files `include` selects under `base`, minus `exclude`, as sorted `/`-separated
|
|
38
|
+
* paths relative to `base`. Vitest globs the same way, so this is the file set the
|
|
39
|
+
* project would run. A base that does not exist selects nothing.
|
|
40
|
+
*/
|
|
41
|
+
export function listProjectFiles(base: string, include: readonly string[], exclude: readonly string[]): string[] {
|
|
42
|
+
let found: string[]
|
|
43
|
+
try {
|
|
44
|
+
found = globSync([...include], {
|
|
45
|
+
cwd: base,
|
|
46
|
+
// Prune the two trees nothing is ever tested in, without walking them.
|
|
47
|
+
exclude: (path) => {
|
|
48
|
+
const name = path.split(/[\\/]/).pop()
|
|
49
|
+
return name === 'node_modules' || name === '.git'
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
} catch {
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
const files = found
|
|
56
|
+
.map(file => file.split(sep).join('/'))
|
|
57
|
+
.filter(file => !exclude.some(pattern => matchesGlob(file, pattern)))
|
|
58
|
+
return [...new Set(files)].sort()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The files of the project whose own text calls a registry-changing `vi` API. */
|
|
62
|
+
export function findRegistryFiles(base: string, include: readonly string[], exclude: readonly string[]): string[] {
|
|
63
|
+
return listProjectFiles(base, include, exclude).filter(file => {
|
|
64
|
+
try {
|
|
65
|
+
return callsRegistryApi(readFileSync(resolve(base, file), 'utf8'))
|
|
66
|
+
} catch {
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve, dirname } from 'node:path'
|
|
1
|
+
import { resolve, dirname, isAbsolute } from 'node:path'
|
|
2
2
|
import { configDefaults } from 'vitest/config'
|
|
3
3
|
import type { ViteUserConfig, UserWorkspaceConfig } from 'vitest/config'
|
|
4
4
|
import type { VitestConfig, VitestModuleConfig, VitestProfileConfig, CoverageConfig } from '../core/config/schema.js'
|
|
@@ -10,6 +10,7 @@ import { loadEnvFiles } from '../core/config/env.js'
|
|
|
10
10
|
import { resolveModules } from '../workspace/discover.js'
|
|
11
11
|
import { workspaceSourcePaths } from '../workspace/source-paths.js'
|
|
12
12
|
import { pipework as pipeworkPlugin } from './vitest-plugin.js'
|
|
13
|
+
import { escapeGlob, findRegistryFiles, isolatedSiblingName } from './shared-registry.js'
|
|
13
14
|
|
|
14
15
|
const DEFAULT_TEST_TIMEOUT = 30_000
|
|
15
16
|
export const DEFAULT_HOOK_TIMEOUT = 60_000
|
|
@@ -22,6 +23,9 @@ export const DEFAULT_HOOK_TIMEOUT = 60_000
|
|
|
22
23
|
const DEFAULT_POOL = 'forks'
|
|
23
24
|
const DEFAULT_ISOLATE = false
|
|
24
25
|
|
|
26
|
+
// Setup file that refuses registry-changing `vi` calls in a shared-registry project.
|
|
27
|
+
const SHARED_REGISTRY_GUARD = 'pipework/test/shared-registry'
|
|
28
|
+
|
|
25
29
|
type PoolName = NonNullable<VitestConfig['pool']>
|
|
26
30
|
|
|
27
31
|
/**
|
|
@@ -199,14 +203,12 @@ export function resolveVitestModules(
|
|
|
199
203
|
|
|
200
204
|
for (const [modulePath, moduleConfig] of [...included.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
201
205
|
refuseProjectCoverage(`vitest.modules["${modulePath}"].coverage`, moduleConfig)
|
|
202
|
-
|
|
203
|
-
projects.push(project)
|
|
206
|
+
pushRouted(projects, buildProjectConfig(modulePath, moduleConfig, vitestConfig, configDir, workspacePackages))
|
|
204
207
|
|
|
205
208
|
if (moduleConfig.profiles !== undefined) {
|
|
206
209
|
for (const [profileName, profileConfig] of Object.entries(moduleConfig.profiles).sort(([a], [b]) => a.localeCompare(b))) {
|
|
207
210
|
refuseProjectCoverage(`vitest.modules["${modulePath}"].profiles["${profileName}"].coverage`, profileConfig)
|
|
208
|
-
|
|
209
|
-
projects.push(profileProject)
|
|
211
|
+
pushRouted(projects, buildProfileConfig(modulePath, profileName, moduleConfig, profileConfig, vitestConfig, configDir, workspacePackages))
|
|
210
212
|
}
|
|
211
213
|
}
|
|
212
214
|
}
|
|
@@ -411,6 +413,7 @@ interface ProjectBuildInput {
|
|
|
411
413
|
|
|
412
414
|
function buildProject(input: ProjectBuildInput): UserWorkspaceConfig {
|
|
413
415
|
const inlineDeps = buildInlineDeps(input)
|
|
416
|
+
const shared = sharesRegistry(input)
|
|
414
417
|
return {
|
|
415
418
|
...input.extra,
|
|
416
419
|
plugins: [
|
|
@@ -434,7 +437,7 @@ function buildProject(input: ProjectBuildInput): UserWorkspaceConfig {
|
|
|
434
437
|
inline: inlineDeps,
|
|
435
438
|
},
|
|
436
439
|
},
|
|
437
|
-
setupFiles: input.setupFiles,
|
|
440
|
+
setupFiles: shared ? [...input.setupFiles, SHARED_REGISTRY_GUARD] : input.setupFiles,
|
|
438
441
|
// `vitest.pool` / `vitest.isolate` are dedicated fields, so an explicitly
|
|
439
442
|
// configured one wins over `extra.test`; unset, the default above stands and
|
|
440
443
|
// `extra.test` still overrides it as it always did.
|
|
@@ -458,19 +461,25 @@ function buildSetupFiles(database: boolean, userSetupFiles: string[] | undefined
|
|
|
458
461
|
return files
|
|
459
462
|
}
|
|
460
463
|
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
function buildInlineDeps(input: ProjectBuildInput): string[] {
|
|
464
|
+
// Whether the project's workers share one module registry: the effective isolate is
|
|
465
|
+
// false and the pool is not a vm pool (those isolate per file whatever `isolate` says).
|
|
466
|
+
// Precedence mirrors buildProject's emitted config: dedicated field, then `extra.test`,
|
|
467
|
+
// then the default.
|
|
468
|
+
function sharesRegistry(input: ProjectBuildInput): boolean {
|
|
467
469
|
const extraTest = 'test' in input.extra ? input.extra['test'] as Record<string, unknown> : {}
|
|
468
470
|
const extraIsolate = typeof extraTest['isolate'] === 'boolean' ? extraTest['isolate'] : undefined
|
|
469
471
|
const extraPool = extraTest['pool']
|
|
470
472
|
const isolate = input.isolate ?? extraIsolate ?? DEFAULT_ISOLATE
|
|
471
473
|
const pool = input.pool ?? extraPool ?? DEFAULT_POOL
|
|
474
|
+
return !isolate && pool !== 'vmThreads' && pool !== 'vmForks'
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Pipework is inlined only where the registry is not shared: a fresh module registry
|
|
478
|
+
// per file needs it. A shared-registry fork already matches Node's single module
|
|
479
|
+
// instance, so inlining buys nothing there.
|
|
480
|
+
function buildInlineDeps(input: ProjectBuildInput): string[] {
|
|
472
481
|
const deps = new Set<string>()
|
|
473
|
-
if (
|
|
482
|
+
if (!sharesRegistry(input)) {
|
|
474
483
|
deps.add('pipework')
|
|
475
484
|
}
|
|
476
485
|
for (const dep of input.inlineDeps) {
|
|
@@ -479,6 +488,50 @@ function buildInlineDeps(input: ProjectBuildInput): string[] {
|
|
|
479
488
|
return [...deps]
|
|
480
489
|
}
|
|
481
490
|
|
|
491
|
+
// Routing for #1001. `isolate: false` projects keep one module registry per worker, and a
|
|
492
|
+
// file that calls vi.resetModules / vi.mock / vi.doMock / vi.unmock / vi.doUnmock breaks
|
|
493
|
+
// the files after it. Per project, the files its `include` selects (under `dir` or the
|
|
494
|
+
// root, minus its merged `exclude`) are read; those whose own text calls one move to a
|
|
495
|
+
// sibling project `<name>:registry-isolated` that is the same project with `isolate: true`
|
|
496
|
+
// and an `include` of exactly those files, and the project itself excludes them. Each
|
|
497
|
+
// project decides for itself from the file's text, so a file two projects select (a
|
|
498
|
+
// module and its profile, or nested module roots) is routed in each, and still runs once
|
|
499
|
+
// per project as before. Files are visited in sorted order, projects in config order.
|
|
500
|
+
function pushRouted(projects: UserWorkspaceConfig[], project: UserWorkspaceConfig): void {
|
|
501
|
+
const test = project.test
|
|
502
|
+
const setup = test?.setupFiles
|
|
503
|
+
if (test === undefined || typeof test.root !== 'string' || !Array.isArray(setup) || !setup.includes(SHARED_REGISTRY_GUARD)) {
|
|
504
|
+
projects.push(project)
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
const root = test.root
|
|
508
|
+
const name = String(test.name)
|
|
509
|
+
const dir = typeof test.dir === 'string' ? (isAbsolute(test.dir) ? test.dir : resolve(root, test.dir)) : root
|
|
510
|
+
const include = test.include ?? [...configDefaults.include]
|
|
511
|
+
const exclude = test.exclude ?? [...configDefaults.exclude]
|
|
512
|
+
const routed = findRegistryFiles(dir, include, exclude)
|
|
513
|
+
if (routed.length === 0) {
|
|
514
|
+
projects.push(project)
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
const escaped = routed.map(escapeGlob)
|
|
518
|
+
projects.push({
|
|
519
|
+
...project,
|
|
520
|
+
test: { ...test, exclude: [...exclude, ...escaped] },
|
|
521
|
+
})
|
|
522
|
+
projects.push({
|
|
523
|
+
...project,
|
|
524
|
+
test: {
|
|
525
|
+
...test,
|
|
526
|
+
name: isolatedSiblingName(name),
|
|
527
|
+
isolate: true,
|
|
528
|
+
include: escaped,
|
|
529
|
+
setupFiles: setup.filter(file => file !== SHARED_REGISTRY_GUARD),
|
|
530
|
+
server: { deps: { inline: [...new Set(['pipework', ...((test.server?.deps?.inline as string[] | undefined) ?? [])])] } },
|
|
531
|
+
},
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
|
|
482
535
|
function closestMatch(input: string, candidates: string[]): string | null {
|
|
483
536
|
if (candidates.length === 0) return null
|
|
484
537
|
|