From 55c0be262dfbf18f07c85a2d979c162377f0c042 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 10:46:13 +0000 Subject: [PATCH 1/6] fix(testkit): coordinate package packs across processes Signed-off-by: Steven McClankerton --- packages/0-config/tsdown/README.md | 10 + packages/0-config/tsdown/shell-pack.ts | 97 +++++ packages/0-config/tsdown/shell-testkit.ts | 33 +- .../@prisma/orm-postgres/package.json | 3 +- .../orm-postgres/test/fixtures/pack-client.ts | 14 + .../test/fixtures/pack-lifecycle.ts | 31 ++ .../test/fixtures/pack-ticket-barrier.ts | 26 ++ .../test/pack-concurrency.test.ts | 382 ++++++++++++++++++ pnpm-lock.yaml | 3 + 9 files changed, 593 insertions(+), 6 deletions(-) create mode 100644 packages/0-config/tsdown/shell-pack.ts create mode 100644 packages/9-public/@prisma/orm-postgres/test/fixtures/pack-client.ts create mode 100644 packages/9-public/@prisma/orm-postgres/test/fixtures/pack-lifecycle.ts create mode 100644 packages/9-public/@prisma/orm-postgres/test/fixtures/pack-ticket-barrier.ts create mode 100644 packages/9-public/@prisma/orm-postgres/test/pack-concurrency.test.ts diff --git a/packages/0-config/tsdown/README.md b/packages/0-config/tsdown/README.md index f74d67d1562c..a36778e7501e 100644 --- a/packages/0-config/tsdown/README.md +++ b/packages/0-config/tsdown/README.md @@ -62,6 +62,16 @@ export default defineConfig({ }) ``` +### Tarball test coordination + +`packShell` and `packShellAtVersion` coordinate testkit callers around the **entire** `pnpm pack` operation, including lifecycle scripts and archive creation. Different real package directories remain parallel; symlink aliases share ownership. This does **not** coordinate arbitrary external `pnpm pack` commands or concurrent builds/staging writes. + +The source-only `shell-pack.ts` worker runs in its own POSIX process group. A per-package Lamport bakery queue lives under `node_modules/.cache/shell-pack` (excluded from archives): each attempt publishes a unique PID/UUID directory before choosing a ticket, then atomically publishes its ticket. Choosing attempts and earlier ticket/ID pairs block entry. Attempts never reuse paths or remove the shared queue, so concurrent stale-record cleanup cannot delete a successor's ownership. + +Acquisition is bounded to 30 seconds; `packShell` accepts `{ lockTimeoutMs }` to set a smaller caller budget (zero means fail on contention). Timeout errors identify the package and blocking process groups. Acquisition failures remove their own attempt. After pnpm starts, ownership lasts until the **whole process group** disappears, on success or failure; the next caller reclaims the inactive record. This also protects against a killed caller/supervisor or a failed lifecycle leaving a child alive. Age alone never expires ownership; uncertain liveness or PID reuse fails closed rather than stealing a live pack's input tree. + +This test infrastructure requires a local filesystem and POSIX process groups (Linux/macOS); lifecycle descendants must not detach into other groups. A stuck/suspended/orphaned live group causes a bounded acquisition failure, not unsafe takeover. The subprocess regression suite in `@prisma/orm-postgres` uses lifecycle barriers and verifies archived file names and bytes, including regenerated skill metadata. + ### Migration from tsup `tsup` is no longer actively maintained. Migrate a monorepo package by uninstalling `tsup` - `pnpm uninstall tsup`. diff --git a/packages/0-config/tsdown/shell-pack.ts b/packages/0-config/tsdown/shell-pack.ts new file mode 100644 index 000000000000..1f3f0703ebc9 --- /dev/null +++ b/packages/0-config/tsdown/shell-pack.ts @@ -0,0 +1,97 @@ +import { execFileSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'pathe'; + +class PackLockError extends Error {} + +const [directory, tarball, timeoutArgument] = process.argv.slice(2); +if (!directory || !tarball) throw new PackLockError('Expected package directory and tarball path'); +if (process.platform === 'win32') + throw new PackLockError('Shell packing requires POSIX process groups'); +const timeoutMs = Number(timeoutArgument); +if (!Number.isFinite(timeoutMs) || timeoutMs < 0) + throw new PackLockError('Invalid pack lock timeout'); + +const packageDir = realpathSync(directory); +const lockDir = join(packageDir, 'node_modules', '.cache', 'shell-pack'); +const id = `${process.pid}-${randomUUID()}`; +const ownDir = join(lockDir, id); +const deadline = performance.now() + timeoutMs; +const pause = new Int32Array(new SharedArrayBuffer(4)); + +function hasCode(error: unknown, code: string): boolean { + return error instanceof Error && 'code' in error && error.code === code; +} + +function groupExists(pid: number): boolean { + try { + process.kill(-pid, 0); + return true; + } catch (error) { + if (hasCode(error, 'ESRCH')) return false; + throw error; + } +} + +function contenders(): { id: string; ticket: number }[] { + const entries: { id: string; ticket: number }[] = []; + for (const entry of readdirSync(lockDir)) { + const pid = Number(entry.split('-')[0]); + if (!Number.isSafeInteger(pid) || pid <= 0) { + throw new PackLockError(`Invalid pack owner in ${lockDir}: ${entry}`); + } + if (!groupExists(pid)) { + rmSync(join(lockDir, entry), { recursive: true, force: true }); + continue; + } + try { + const ticket = Number(readFileSync(join(lockDir, entry, 'ticket'), 'utf8')); + if (!Number.isSafeInteger(ticket) || ticket <= 0) { + throw new PackLockError(`Invalid pack ticket in ${lockDir}: ${entry}`); + } + entries.push({ id: entry, ticket }); + } catch (error) { + if (!hasCode(error, 'ENOENT')) throw error; + // A published directory without a ticket is still choosing its place in the queue. + entries.push({ id: entry, ticket: 0 }); + } + } + return entries; +} + +mkdirSync(lockDir, { recursive: true }); +mkdirSync(ownDir); +let packStarted = false; +try { + const ticket = Math.max(0, ...contenders().map((entry) => entry.ticket)) + 1; + writeFileSync(join(ownDir, 'ticket.tmp'), String(ticket)); + renameSync(join(ownDir, 'ticket.tmp'), join(ownDir, 'ticket')); + while (true) { + const blockers = contenders().filter( + (entry) => + entry.id !== id && + (entry.ticket === 0 || entry.ticket < ticket || (entry.ticket === ticket && entry.id < id)), + ); + if (blockers.length === 0) break; + if (performance.now() >= deadline) { + throw new PackLockError( + `Timed out acquiring pack lock for ${directory} (${packageDir}); owners: ${blockers.map((entry) => entry.id).join(', ')}`, + ); + } + Atomics.wait(pause, 0, 0, Math.min(25, deadline - performance.now())); + } + packStarted = true; + execFileSync('pnpm', ['pack', '--out', tarball], { cwd: packageDir, stdio: 'pipe' }); +} finally { + // Once launched, only a dead process group proves that pnpm and its lifecycle children are done. + if (!packStarted) rmSync(ownDir, { recursive: true, force: true }); +} diff --git a/packages/0-config/tsdown/shell-testkit.ts b/packages/0-config/tsdown/shell-testkit.ts index caf1ba4acb1c..f146640cfcf2 100644 --- a/packages/0-config/tsdown/shell-testkit.ts +++ b/packages/0-config/tsdown/shell-testkit.ts @@ -1,6 +1,7 @@ -import { execFileSync } from 'node:child_process'; +import { execFileSync, type SpawnOptions } from 'node:child_process'; import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { publicShells } from '@internal/publish-surface/shells'; import { init as initLexer, parse as parseModule } from 'es-module-lexer'; @@ -50,11 +51,33 @@ function manifestName(packageDir: string, manifest: Record): st return name; } -/** `pnpm pack` a shell package into `outDir`, returning the published name + tarball path. */ -export function packShell(shellDir: string, outDir: string): PackedShell { +export interface PackOptions { + readonly lockTimeoutMs?: number | undefined; +} + +function runPack(packageDir: string, tarball: string, options: PackOptions): void { + const childOptions = { detached: true, stdio: 'pipe' } satisfies SpawnOptions; + execFileSync( + process.execPath, + [ + fileURLToPath(new URL('./shell-pack.ts', import.meta.url)), + packageDir, + tarball, + String(options.lockTimeoutMs ?? 30_000), + ], + childOptions, + ); +} + +/** `pnpm pack` a shell package, coordinating testkit callers through archive completion. */ +export function packShell( + shellDir: string, + outDir: string, + options: PackOptions = {}, +): PackedShell { const name = manifestName(shellDir, readManifest(shellDir)); const tarball = join(outDir, `${name.replaceAll(/[@/]/g, '-').replace(/^-/, '')}.tgz`); - execFileSync('pnpm', ['pack', '--out', tarball], { cwd: shellDir, stdio: 'pipe' }); + runPack(shellDir, tarball, options); return { name, tarball }; } @@ -94,7 +117,7 @@ export function packShellAtVersion(shellDir: string, outDir: string, version: st delete staged['devDependencies']; writeFileSync(join(stageDir, 'package.json'), `${JSON.stringify(staged, null, 2)}\n`); const tarball = join(outDir, `${name.replaceAll(/[@/]/g, '-').replace(/^-/, '')}-${version}.tgz`); - execFileSync('pnpm', ['pack', '--out', tarball], { cwd: stageDir, stdio: 'pipe' }); + runPack(stageDir, tarball, {}); return { name, tarball, override: false }; } diff --git a/packages/9-public/@prisma/orm-postgres/package.json b/packages/9-public/@prisma/orm-postgres/package.json index 556191a119fc..772217ec3cab 100644 --- a/packages/9-public/@prisma/orm-postgres/package.json +++ b/packages/9-public/@prisma/orm-postgres/package.json @@ -28,8 +28,9 @@ }, "devDependencies": { "@internal/postgres": "workspace:8.0.0-rc.11", - "@repo/tsconfig": "workspace:8.0.0-rc.11", "@internal/publish-surface": "workspace:8.0.0-rc.11", + "@repo/test-utils": "workspace:8.0.0-rc.11", + "@repo/tsconfig": "workspace:8.0.0-rc.11", "@repo/tsdown": "workspace:8.0.0-rc.11", "tsdown": "catalog:", "typescript": "catalog:", diff --git a/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-client.ts b/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-client.ts new file mode 100644 index 000000000000..88bc124ee554 --- /dev/null +++ b/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-client.ts @@ -0,0 +1,14 @@ +import { packShell } from '@repo/tsdown/shell-testkit'; + +const [shellDir, outDir, lockTimeoutMs] = process.argv.slice(2); +if (!shellDir || !outDir) throw new Error('Expected package and output directories'); + +try { + const packed = packShell(shellDir, outDir, { + lockTimeoutMs: lockTimeoutMs === undefined ? undefined : Number(lockTimeoutMs), + }); + console.log(JSON.stringify(packed)); +} catch (error) { + console.error(error); + process.exitCode = 1; +} diff --git a/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-lifecycle.ts b/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-lifecycle.ts new file mode 100644 index 000000000000..4e044a87fb8f --- /dev/null +++ b/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-lifecycle.ts @@ -0,0 +1,31 @@ +import { spawn } from 'node:child_process'; +import { cp, readFile, rm, writeFile } from 'node:fs/promises'; +import { connect } from 'node:net'; +import { join } from 'pathe'; + +const phase = process.argv[2]; +const socket = connect(Number(process.env['PACK_BARRIER_PORT']), '127.0.0.1'); +socket.write( + `${JSON.stringify({ label: process.env['PACK_LABEL'], phase, owner: process.env['PACK_OWNER'] })}\n`, +); +const command = await new Promise((resolve, reject) => { + let buffer = ''; + socket.on('data', (data) => { + buffer += data.toString(); + if (buffer.includes('\n')) resolve(buffer.trim()); + }); + socket.once('error', reject); +}); +socket.end(); +if (command === 'orphan') { + spawn(process.execPath, [process.argv[1]!, 'orphan'], { stdio: 'ignore' }).unref(); +} +if (command !== 'continue') process.exit(1); + +if (phase === 'prepack') { + await rm('skills', { recursive: true, force: true }); + await cp('skill-source', 'skills', { recursive: true }); + const manifest = JSON.parse(await readFile('package.json', 'utf8')) as { name: string }; + const skill = join('skills', 'SKILL.md'); + await writeFile(skill, (await readFile(skill, 'utf8')).replace('SOURCE', manifest.name)); +} diff --git a/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-ticket-barrier.ts b/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-ticket-barrier.ts new file mode 100644 index 000000000000..38ddf998b642 --- /dev/null +++ b/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-ticket-barrier.ts @@ -0,0 +1,26 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { basename, dirname } from 'pathe'; + +if (process.argv[1]?.endsWith('/shell-pack.ts')) { + const barrier = (phase: string, path: string) => { + execFileSync( + process.execPath, + [fileURLToPath(new URL('./pack-lifecycle.ts', import.meta.url)), phase], + { env: { ...process.env, PACK_OWNER: basename(dirname(path)) }, stdio: 'pipe' }, + ); + }; + const writeFileSync = fs.writeFileSync; + fs.writeFileSync = (file, data, options) => { + if (typeof file === 'string' && file.endsWith('/ticket.tmp')) barrier('choosing', file); + writeFileSync(file, data, options); + }; + const renameSync = fs.renameSync; + fs.renameSync = (from, to) => { + renameSync(from, to); + if (typeof to === 'string' && to.endsWith('/ticket')) barrier('chosen', to); + }; + syncBuiltinESMExports(); +} diff --git a/packages/9-public/@prisma/orm-postgres/test/pack-concurrency.test.ts b/packages/9-public/@prisma/orm-postgres/test/pack-concurrency.test.ts new file mode 100644 index 000000000000..a7ed0708e56d --- /dev/null +++ b/packages/9-public/@prisma/orm-postgres/test/pack-concurrency.test.ts @@ -0,0 +1,382 @@ +import { type ChildProcess, execFileSync, spawn } from 'node:child_process'; +import { EventEmitter, once } from 'node:events'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + watch, + writeFileSync, +} from 'node:fs'; +import { createServer, type Socket } from 'node:net'; +import { tmpdir } from 'node:os'; +import { pathToFileURL } from 'node:url'; +import { timeouts } from '@repo/test-utils/timeouts'; +import { join } from 'pathe'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +type Phase = 'prepack' | 'prepare' | 'postpack' | 'orphan' | 'choosing' | 'chosen'; +interface Barrier { + readonly label: string; + readonly phase: Phase; + readonly socket: Socket; + readonly owner: string | undefined; +} +interface Result { + readonly code: number | null; + readonly output: string; +} +interface Client { + readonly child: ChildProcess; + readonly result: Promise; +} + +const fixtureDir = join(import.meta.dirname, 'fixtures'); +const sourceFiles = { + 'SKILL.md': '---\nlibrary: SOURCE\n---\nComplete skill\n', + 'references/one.md': 'first reference\n', + 'references/two.md': 'second reference\n', +}; + +let scratch: string; +let port: number; +let clients: Client[]; +let barriers: Barrier[]; +let events: EventEmitter; +let closing: boolean; +let server: ReturnType; + +beforeEach(async () => { + scratch = mkdtempSync(join(tmpdir(), 'shell-pack-concurrency-')); + clients = []; + barriers = []; + events = new EventEmitter(); + closing = false; + server = createServer((socket) => { + let buffer = ''; + socket.on('data', (chunk) => { + buffer += chunk.toString(); + if (!buffer.includes('\n')) return; + const event = JSON.parse(buffer) as Omit; + barriers.push({ ...event, socket }); + if (closing) socket.end('continue\n'); + events.emit('barrier'); + }); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (address === null || typeof address === 'string') throw new Error('Missing barrier address'); + port = address.port; +}); + +afterEach(async () => { + closing = true; + for (const barrier of barriers) barrier.socket.end('continue\n'); + const stopChildren = () => { + for (const dir of readdirSync(scratch)) { + const lockDir = join(scratch, dir, 'node_modules', '.cache', 'shell-pack'); + if (!existsSync(lockDir)) continue; + for (const entry of readdirSync(lockDir)) { + try { + process.kill(-Number(entry.split('-')[0]), 'SIGKILL'); + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ESRCH')) throw error; + } + } + } + for (const client of clients) client.child.kill('SIGKILL'); + }; + const deadline = setTimeout(stopChildren, timeouts.databaseOperation); + try { + await Promise.all(clients.map((client) => client.result)); + } finally { + clearTimeout(deadline); + stopChildren(); + await Promise.allSettled(clients.map((client) => client.result)); + for (const barrier of barriers) barrier.socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + rmSync(scratch, { recursive: true, force: true }); + } +}); + +function makePackage(name: string): string { + const dir = join(scratch, name); + mkdirSync(dir); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ + name, + version: '1.0.0', + files: ['skills'], + scripts: Object.fromEntries( + ['prepack', 'prepare', 'postpack'].map((phase) => [ + phase, + `node ${JSON.stringify(join(fixtureDir, 'pack-lifecycle.ts'))} ${phase}`, + ]), + ), + }), + ); + writeFileSync(join(dir, 'pnpm-workspace.yaml'), '{}\n'); + for (const [file, content] of Object.entries(sourceFiles)) { + const path = join(dir, 'skill-source', file); + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, content); + } + mkdirSync(join(dir, 'skills')); + writeFileSync(join(dir, 'skills', 'obsolete.md'), 'must not survive prepack'); + return dir; +} + +function pack(dir: string, label: string, lockTimeoutMs?: number, ticketBarrier = false): Client { + const out = join(scratch, label); + mkdirSync(out); + const args = [join(fixtureDir, 'pack-client.ts'), dir, out]; + if (lockTimeoutMs !== undefined) args.push(String(lockTimeoutMs)); + const env: NodeJS.ProcessEnv = { + ...process.env, + PACK_BARRIER_PORT: String(port), + PACK_LABEL: label, + }; + if (ticketBarrier) { + const preload = pathToFileURL(join(fixtureDir, 'pack-ticket-barrier.ts')).href; + env['NODE_OPTIONS'] = `${process.env['NODE_OPTIONS'] ?? ''} --import=${preload}`; + } + const child = spawn(process.execPath, args, { + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout.on('data', (data) => { + output += data; + }); + child.stderr.on('data', (data) => { + output += data; + }); + const result = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code) => resolve({ code, output })); + }); + const client = { child, result }; + clients.push(client); + return client; +} + +async function queuedPack(dir: string, label: string): Promise<{ client: Client; entry: string }> { + const lockDir = join(dir, 'node_modules', '.cache', 'shell-pack'); + const original = new Set(readdirSync(lockDir)); + const watcher = watch(lockDir); + const change = once(watcher, 'change', { + signal: AbortSignal.timeout(timeouts.coldTransformImport), + }); + try { + const client = pack(dir, label); + await change; + const entry = readdirSync(lockDir).find((name) => !original.has(name)); + expect(entry).toBeDefined(); + return { client, entry: entry! }; + } finally { + watcher.close(); + } +} + +async function entered(label: string, phase: Phase): Promise { + while (true) { + const event = barriers.find((barrier) => barrier.label === label && barrier.phase === phase); + if (event) return event; + await once(events, 'barrier'); + } +} + +async function release(label: string, phase: Phase, command = 'continue'): Promise { + (await entered(label, phase)).socket.end(`${command}\n`); +} + +async function finish(client: Client, label: string): Promise { + for (const phase of ['prepack', 'prepare', 'postpack'] as const) await release(label, phase); + return client.result; +} + +async function expectExcluded(dir: string, label: string, lockTimeoutMs = 0): Promise { + await expectBlocked(pack(dir, label, lockTimeoutMs), label, dir); +} + +async function expectBlocked(contender: Client, label: string, dir: string): Promise { + const outcome = await Promise.race([ + contender.result, + entered(label, 'prepack').then( + () => 'entered prepack while another pack still owns the package', + ), + ]); + expect(outcome).toEqual({ + code: 1, + output: expect.stringContaining('Timed out acquiring pack lock'), + }); + expect(outcome).toEqual({ code: 1, output: expect.stringContaining(dir) }); +} + +function expectArchive(label: string, name: string): void { + const tarball = join(scratch, label, `${name}.tgz`); + const files = execFileSync('tar', ['-tzf', tarball], { encoding: 'utf8' }) + .trim() + .split('\n') + .filter((file) => !file.endsWith('/')) + .sort(); + expect(files).toEqual( + [ + 'package/package.json', + ...Object.keys(sourceFiles).map((file) => `package/skills/${file}`), + ].sort(), + ); + for (const [file, content] of Object.entries(sourceFiles)) { + expect( + execFileSync('tar', ['-xOzf', tarball, `package/skills/${file}`], { encoding: 'utf8' }), + ).toBe(content.replace('SOURCE', name)); + } +} + +describe( + 'cross-process shell packing', + () => { + it('excludes aliases of the same package through archive completion and packs complete contents', async () => { + const dir = makePackage('same-package'); + const alias = join(scratch, 'alias'); + symlinkSync(dir, alias); + const first = pack(dir, 'first'); + await entered('first', 'prepack'); + await expectExcluded(alias, 'during-prepack'); + await release('first', 'prepack'); + await entered('first', 'prepare'); + await expectExcluded(dir, 'before-archive'); + const { client: second } = await queuedPack(alias, 'second'); + await release('first', 'prepare'); + await entered('first', 'postpack'); + expectArchive('first', 'same-package'); + await expectExcluded(dir, 'after-archive', timeouts.default); + await release('first', 'postpack'); + expect(await first.result).toMatchObject({ code: 0 }); + expect(await finish(second, 'second')).toMatchObject({ code: 0 }); + expectArchive('second', 'same-package'); + }); + + it('breaks equal-ticket ties without admitting two simultaneous choosers', async () => { + const dir = makePackage('equal-tickets'); + const first = pack(dir, 'a', 0, true); + await entered('a', 'choosing'); + const second = pack(dir, 'b', 0, true); + await entered('b', 'choosing'); + await release('a', 'choosing'); + await release('b', 'choosing'); + const a = await entered('a', 'chosen'); + const b = await entered('b', 'chosen'); + expect(a.owner).toBeDefined(); + expect(b.owner).toBeDefined(); + const lockDir = join(dir, 'node_modules', '.cache', 'shell-pack'); + expect( + [a, b].map(({ owner }) => readFileSync(join(lockDir, owner!, 'ticket'), 'utf8')), + ).toEqual(['1', '1']); + const [leader, follower] = + a.owner! < b.owner! + ? [ + { client: first, label: 'a' }, + { client: second, label: 'b' }, + ] + : [ + { client: second, label: 'b' }, + { client: first, label: 'a' }, + ]; + await release(leader!.label, 'chosen'); + await entered(leader!.label, 'prepack'); + await release(follower!.label, 'chosen'); + await expectBlocked(follower!.client, follower!.label, dir); + expect(await finish(leader!.client, leader!.label)).toMatchObject({ code: 0 }); + expectArchive(leader!.label, 'equal-tickets'); + }); + + it('lets different packages enter before either owner is released', async () => { + const first = pack(makePackage('package-a'), 'a'); + await entered('a', 'prepack'); + const second = pack(makePackage('package-b'), 'b'); + await entered('b', 'prepack'); + expect(await finish(first, 'a')).toMatchObject({ code: 0 }); + expect(await finish(second, 'b')).toMatchObject({ code: 0 }); + expectArchive('a', 'package-a'); + expectArchive('b', 'package-b'); + }); + + it('releases ownership when pnpm fails so a waiting pack can finish', async () => { + const dir = makePackage('failed-package'); + const first = pack(dir, 'failed'); + await entered('failed', 'prepack'); + const { client: second } = await queuedPack(dir, 'retry'); + await release('failed', 'prepack', 'fail'); + expect(await first.result).toMatchObject({ code: 1 }); + expect(await finish(second, 'retry')).toMatchObject({ code: 0 }); + expectArchive('retry', 'failed-package'); + }); + + it('retains ownership after the caller dies until its pack process finishes', async () => { + const dir = makePackage('abandoned-caller'); + const first = pack(dir, 'abandoned'); + await entered('abandoned', 'prepack'); + first.child.kill('SIGKILL'); + await expectExcluded(dir, 'still-owned'); + await finish(first, 'abandoned'); + expect(await finish(pack(dir, 'recovered'), 'recovered')).toMatchObject({ code: 0 }); + expectArchive('recovered', 'abandoned-caller'); + }); + + it('retains abandoned ownership while a failed lifecycle still has a live child', async () => { + const dir = makePackage('orphaned-lifecycle'); + const first = pack(dir, 'failed'); + await release('failed', 'prepack', 'orphan'); + await entered('failed', 'orphan'); + expect(await first.result).toMatchObject({ code: 1 }); + await expectExcluded(dir, 'still-owned'); + await release('failed', 'orphan'); + expect(await finish(pack(dir, 'recovered'), 'recovered')).toMatchObject({ code: 0 }); + expectArchive('recovered', 'orphaned-lifecycle'); + }); + + it('retains abandoned ownership when its supervisor dies before pnpm finishes', async () => { + const dir = makePackage('orphaned-pack'); + const first = pack(dir, 'killed'); + await release('killed', 'prepack'); + await release('killed', 'prepare'); + await entered('killed', 'postpack'); + const [owner] = readdirSync(join(dir, 'node_modules', '.cache', 'shell-pack')); + expect(owner).toBeDefined(); + process.kill(Number(owner!.split('-')[0]), 'SIGKILL'); + expect(await first.result).toMatchObject({ code: 1 }); + await expectExcluded(dir, 'still-owned'); + await release('killed', 'postpack'); + expect(await finish(pack(dir, 'recovered'), 'recovered')).toMatchObject({ code: 0 }); + expectArchive('recovered', 'orphaned-pack'); + }); + + it('ignores an abandoned contender without deleting a live owner', async () => { + const dir = makePackage('abandoned-contender'); + const first = pack(dir, 'owner'); + await entered('owner', 'prepack'); + const lockDir = join(dir, 'node_modules', '.cache', 'shell-pack'); + const { client: contender, entry } = await queuedPack(dir, 'contender'); + const pid = Number(entry.split('-')[0]); + process.kill(-pid, 'SIGKILL'); + expect(await contender.result).toMatchObject({ code: 1 }); + await expectExcluded(dir, 'owner-survives'); + expect(await finish(first, 'owner')).toMatchObject({ code: 0 }); + expect(await finish(pack(dir, 'recovered'), 'recovered')).toMatchObject({ code: 0 }); + expectArchive('recovered', 'abandoned-contender'); + const remaining = readdirSync(lockDir); + expect(remaining).not.toContain(entry); + expect(remaining).toHaveLength(1); + expect(() => process.kill(-Number(remaining[0]!.split('-')[0]), 0)).toThrow( + expect.objectContaining({ code: 'ESRCH' }), + ); + }); + }, + timeouts.coldTransformImport, +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccf1372bee1e..ed7a8a11f195 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5000,6 +5000,9 @@ importers: '@internal/publish-surface': specifier: workspace:8.0.0-rc.11 version: link:../../../0-shared/publish-surface + '@repo/test-utils': + specifier: workspace:8.0.0-rc.11 + version: link:../../../../test/utils '@repo/tsconfig': specifier: workspace:8.0.0-rc.11 version: link:../../../0-config/tsconfig From ee01c73ad24d82bfb3779646fe4b3babd1bde3c5 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 14:54:03 +0000 Subject: [PATCH 2/6] Revert "fix(testkit): coordinate package packs across processes" This reverts commit 55c0be262dfbf18f07c85a2d979c162377f0c042. Signed-off-by: Steven McClankerton --- packages/0-config/tsdown/README.md | 10 - packages/0-config/tsdown/shell-pack.ts | 97 ----- packages/0-config/tsdown/shell-testkit.ts | 33 +- .../@prisma/orm-postgres/package.json | 3 +- .../orm-postgres/test/fixtures/pack-client.ts | 14 - .../test/fixtures/pack-lifecycle.ts | 31 -- .../test/fixtures/pack-ticket-barrier.ts | 26 -- .../test/pack-concurrency.test.ts | 382 ------------------ pnpm-lock.yaml | 3 - 9 files changed, 6 insertions(+), 593 deletions(-) delete mode 100644 packages/0-config/tsdown/shell-pack.ts delete mode 100644 packages/9-public/@prisma/orm-postgres/test/fixtures/pack-client.ts delete mode 100644 packages/9-public/@prisma/orm-postgres/test/fixtures/pack-lifecycle.ts delete mode 100644 packages/9-public/@prisma/orm-postgres/test/fixtures/pack-ticket-barrier.ts delete mode 100644 packages/9-public/@prisma/orm-postgres/test/pack-concurrency.test.ts diff --git a/packages/0-config/tsdown/README.md b/packages/0-config/tsdown/README.md index a36778e7501e..f74d67d1562c 100644 --- a/packages/0-config/tsdown/README.md +++ b/packages/0-config/tsdown/README.md @@ -62,16 +62,6 @@ export default defineConfig({ }) ``` -### Tarball test coordination - -`packShell` and `packShellAtVersion` coordinate testkit callers around the **entire** `pnpm pack` operation, including lifecycle scripts and archive creation. Different real package directories remain parallel; symlink aliases share ownership. This does **not** coordinate arbitrary external `pnpm pack` commands or concurrent builds/staging writes. - -The source-only `shell-pack.ts` worker runs in its own POSIX process group. A per-package Lamport bakery queue lives under `node_modules/.cache/shell-pack` (excluded from archives): each attempt publishes a unique PID/UUID directory before choosing a ticket, then atomically publishes its ticket. Choosing attempts and earlier ticket/ID pairs block entry. Attempts never reuse paths or remove the shared queue, so concurrent stale-record cleanup cannot delete a successor's ownership. - -Acquisition is bounded to 30 seconds; `packShell` accepts `{ lockTimeoutMs }` to set a smaller caller budget (zero means fail on contention). Timeout errors identify the package and blocking process groups. Acquisition failures remove their own attempt. After pnpm starts, ownership lasts until the **whole process group** disappears, on success or failure; the next caller reclaims the inactive record. This also protects against a killed caller/supervisor or a failed lifecycle leaving a child alive. Age alone never expires ownership; uncertain liveness or PID reuse fails closed rather than stealing a live pack's input tree. - -This test infrastructure requires a local filesystem and POSIX process groups (Linux/macOS); lifecycle descendants must not detach into other groups. A stuck/suspended/orphaned live group causes a bounded acquisition failure, not unsafe takeover. The subprocess regression suite in `@prisma/orm-postgres` uses lifecycle barriers and verifies archived file names and bytes, including regenerated skill metadata. - ### Migration from tsup `tsup` is no longer actively maintained. Migrate a monorepo package by uninstalling `tsup` - `pnpm uninstall tsup`. diff --git a/packages/0-config/tsdown/shell-pack.ts b/packages/0-config/tsdown/shell-pack.ts deleted file mode 100644 index 1f3f0703ebc9..000000000000 --- a/packages/0-config/tsdown/shell-pack.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; -import { - mkdirSync, - readdirSync, - readFileSync, - realpathSync, - renameSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { join } from 'pathe'; - -class PackLockError extends Error {} - -const [directory, tarball, timeoutArgument] = process.argv.slice(2); -if (!directory || !tarball) throw new PackLockError('Expected package directory and tarball path'); -if (process.platform === 'win32') - throw new PackLockError('Shell packing requires POSIX process groups'); -const timeoutMs = Number(timeoutArgument); -if (!Number.isFinite(timeoutMs) || timeoutMs < 0) - throw new PackLockError('Invalid pack lock timeout'); - -const packageDir = realpathSync(directory); -const lockDir = join(packageDir, 'node_modules', '.cache', 'shell-pack'); -const id = `${process.pid}-${randomUUID()}`; -const ownDir = join(lockDir, id); -const deadline = performance.now() + timeoutMs; -const pause = new Int32Array(new SharedArrayBuffer(4)); - -function hasCode(error: unknown, code: string): boolean { - return error instanceof Error && 'code' in error && error.code === code; -} - -function groupExists(pid: number): boolean { - try { - process.kill(-pid, 0); - return true; - } catch (error) { - if (hasCode(error, 'ESRCH')) return false; - throw error; - } -} - -function contenders(): { id: string; ticket: number }[] { - const entries: { id: string; ticket: number }[] = []; - for (const entry of readdirSync(lockDir)) { - const pid = Number(entry.split('-')[0]); - if (!Number.isSafeInteger(pid) || pid <= 0) { - throw new PackLockError(`Invalid pack owner in ${lockDir}: ${entry}`); - } - if (!groupExists(pid)) { - rmSync(join(lockDir, entry), { recursive: true, force: true }); - continue; - } - try { - const ticket = Number(readFileSync(join(lockDir, entry, 'ticket'), 'utf8')); - if (!Number.isSafeInteger(ticket) || ticket <= 0) { - throw new PackLockError(`Invalid pack ticket in ${lockDir}: ${entry}`); - } - entries.push({ id: entry, ticket }); - } catch (error) { - if (!hasCode(error, 'ENOENT')) throw error; - // A published directory without a ticket is still choosing its place in the queue. - entries.push({ id: entry, ticket: 0 }); - } - } - return entries; -} - -mkdirSync(lockDir, { recursive: true }); -mkdirSync(ownDir); -let packStarted = false; -try { - const ticket = Math.max(0, ...contenders().map((entry) => entry.ticket)) + 1; - writeFileSync(join(ownDir, 'ticket.tmp'), String(ticket)); - renameSync(join(ownDir, 'ticket.tmp'), join(ownDir, 'ticket')); - while (true) { - const blockers = contenders().filter( - (entry) => - entry.id !== id && - (entry.ticket === 0 || entry.ticket < ticket || (entry.ticket === ticket && entry.id < id)), - ); - if (blockers.length === 0) break; - if (performance.now() >= deadline) { - throw new PackLockError( - `Timed out acquiring pack lock for ${directory} (${packageDir}); owners: ${blockers.map((entry) => entry.id).join(', ')}`, - ); - } - Atomics.wait(pause, 0, 0, Math.min(25, deadline - performance.now())); - } - packStarted = true; - execFileSync('pnpm', ['pack', '--out', tarball], { cwd: packageDir, stdio: 'pipe' }); -} finally { - // Once launched, only a dead process group proves that pnpm and its lifecycle children are done. - if (!packStarted) rmSync(ownDir, { recursive: true, force: true }); -} diff --git a/packages/0-config/tsdown/shell-testkit.ts b/packages/0-config/tsdown/shell-testkit.ts index f146640cfcf2..caf1ba4acb1c 100644 --- a/packages/0-config/tsdown/shell-testkit.ts +++ b/packages/0-config/tsdown/shell-testkit.ts @@ -1,7 +1,6 @@ -import { execFileSync, type SpawnOptions } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { publicShells } from '@internal/publish-surface/shells'; import { init as initLexer, parse as parseModule } from 'es-module-lexer'; @@ -51,33 +50,11 @@ function manifestName(packageDir: string, manifest: Record): st return name; } -export interface PackOptions { - readonly lockTimeoutMs?: number | undefined; -} - -function runPack(packageDir: string, tarball: string, options: PackOptions): void { - const childOptions = { detached: true, stdio: 'pipe' } satisfies SpawnOptions; - execFileSync( - process.execPath, - [ - fileURLToPath(new URL('./shell-pack.ts', import.meta.url)), - packageDir, - tarball, - String(options.lockTimeoutMs ?? 30_000), - ], - childOptions, - ); -} - -/** `pnpm pack` a shell package, coordinating testkit callers through archive completion. */ -export function packShell( - shellDir: string, - outDir: string, - options: PackOptions = {}, -): PackedShell { +/** `pnpm pack` a shell package into `outDir`, returning the published name + tarball path. */ +export function packShell(shellDir: string, outDir: string): PackedShell { const name = manifestName(shellDir, readManifest(shellDir)); const tarball = join(outDir, `${name.replaceAll(/[@/]/g, '-').replace(/^-/, '')}.tgz`); - runPack(shellDir, tarball, options); + execFileSync('pnpm', ['pack', '--out', tarball], { cwd: shellDir, stdio: 'pipe' }); return { name, tarball }; } @@ -117,7 +94,7 @@ export function packShellAtVersion(shellDir: string, outDir: string, version: st delete staged['devDependencies']; writeFileSync(join(stageDir, 'package.json'), `${JSON.stringify(staged, null, 2)}\n`); const tarball = join(outDir, `${name.replaceAll(/[@/]/g, '-').replace(/^-/, '')}-${version}.tgz`); - runPack(stageDir, tarball, {}); + execFileSync('pnpm', ['pack', '--out', tarball], { cwd: stageDir, stdio: 'pipe' }); return { name, tarball, override: false }; } diff --git a/packages/9-public/@prisma/orm-postgres/package.json b/packages/9-public/@prisma/orm-postgres/package.json index 772217ec3cab..556191a119fc 100644 --- a/packages/9-public/@prisma/orm-postgres/package.json +++ b/packages/9-public/@prisma/orm-postgres/package.json @@ -28,9 +28,8 @@ }, "devDependencies": { "@internal/postgres": "workspace:8.0.0-rc.11", - "@internal/publish-surface": "workspace:8.0.0-rc.11", - "@repo/test-utils": "workspace:8.0.0-rc.11", "@repo/tsconfig": "workspace:8.0.0-rc.11", + "@internal/publish-surface": "workspace:8.0.0-rc.11", "@repo/tsdown": "workspace:8.0.0-rc.11", "tsdown": "catalog:", "typescript": "catalog:", diff --git a/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-client.ts b/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-client.ts deleted file mode 100644 index 88bc124ee554..000000000000 --- a/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-client.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { packShell } from '@repo/tsdown/shell-testkit'; - -const [shellDir, outDir, lockTimeoutMs] = process.argv.slice(2); -if (!shellDir || !outDir) throw new Error('Expected package and output directories'); - -try { - const packed = packShell(shellDir, outDir, { - lockTimeoutMs: lockTimeoutMs === undefined ? undefined : Number(lockTimeoutMs), - }); - console.log(JSON.stringify(packed)); -} catch (error) { - console.error(error); - process.exitCode = 1; -} diff --git a/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-lifecycle.ts b/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-lifecycle.ts deleted file mode 100644 index 4e044a87fb8f..000000000000 --- a/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-lifecycle.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { spawn } from 'node:child_process'; -import { cp, readFile, rm, writeFile } from 'node:fs/promises'; -import { connect } from 'node:net'; -import { join } from 'pathe'; - -const phase = process.argv[2]; -const socket = connect(Number(process.env['PACK_BARRIER_PORT']), '127.0.0.1'); -socket.write( - `${JSON.stringify({ label: process.env['PACK_LABEL'], phase, owner: process.env['PACK_OWNER'] })}\n`, -); -const command = await new Promise((resolve, reject) => { - let buffer = ''; - socket.on('data', (data) => { - buffer += data.toString(); - if (buffer.includes('\n')) resolve(buffer.trim()); - }); - socket.once('error', reject); -}); -socket.end(); -if (command === 'orphan') { - spawn(process.execPath, [process.argv[1]!, 'orphan'], { stdio: 'ignore' }).unref(); -} -if (command !== 'continue') process.exit(1); - -if (phase === 'prepack') { - await rm('skills', { recursive: true, force: true }); - await cp('skill-source', 'skills', { recursive: true }); - const manifest = JSON.parse(await readFile('package.json', 'utf8')) as { name: string }; - const skill = join('skills', 'SKILL.md'); - await writeFile(skill, (await readFile(skill, 'utf8')).replace('SOURCE', manifest.name)); -} diff --git a/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-ticket-barrier.ts b/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-ticket-barrier.ts deleted file mode 100644 index 38ddf998b642..000000000000 --- a/packages/9-public/@prisma/orm-postgres/test/fixtures/pack-ticket-barrier.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import { syncBuiltinESMExports } from 'node:module'; -import { fileURLToPath } from 'node:url'; -import { basename, dirname } from 'pathe'; - -if (process.argv[1]?.endsWith('/shell-pack.ts')) { - const barrier = (phase: string, path: string) => { - execFileSync( - process.execPath, - [fileURLToPath(new URL('./pack-lifecycle.ts', import.meta.url)), phase], - { env: { ...process.env, PACK_OWNER: basename(dirname(path)) }, stdio: 'pipe' }, - ); - }; - const writeFileSync = fs.writeFileSync; - fs.writeFileSync = (file, data, options) => { - if (typeof file === 'string' && file.endsWith('/ticket.tmp')) barrier('choosing', file); - writeFileSync(file, data, options); - }; - const renameSync = fs.renameSync; - fs.renameSync = (from, to) => { - renameSync(from, to); - if (typeof to === 'string' && to.endsWith('/ticket')) barrier('chosen', to); - }; - syncBuiltinESMExports(); -} diff --git a/packages/9-public/@prisma/orm-postgres/test/pack-concurrency.test.ts b/packages/9-public/@prisma/orm-postgres/test/pack-concurrency.test.ts deleted file mode 100644 index a7ed0708e56d..000000000000 --- a/packages/9-public/@prisma/orm-postgres/test/pack-concurrency.test.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { type ChildProcess, execFileSync, spawn } from 'node:child_process'; -import { EventEmitter, once } from 'node:events'; -import { - existsSync, - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - symlinkSync, - watch, - writeFileSync, -} from 'node:fs'; -import { createServer, type Socket } from 'node:net'; -import { tmpdir } from 'node:os'; -import { pathToFileURL } from 'node:url'; -import { timeouts } from '@repo/test-utils/timeouts'; -import { join } from 'pathe'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -type Phase = 'prepack' | 'prepare' | 'postpack' | 'orphan' | 'choosing' | 'chosen'; -interface Barrier { - readonly label: string; - readonly phase: Phase; - readonly socket: Socket; - readonly owner: string | undefined; -} -interface Result { - readonly code: number | null; - readonly output: string; -} -interface Client { - readonly child: ChildProcess; - readonly result: Promise; -} - -const fixtureDir = join(import.meta.dirname, 'fixtures'); -const sourceFiles = { - 'SKILL.md': '---\nlibrary: SOURCE\n---\nComplete skill\n', - 'references/one.md': 'first reference\n', - 'references/two.md': 'second reference\n', -}; - -let scratch: string; -let port: number; -let clients: Client[]; -let barriers: Barrier[]; -let events: EventEmitter; -let closing: boolean; -let server: ReturnType; - -beforeEach(async () => { - scratch = mkdtempSync(join(tmpdir(), 'shell-pack-concurrency-')); - clients = []; - barriers = []; - events = new EventEmitter(); - closing = false; - server = createServer((socket) => { - let buffer = ''; - socket.on('data', (chunk) => { - buffer += chunk.toString(); - if (!buffer.includes('\n')) return; - const event = JSON.parse(buffer) as Omit; - barriers.push({ ...event, socket }); - if (closing) socket.end('continue\n'); - events.emit('barrier'); - }); - }); - server.listen(0, '127.0.0.1'); - await once(server, 'listening'); - const address = server.address(); - if (address === null || typeof address === 'string') throw new Error('Missing barrier address'); - port = address.port; -}); - -afterEach(async () => { - closing = true; - for (const barrier of barriers) barrier.socket.end('continue\n'); - const stopChildren = () => { - for (const dir of readdirSync(scratch)) { - const lockDir = join(scratch, dir, 'node_modules', '.cache', 'shell-pack'); - if (!existsSync(lockDir)) continue; - for (const entry of readdirSync(lockDir)) { - try { - process.kill(-Number(entry.split('-')[0]), 'SIGKILL'); - } catch (error) { - if (!(error instanceof Error && 'code' in error && error.code === 'ESRCH')) throw error; - } - } - } - for (const client of clients) client.child.kill('SIGKILL'); - }; - const deadline = setTimeout(stopChildren, timeouts.databaseOperation); - try { - await Promise.all(clients.map((client) => client.result)); - } finally { - clearTimeout(deadline); - stopChildren(); - await Promise.allSettled(clients.map((client) => client.result)); - for (const barrier of barriers) barrier.socket.destroy(); - await new Promise((resolve) => server.close(() => resolve())); - rmSync(scratch, { recursive: true, force: true }); - } -}); - -function makePackage(name: string): string { - const dir = join(scratch, name); - mkdirSync(dir); - writeFileSync( - join(dir, 'package.json'), - JSON.stringify({ - name, - version: '1.0.0', - files: ['skills'], - scripts: Object.fromEntries( - ['prepack', 'prepare', 'postpack'].map((phase) => [ - phase, - `node ${JSON.stringify(join(fixtureDir, 'pack-lifecycle.ts'))} ${phase}`, - ]), - ), - }), - ); - writeFileSync(join(dir, 'pnpm-workspace.yaml'), '{}\n'); - for (const [file, content] of Object.entries(sourceFiles)) { - const path = join(dir, 'skill-source', file); - mkdirSync(join(path, '..'), { recursive: true }); - writeFileSync(path, content); - } - mkdirSync(join(dir, 'skills')); - writeFileSync(join(dir, 'skills', 'obsolete.md'), 'must not survive prepack'); - return dir; -} - -function pack(dir: string, label: string, lockTimeoutMs?: number, ticketBarrier = false): Client { - const out = join(scratch, label); - mkdirSync(out); - const args = [join(fixtureDir, 'pack-client.ts'), dir, out]; - if (lockTimeoutMs !== undefined) args.push(String(lockTimeoutMs)); - const env: NodeJS.ProcessEnv = { - ...process.env, - PACK_BARRIER_PORT: String(port), - PACK_LABEL: label, - }; - if (ticketBarrier) { - const preload = pathToFileURL(join(fixtureDir, 'pack-ticket-barrier.ts')).href; - env['NODE_OPTIONS'] = `${process.env['NODE_OPTIONS'] ?? ''} --import=${preload}`; - } - const child = spawn(process.execPath, args, { - env, - stdio: ['ignore', 'pipe', 'pipe'], - }); - let output = ''; - child.stdout.on('data', (data) => { - output += data; - }); - child.stderr.on('data', (data) => { - output += data; - }); - const result = new Promise((resolve, reject) => { - child.once('error', reject); - child.once('close', (code) => resolve({ code, output })); - }); - const client = { child, result }; - clients.push(client); - return client; -} - -async function queuedPack(dir: string, label: string): Promise<{ client: Client; entry: string }> { - const lockDir = join(dir, 'node_modules', '.cache', 'shell-pack'); - const original = new Set(readdirSync(lockDir)); - const watcher = watch(lockDir); - const change = once(watcher, 'change', { - signal: AbortSignal.timeout(timeouts.coldTransformImport), - }); - try { - const client = pack(dir, label); - await change; - const entry = readdirSync(lockDir).find((name) => !original.has(name)); - expect(entry).toBeDefined(); - return { client, entry: entry! }; - } finally { - watcher.close(); - } -} - -async function entered(label: string, phase: Phase): Promise { - while (true) { - const event = barriers.find((barrier) => barrier.label === label && barrier.phase === phase); - if (event) return event; - await once(events, 'barrier'); - } -} - -async function release(label: string, phase: Phase, command = 'continue'): Promise { - (await entered(label, phase)).socket.end(`${command}\n`); -} - -async function finish(client: Client, label: string): Promise { - for (const phase of ['prepack', 'prepare', 'postpack'] as const) await release(label, phase); - return client.result; -} - -async function expectExcluded(dir: string, label: string, lockTimeoutMs = 0): Promise { - await expectBlocked(pack(dir, label, lockTimeoutMs), label, dir); -} - -async function expectBlocked(contender: Client, label: string, dir: string): Promise { - const outcome = await Promise.race([ - contender.result, - entered(label, 'prepack').then( - () => 'entered prepack while another pack still owns the package', - ), - ]); - expect(outcome).toEqual({ - code: 1, - output: expect.stringContaining('Timed out acquiring pack lock'), - }); - expect(outcome).toEqual({ code: 1, output: expect.stringContaining(dir) }); -} - -function expectArchive(label: string, name: string): void { - const tarball = join(scratch, label, `${name}.tgz`); - const files = execFileSync('tar', ['-tzf', tarball], { encoding: 'utf8' }) - .trim() - .split('\n') - .filter((file) => !file.endsWith('/')) - .sort(); - expect(files).toEqual( - [ - 'package/package.json', - ...Object.keys(sourceFiles).map((file) => `package/skills/${file}`), - ].sort(), - ); - for (const [file, content] of Object.entries(sourceFiles)) { - expect( - execFileSync('tar', ['-xOzf', tarball, `package/skills/${file}`], { encoding: 'utf8' }), - ).toBe(content.replace('SOURCE', name)); - } -} - -describe( - 'cross-process shell packing', - () => { - it('excludes aliases of the same package through archive completion and packs complete contents', async () => { - const dir = makePackage('same-package'); - const alias = join(scratch, 'alias'); - symlinkSync(dir, alias); - const first = pack(dir, 'first'); - await entered('first', 'prepack'); - await expectExcluded(alias, 'during-prepack'); - await release('first', 'prepack'); - await entered('first', 'prepare'); - await expectExcluded(dir, 'before-archive'); - const { client: second } = await queuedPack(alias, 'second'); - await release('first', 'prepare'); - await entered('first', 'postpack'); - expectArchive('first', 'same-package'); - await expectExcluded(dir, 'after-archive', timeouts.default); - await release('first', 'postpack'); - expect(await first.result).toMatchObject({ code: 0 }); - expect(await finish(second, 'second')).toMatchObject({ code: 0 }); - expectArchive('second', 'same-package'); - }); - - it('breaks equal-ticket ties without admitting two simultaneous choosers', async () => { - const dir = makePackage('equal-tickets'); - const first = pack(dir, 'a', 0, true); - await entered('a', 'choosing'); - const second = pack(dir, 'b', 0, true); - await entered('b', 'choosing'); - await release('a', 'choosing'); - await release('b', 'choosing'); - const a = await entered('a', 'chosen'); - const b = await entered('b', 'chosen'); - expect(a.owner).toBeDefined(); - expect(b.owner).toBeDefined(); - const lockDir = join(dir, 'node_modules', '.cache', 'shell-pack'); - expect( - [a, b].map(({ owner }) => readFileSync(join(lockDir, owner!, 'ticket'), 'utf8')), - ).toEqual(['1', '1']); - const [leader, follower] = - a.owner! < b.owner! - ? [ - { client: first, label: 'a' }, - { client: second, label: 'b' }, - ] - : [ - { client: second, label: 'b' }, - { client: first, label: 'a' }, - ]; - await release(leader!.label, 'chosen'); - await entered(leader!.label, 'prepack'); - await release(follower!.label, 'chosen'); - await expectBlocked(follower!.client, follower!.label, dir); - expect(await finish(leader!.client, leader!.label)).toMatchObject({ code: 0 }); - expectArchive(leader!.label, 'equal-tickets'); - }); - - it('lets different packages enter before either owner is released', async () => { - const first = pack(makePackage('package-a'), 'a'); - await entered('a', 'prepack'); - const second = pack(makePackage('package-b'), 'b'); - await entered('b', 'prepack'); - expect(await finish(first, 'a')).toMatchObject({ code: 0 }); - expect(await finish(second, 'b')).toMatchObject({ code: 0 }); - expectArchive('a', 'package-a'); - expectArchive('b', 'package-b'); - }); - - it('releases ownership when pnpm fails so a waiting pack can finish', async () => { - const dir = makePackage('failed-package'); - const first = pack(dir, 'failed'); - await entered('failed', 'prepack'); - const { client: second } = await queuedPack(dir, 'retry'); - await release('failed', 'prepack', 'fail'); - expect(await first.result).toMatchObject({ code: 1 }); - expect(await finish(second, 'retry')).toMatchObject({ code: 0 }); - expectArchive('retry', 'failed-package'); - }); - - it('retains ownership after the caller dies until its pack process finishes', async () => { - const dir = makePackage('abandoned-caller'); - const first = pack(dir, 'abandoned'); - await entered('abandoned', 'prepack'); - first.child.kill('SIGKILL'); - await expectExcluded(dir, 'still-owned'); - await finish(first, 'abandoned'); - expect(await finish(pack(dir, 'recovered'), 'recovered')).toMatchObject({ code: 0 }); - expectArchive('recovered', 'abandoned-caller'); - }); - - it('retains abandoned ownership while a failed lifecycle still has a live child', async () => { - const dir = makePackage('orphaned-lifecycle'); - const first = pack(dir, 'failed'); - await release('failed', 'prepack', 'orphan'); - await entered('failed', 'orphan'); - expect(await first.result).toMatchObject({ code: 1 }); - await expectExcluded(dir, 'still-owned'); - await release('failed', 'orphan'); - expect(await finish(pack(dir, 'recovered'), 'recovered')).toMatchObject({ code: 0 }); - expectArchive('recovered', 'orphaned-lifecycle'); - }); - - it('retains abandoned ownership when its supervisor dies before pnpm finishes', async () => { - const dir = makePackage('orphaned-pack'); - const first = pack(dir, 'killed'); - await release('killed', 'prepack'); - await release('killed', 'prepare'); - await entered('killed', 'postpack'); - const [owner] = readdirSync(join(dir, 'node_modules', '.cache', 'shell-pack')); - expect(owner).toBeDefined(); - process.kill(Number(owner!.split('-')[0]), 'SIGKILL'); - expect(await first.result).toMatchObject({ code: 1 }); - await expectExcluded(dir, 'still-owned'); - await release('killed', 'postpack'); - expect(await finish(pack(dir, 'recovered'), 'recovered')).toMatchObject({ code: 0 }); - expectArchive('recovered', 'orphaned-pack'); - }); - - it('ignores an abandoned contender without deleting a live owner', async () => { - const dir = makePackage('abandoned-contender'); - const first = pack(dir, 'owner'); - await entered('owner', 'prepack'); - const lockDir = join(dir, 'node_modules', '.cache', 'shell-pack'); - const { client: contender, entry } = await queuedPack(dir, 'contender'); - const pid = Number(entry.split('-')[0]); - process.kill(-pid, 'SIGKILL'); - expect(await contender.result).toMatchObject({ code: 1 }); - await expectExcluded(dir, 'owner-survives'); - expect(await finish(first, 'owner')).toMatchObject({ code: 0 }); - expect(await finish(pack(dir, 'recovered'), 'recovered')).toMatchObject({ code: 0 }); - expectArchive('recovered', 'abandoned-contender'); - const remaining = readdirSync(lockDir); - expect(remaining).not.toContain(entry); - expect(remaining).toHaveLength(1); - expect(() => process.kill(-Number(remaining[0]!.split('-')[0]), 0)).toThrow( - expect.objectContaining({ code: 'ESRCH' }), - ); - }); - }, - timeouts.coldTransformImport, -); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed7a8a11f195..ccf1372bee1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5000,9 +5000,6 @@ importers: '@internal/publish-surface': specifier: workspace:8.0.0-rc.11 version: link:../../../0-shared/publish-surface - '@repo/test-utils': - specifier: workspace:8.0.0-rc.11 - version: link:../../../../test/utils '@repo/tsconfig': specifier: workspace:8.0.0-rc.11 version: link:../../../0-config/tsconfig From 0e01d6cc033c0d4fb46abd6edbc3b38acb4481c6 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 15:29:07 +0000 Subject: [PATCH 3/6] test(packaging): serialize tarball suites in a dedicated integration project Signed-off-by: Steven McClankerton --- .../orm-extension-pgvector/package.json | 7 ++--- .../orm-extension-pgvector/vitest.config.ts | 11 -------- .../@prisma/orm-postgres/package.json | 7 ++--- .../@prisma/orm-postgres/vitest.config.ts | 11 -------- pnpm-lock.yaml | 18 +++++-------- scripts/lint-legacy-name.mjs | 2 +- scripts/lint-single-import-root.mjs | 15 ++++++++--- scripts/lint-single-import-root.test.mjs | 14 ++++++++++ test/integration/README.md | 5 ++++ test/integration/package.json | 2 ++ .../integration/test/packaging-config.test.ts | 27 +++++++++++++++++++ .../test/packaging}/extension-tarball.test.ts | 2 +- .../test/packaging}/facade-tarball.test.ts | 2 +- test/integration/vitest.config.ts | 21 +++++++++++++++ turbo.json | 8 ------ 15 files changed, 94 insertions(+), 58 deletions(-) delete mode 100644 packages/9-public/@prisma/orm-extension-pgvector/vitest.config.ts delete mode 100644 packages/9-public/@prisma/orm-postgres/vitest.config.ts create mode 100644 test/integration/test/packaging-config.test.ts rename {packages/9-public/@prisma/orm-extension-pgvector/test => test/integration/test/packaging}/extension-tarball.test.ts (99%) rename {packages/9-public/@prisma/orm-postgres/test => test/integration/test/packaging}/facade-tarball.test.ts (99%) diff --git a/packages/9-public/@prisma/orm-extension-pgvector/package.json b/packages/9-public/@prisma/orm-extension-pgvector/package.json index c3457fd46417..08efd273526c 100644 --- a/packages/9-public/@prisma/orm-extension-pgvector/package.json +++ b/packages/9-public/@prisma/orm-extension-pgvector/package.json @@ -11,8 +11,7 @@ "scripts": { "build": "tsdown", "lint": "biome check . --error-on-warnings", - "clean": "rm -rf dist src-gen", - "test": "vitest run" + "clean": "rm -rf dist src-gen" }, "dependencies": { "@prisma/orm-family-sql": "workspace:8.0.0-rc.11", @@ -24,12 +23,10 @@ "devDependencies": { "@internal/extension-pgvector": "workspace:8.0.0-rc.11", "@repo/tsconfig": "workspace:8.0.0-rc.11", - "@internal/publish-surface": "workspace:8.0.0-rc.11", "@repo/tsdown": "workspace:8.0.0-rc.11", "@prisma/orm-target-postgres": "workspace:8.0.0-rc.11", "tsdown": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" + "typescript": "catalog:" }, "peerDependencies": { "@prisma/orm-target-postgres": "workspace:8.0.0-rc.11", diff --git a/packages/9-public/@prisma/orm-extension-pgvector/vitest.config.ts b/packages/9-public/@prisma/orm-extension-pgvector/vitest.config.ts deleted file mode 100644 index 0dd8cd87a28d..000000000000 --- a/packages/9-public/@prisma/orm-extension-pgvector/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - environment: 'node', - include: ['test/**/*.test.ts'], - testTimeout: 300_000, - hookTimeout: 300_000, - }, -}); diff --git a/packages/9-public/@prisma/orm-postgres/package.json b/packages/9-public/@prisma/orm-postgres/package.json index 556191a119fc..6472d736bf53 100644 --- a/packages/9-public/@prisma/orm-postgres/package.json +++ b/packages/9-public/@prisma/orm-postgres/package.json @@ -13,8 +13,7 @@ "build": "tsdown", "prepack": "node ../../../../scripts/sync-package-skills.ts @prisma/orm-postgres", "lint": "biome check . --error-on-warnings", - "clean": "rm -rf dist src-gen skills", - "test": "vitest run" + "clean": "rm -rf dist src-gen skills" }, "dependencies": { "@prisma/orm-family-sql": "workspace:8.0.0-rc.11", @@ -29,11 +28,9 @@ "devDependencies": { "@internal/postgres": "workspace:8.0.0-rc.11", "@repo/tsconfig": "workspace:8.0.0-rc.11", - "@internal/publish-surface": "workspace:8.0.0-rc.11", "@repo/tsdown": "workspace:8.0.0-rc.11", "tsdown": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" + "typescript": "catalog:" }, "peerDependencies": { "typescript": ">=5.9" diff --git a/packages/9-public/@prisma/orm-postgres/vitest.config.ts b/packages/9-public/@prisma/orm-postgres/vitest.config.ts deleted file mode 100644 index 0dd8cd87a28d..000000000000 --- a/packages/9-public/@prisma/orm-postgres/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - environment: 'node', - include: ['test/**/*.test.ts'], - testTimeout: 300_000, - hookTimeout: 300_000, - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccf1372bee1e..e4314ccb0214 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4603,9 +4603,6 @@ importers: '@internal/extension-pgvector': specifier: workspace:8.0.0-rc.11 version: link:../../../3-extensions/pgvector - '@internal/publish-surface': - specifier: workspace:8.0.0-rc.11 - version: link:../../../0-shared/publish-surface '@prisma/orm-target-postgres': specifier: workspace:8.0.0-rc.11 version: link:../orm-target-postgres @@ -4621,9 +4618,6 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 - vitest: - specifier: 'catalog:' - version: 5.0.0-rc.2(@types/node@26.1.2)(@vitest/coverage-v8@5.0.0-rc.2)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/9-public/@prisma/orm-extension-postgis: dependencies: @@ -4997,9 +4991,6 @@ importers: '@internal/postgres': specifier: workspace:8.0.0-rc.11 version: link:../../../3-extensions/postgres - '@internal/publish-surface': - specifier: workspace:8.0.0-rc.11 - version: link:../../../0-shared/publish-surface '@repo/tsconfig': specifier: workspace:8.0.0-rc.11 version: link:../../../0-config/tsconfig @@ -5012,9 +5003,6 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 - vitest: - specifier: 'catalog:' - version: 5.0.0-rc.2(@types/node@26.1.2)(@vitest/coverage-v8@5.0.0-rc.2)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/9-public/@prisma/orm-sqlite: dependencies: @@ -5532,6 +5520,9 @@ importers: '@internal/mongo-lowering': specifier: workspace:8.0.0-rc.11 version: link:../../packages/2-mongo-family/6-transport/mongo-lowering + '@internal/publish-surface': + specifier: workspace:8.0.0-rc.11 + version: link:../../packages/0-shared/publish-surface '@prisma/cli-engine': specifier: 0.4.0 version: 0.4.0(@prisma/management-api-sdk@1.61.0)(magicast@0.5.4) @@ -5541,6 +5532,9 @@ importers: '@repo/tsconfig': specifier: workspace:8.0.0-rc.11 version: link:../../packages/0-config/tsconfig + '@repo/tsdown': + specifier: workspace:8.0.0-rc.11 + version: link:../../packages/0-config/tsdown '@types/pg': specifier: 'catalog:' version: 8.20.4 diff --git a/scripts/lint-legacy-name.mjs b/scripts/lint-legacy-name.mjs index d4bd2bb50bcd..607e68aa4b4e 100644 --- a/scripts/lint-legacy-name.mjs +++ b/scripts/lint-legacy-name.mjs @@ -33,7 +33,7 @@ const RETIREMENT_PROOFS = new Set([ 'packages/1-framework/3-tooling/cli/test/orm/init-scaffold.test.ts', 'packages/1-framework/1-core/errors/test/next-actions.test.ts', 'packages/1-framework/3-tooling/migration/test/next-actions.test.ts', - 'packages/9-public/@prisma/orm-postgres/test/facade-tarball.test.ts', + 'test/integration/test/packaging/facade-tarball.test.ts', 'packages/9-public/@prisma/orm-target-postgres/test/cross-shell-tarball.test.ts', 'test/integration/test/cli.init-skill-distribution.integration.test.ts', ]); diff --git a/scripts/lint-single-import-root.mjs b/scripts/lint-single-import-root.mjs index 3a7d784ec03b..0650f2cc1623 100644 --- a/scripts/lint-single-import-root.mjs +++ b/scripts/lint-single-import-root.mjs @@ -18,7 +18,11 @@ * Scope is the consumer trees: `examples/`, `apps/` and `test/`. Packages * under `packages/` are the substrate the shells are built from and name * workspace packages by construction; the shells' own tests legitimately name - * both, and are excluded with them. + * both, and are excluded with them. The packaging suites under + * `test/integration/test/packaging/` are exempt for the same reason: their + * published-root specifiers are strings handed to scratch projects that + * install packed tarballs and run in a child process, so both roots never + * load into one module graph. * * Exits 1 listing every mixed package; exits 0 otherwise. */ @@ -28,6 +32,7 @@ import { extname, join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const CONSUMER_ROOTS = ['examples', 'apps', 'test']; +const EXEMPT_SUBTREES = ['test/integration/test/packaging']; const INTERNAL_SCOPE = '@internal/'; const PUBLISHED_SCOPE = '@prisma/orm-'; const INCLUDED_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']); @@ -94,13 +99,17 @@ function ownFiles(pkg, allPackages) { * root and how many more there are. Takes the roots as an argument so the * check is reachable from a test with a fixture tree of its own. */ -export function findMixedPackages(baseDir, roots = CONSUMER_ROOTS) { +export function findMixedPackages(baseDir, roots = CONSUMER_ROOTS, exempt = EXEMPT_SUBTREES) { const packages = roots.flatMap((root) => [...walkPackages(join(baseDir, root))]); + const exemptPrefixes = exempt.map((subtree) => join(baseDir, subtree) + sep); const mixed = []; for (const pkg of packages) { const internal = new Map(); const published = new Map(); - for (const file of ownFiles(pkg, packages)) { + const files = ownFiles(pkg, packages).filter( + (file) => !exemptPrefixes.some((prefix) => file.startsWith(prefix)), + ); + for (const file of files) { for (const [, , specifier] of readFileSync(file, 'utf8').matchAll(MODULE_SPECIFIER)) { const seen = specifier.startsWith(INTERNAL_SCOPE) ? internal diff --git a/scripts/lint-single-import-root.test.mjs b/scripts/lint-single-import-root.test.mjs index 8f8d936fa802..baccb42d8e1e 100644 --- a/scripts/lint-single-import-root.test.mjs +++ b/scripts/lint-single-import-root.test.mjs @@ -34,6 +34,10 @@ before(() => { }); pkg('examples/parent', { 'src/app.ts': "import x from '@prisma/orm-mongo/runtime';" }); pkg('examples/parent/nested', { 'src/app.ts': "import y from '@internal/mongo-orm';" }); + pkg('test/integration', { + 'test/query.ts': "import { budgets } from '@internal/sql-runtime';", + 'test/packaging/tarball.test.ts': "await import('@prisma/orm-postgres/runtime');", + }); }); after(() => { @@ -66,6 +70,16 @@ describe('findMixedPackages', () => { assert.deepEqual([...entry.published.keys()], ['@prisma/orm-postgres/runtime']); assert.deepEqual([...entry.internal.keys()], ['@internal/sql-runtime']); }); + + test('skips the packaging suites when attributing specifiers', () => { + const mixed = findMixedPackages(base, ['test']).map((entry) => entry.pkg); + assert.ok(!mixed.includes('test/integration')); + }); + + test('still reports the package when the same specifiers sit outside the exempt subtree', () => { + const mixed = findMixedPackages(base, ['test'], []).map((entry) => entry.pkg); + assert.ok(mixed.includes('test/integration')); + }); }); describe('main(baseDir)', () => { diff --git a/test/integration/README.md b/test/integration/README.md index bdd3c949e0dc..5a3d19bd39da 100644 --- a/test/integration/README.md +++ b/test/integration/README.md @@ -20,6 +20,11 @@ This package contains integration tests that verify the complete flow from contr - `test/*.test-d.ts` - Type-only test files (for testing TypeScript types) - `test/*.helpers.ts` - Shared test helpers for related test files - `test/fixtures/` - Test fixtures (contract JSON, type definitions, CLI fixture apps) +- `test/packaging/` - Tarball suites that `pnpm pack` real workspace packages + +### Packaging suites run sequentially + +The suites under `test/packaging/` pack overlapping real package directories (both pack the Postgres facade, whose `prepack` rewrites its `skills/` tree in place), so two of them packing concurrently corrupt each other's tarballs. `vitest.config.ts` therefore isolates them in a dedicated `packaging` project with `fileParallelism: false`: Vitest runs every such project in one shared sequential group while the `integration` project keeps its normal file parallelism. `test/packaging-config.test.ts` guards this split. **Note**: Integration tests that depend on multiple packages (for example SQL authoring, emission, and runtime packages together) are placed here to avoid cyclic dependencies. diff --git a/test/integration/package.json b/test/integration/package.json index 2dad4d7a828d..c452bc1ffaa3 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -77,8 +77,10 @@ "@internal/language-server": "workspace:8.0.0-rc.11", "@internal/middleware-cache": "workspace:8.0.0-rc.11", "@internal/mongo-lowering": "workspace:8.0.0-rc.11", + "@internal/publish-surface": "workspace:8.0.0-rc.11", "@prisma/cli-engine": "0.4.0", "@repo/test-utils": "workspace:8.0.0-rc.11", + "@repo/tsdown": "workspace:8.0.0-rc.11", "@repo/tsconfig": "workspace:8.0.0-rc.11", "@types/pg": "catalog:", "commander": "^15.0.0", diff --git a/test/integration/test/packaging-config.test.ts b/test/integration/test/packaging-config.test.ts new file mode 100644 index 000000000000..e1b9de208a68 --- /dev/null +++ b/test/integration/test/packaging-config.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import config from '../vitest.config'; + +type Project = { extends?: boolean; test?: Record }; + +function project(name: string): Project { + const projects = (config.test?.projects ?? []) as Project[]; + const found = projects.find((candidate) => candidate.test?.['name'] === name); + if (found === undefined) throw new Error(`no vitest project named ${name}`); + return found; +} + +describe('packaging test scheduling', () => { + it('serializes the tarball suites in a dedicated project', () => { + expect(project('packaging').test).toMatchObject({ + include: ['test/packaging/**/*.test.ts'], + fileParallelism: false, + }); + }); + + it('keeps the other integration tests parallel and free of packaging suites', () => { + expect(config.test?.fileParallelism).not.toBe(false); + const integration = project('integration').test; + expect(integration?.['exclude']).toContain('test/packaging/**'); + expect(integration?.['fileParallelism']).toBeUndefined(); + }); +}); diff --git a/packages/9-public/@prisma/orm-extension-pgvector/test/extension-tarball.test.ts b/test/integration/test/packaging/extension-tarball.test.ts similarity index 99% rename from packages/9-public/@prisma/orm-extension-pgvector/test/extension-tarball.test.ts rename to test/integration/test/packaging/extension-tarball.test.ts index 4d75655b9fc7..77754855cc36 100644 --- a/packages/9-public/@prisma/orm-extension-pgvector/test/extension-tarball.test.ts +++ b/test/integration/test/packaging/extension-tarball.test.ts @@ -17,7 +17,7 @@ import { } from '@repo/tsdown/shell-testkit'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '..'); +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..'); const workspaceVersion = ( JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')) as { version: string } ).version; diff --git a/packages/9-public/@prisma/orm-postgres/test/facade-tarball.test.ts b/test/integration/test/packaging/facade-tarball.test.ts similarity index 99% rename from packages/9-public/@prisma/orm-postgres/test/facade-tarball.test.ts rename to test/integration/test/packaging/facade-tarball.test.ts index a3f6c8827835..7dfd2c00de20 100644 --- a/packages/9-public/@prisma/orm-postgres/test/facade-tarball.test.ts +++ b/test/integration/test/packaging/facade-tarball.test.ts @@ -15,7 +15,7 @@ import { } from '@repo/tsdown/shell-testkit'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '..'); +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..'); const facade = '@prisma/orm-postgres'; const platform: ShellName[] = [ '@prisma/orm-framework', diff --git a/test/integration/vitest.config.ts b/test/integration/vitest.config.ts index 93a5a41a7c85..5959ba20c06d 100644 --- a/test/integration/vitest.config.ts +++ b/test/integration/vitest.config.ts @@ -70,5 +70,26 @@ export default defineConfig({ // Note it cannot cover the JIT abort above: that kills the worker fork // rather than failing a test, so there is nothing for vitest to retry. retry: process.env['CI'] ? 2 : 0, + projects: [ + { + extends: true, + test: { + name: 'integration', + include: ['test/**/*.test.ts'], + exclude: [...configDefaults.exclude, ...initJourneyExclude, 'test/packaging/**'], + }, + }, + { + extends: true, + test: { + name: 'packaging', + include: ['test/packaging/**/*.test.ts'], + fileParallelism: false, + typecheck: { enabled: false }, + testTimeout: 300_000, + hookTimeout: 300_000, + }, + }, + ], }, }); diff --git a/turbo.json b/turbo.json index 34fb3eb06448..57ff264f4548 100644 --- a/turbo.json +++ b/turbo.json @@ -71,14 +71,6 @@ "@prisma/orm-target-postgres#test": { "dependsOn": ["build", "^build"], "inputs": ["test/**", "dist/**", "vitest.config.ts"] - }, - "@prisma/orm-postgres#test": { - "dependsOn": ["build", "^build"], - "inputs": ["test/**", "dist/**", "vitest.config.ts"] - }, - "@prisma/orm-extension-pgvector#test": { - "dependsOn": ["build", "^build", "@prisma/orm-postgres#build"], - "inputs": ["test/**", "dist/**", "vitest.config.ts"] } } } From 236003864c158c60ed8befe173018a32379133d0 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 16:02:11 +0000 Subject: [PATCH 4/6] test(packaging): drop the vitest config snapshot test Signed-off-by: Steven McClankerton --- test/integration/README.md | 2 +- .../integration/test/packaging-config.test.ts | 27 ------------------- 2 files changed, 1 insertion(+), 28 deletions(-) delete mode 100644 test/integration/test/packaging-config.test.ts diff --git a/test/integration/README.md b/test/integration/README.md index 5a3d19bd39da..3a985dc3190f 100644 --- a/test/integration/README.md +++ b/test/integration/README.md @@ -24,7 +24,7 @@ This package contains integration tests that verify the complete flow from contr ### Packaging suites run sequentially -The suites under `test/packaging/` pack overlapping real package directories (both pack the Postgres facade, whose `prepack` rewrites its `skills/` tree in place), so two of them packing concurrently corrupt each other's tarballs. `vitest.config.ts` therefore isolates them in a dedicated `packaging` project with `fileParallelism: false`: Vitest runs every such project in one shared sequential group while the `integration` project keeps its normal file parallelism. `test/packaging-config.test.ts` guards this split. +The suites under `test/packaging/` pack overlapping real package directories (both pack the Postgres facade, whose `prepack` rewrites its `skills/` tree in place), so two of them packing concurrently corrupt each other's tarballs. `vitest.config.ts` therefore isolates them in a dedicated `packaging` project with `fileParallelism: false`: Vitest runs every such project in one shared sequential group while the `integration` project keeps its normal file parallelism. **Note**: Integration tests that depend on multiple packages (for example SQL authoring, emission, and runtime packages together) are placed here to avoid cyclic dependencies. diff --git a/test/integration/test/packaging-config.test.ts b/test/integration/test/packaging-config.test.ts deleted file mode 100644 index e1b9de208a68..000000000000 --- a/test/integration/test/packaging-config.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import config from '../vitest.config'; - -type Project = { extends?: boolean; test?: Record }; - -function project(name: string): Project { - const projects = (config.test?.projects ?? []) as Project[]; - const found = projects.find((candidate) => candidate.test?.['name'] === name); - if (found === undefined) throw new Error(`no vitest project named ${name}`); - return found; -} - -describe('packaging test scheduling', () => { - it('serializes the tarball suites in a dedicated project', () => { - expect(project('packaging').test).toMatchObject({ - include: ['test/packaging/**/*.test.ts'], - fileParallelism: false, - }); - }); - - it('keeps the other integration tests parallel and free of packaging suites', () => { - expect(config.test?.fileParallelism).not.toBe(false); - const integration = project('integration').test; - expect(integration?.['exclude']).toContain('test/packaging/**'); - expect(integration?.['fileParallelism']).toBeUndefined(); - }); -}); From 31735bf06cd5bda5039309bb737f22fa6052dff6 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 16:15:51 +0000 Subject: [PATCH 5/6] test(coverage): retire the orphaned coverage policies of the moved suites Signed-off-by: Steven McClankerton --- .../@prisma/orm-extension-pgvector/coverage.config.json | 5 ----- packages/9-public/@prisma/orm-postgres/coverage.config.json | 5 ----- scripts/coverage-config.test.mjs | 2 +- 3 files changed, 1 insertion(+), 11 deletions(-) delete mode 100644 packages/9-public/@prisma/orm-extension-pgvector/coverage.config.json delete mode 100644 packages/9-public/@prisma/orm-postgres/coverage.config.json diff --git a/packages/9-public/@prisma/orm-extension-pgvector/coverage.config.json b/packages/9-public/@prisma/orm-extension-pgvector/coverage.config.json deleted file mode 100644 index 8fc75c6d0c9b..000000000000 --- a/packages/9-public/@prisma/orm-extension-pgvector/coverage.config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "include": [], - "exclude": [], - "thresholds": {} -} diff --git a/packages/9-public/@prisma/orm-postgres/coverage.config.json b/packages/9-public/@prisma/orm-postgres/coverage.config.json deleted file mode 100644 index 8fc75c6d0c9b..000000000000 --- a/packages/9-public/@prisma/orm-postgres/coverage.config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "include": [], - "exclude": [], - "thresholds": {} -} diff --git a/scripts/coverage-config.test.mjs b/scripts/coverage-config.test.mjs index 02279dac4bdb..72987d71f5d0 100644 --- a/scripts/coverage-config.test.mjs +++ b/scripts/coverage-config.test.mjs @@ -234,7 +234,7 @@ describe('coverage config', () => { } vitestPaths.sort(); - assert.equal(vitestPaths.length, 70); + assert.equal(vitestPaths.length, 68); assert.deepEqual( configs.map(({ configPath }) => relative(repositoryRoot, configPath)), vitestPaths.map((path) => path.replace('vitest.config.ts', 'coverage.config.json')), From c2030fe1f54f2fbf1a331235044c7a50e64b1787 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 16:17:18 +0000 Subject: [PATCH 6/6] test(coverage): drop the vitest config census Signed-off-by: Steven McClankerton --- scripts/coverage-config.test.mjs | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/scripts/coverage-config.test.mjs b/scripts/coverage-config.test.mjs index 72987d71f5d0..6c479d7d0e30 100644 --- a/scripts/coverage-config.test.mjs +++ b/scripts/coverage-config.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { glob, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join, relative } from 'node:path'; +import { dirname, join } from 'node:path'; import { after, before, describe, it } from 'node:test'; import { @@ -225,26 +225,6 @@ describe('coverage config', () => { } }); - it('every package Vitest project owns JSON and no TS config retains coverage', async () => { - const repositoryRoot = join(import.meta.dirname, '..'); - const configs = discoverCoverageConfigs(repositoryRoot); - const vitestPaths = []; - for await (const path of glob('packages/**/vitest.config.ts', { cwd: repositoryRoot })) { - vitestPaths.push(path); - } - vitestPaths.sort(); - - assert.equal(vitestPaths.length, 68); - assert.deepEqual( - configs.map(({ configPath }) => relative(repositoryRoot, configPath)), - vitestPaths.map((path) => path.replace('vitest.config.ts', 'coverage.config.json')), - ); - for (const path of vitestPaths) { - const source = await readFile(join(repositoryRoot, path), 'utf8'); - assert.doesNotMatch(source, /\bcoverage\s*:/); - } - }); - it('runs package coverage once from the root without package entry points', async () => { const repositoryRoot = join(import.meta.dirname, '..'); const rootManifest = JSON.parse(await readFile(join(repositoryRoot, 'package.json'), 'utf8'));