diff --git a/packages/grpc/src/__tests__/glue-action-refresh.test.ts b/packages/grpc/src/__tests__/glue-action-refresh.test.ts index 210057e95..3b147b4cb 100644 --- a/packages/grpc/src/__tests__/glue-action-refresh.test.ts +++ b/packages/grpc/src/__tests__/glue-action-refresh.test.ts @@ -17,7 +17,7 @@ */ import { afterAll, afterEach, beforeAll, describe, expect, it, mock } from 'bun:test'; -import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs'; +import { mkdirSync, writeFileSync } from 'node:fs'; import { createServer, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; import { dirname, join } from 'node:path'; @@ -29,7 +29,7 @@ import { } from '../../fixtures/zone/src/generated/grpc/orders/v1/orders_pb'; import { generateFiles } from '../generate'; import * as runtime from '../runtime'; -import { emptyDir, FIXTURE_ZONE, PKG_DIR, removeScratchDirs } from './helpers'; +import { emptyDir, FIXTURE_ZONE, linkWorkspaceRuntime, removeScratchDirs } from './helpers'; const TAG = 'knext:orders:orders.v1.OrdersService'; const AUTHORIZE = Symbol.for('knext.test.glueActionRefresh.authorize'); @@ -98,7 +98,7 @@ beforeAll(async () => { } `, ); - symlinkSync(join(PKG_DIR, 'node_modules'), join(dir, 'node_modules')); + linkWorkspaceRuntime(dir); actions = await import( join(dir, 'src', 'generated', 'grpc', 'orders', 'v1', 'orders_knext_actions.ts') ); diff --git a/packages/grpc/src/__tests__/glue-runtime-link-guard.test.ts b/packages/grpc/src/__tests__/glue-runtime-link-guard.test.ts new file mode 100644 index 000000000..dbf6768ef --- /dev/null +++ b/packages/grpc/src/__tests__/glue-runtime-link-guard.test.ts @@ -0,0 +1,43 @@ +/** + * Guard: a spec that mocks `@getknext/grpc/runtime` AND imports generated glue + * from a scratch dir must make that specifier resolve with + * `linkWorkspaceRuntime`. A bare `mock.module` passes only while + * `packages/grpc/dist` is absent (Bun keys the mock to the dist path once it + * exists), so the spec's result would depend on which test built dist first. + * Scans the specs, so a new one cannot forget the link. + */ + +import { describe, expect, it } from 'bun:test'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const DIR = import.meta.dir; +const MOCKS_RUNTIME = /mock\.module\(\s*['"]@getknext\/grpc\/runtime['"]/; +const SCRATCH = /\b(emptyDir|copyFixtureZone|mkdtempSync)\(/; + +function violations(files: Record): string[] { + return Object.entries(files) + .filter( + ([, src]) => + MOCKS_RUNTIME.test(src) && SCRATCH.test(src) && !/\blinkWorkspaceRuntime\(/.test(src), + ) + .map(([name]) => name); +} + +describe('scratch glue specs link the workspace runtime', () => { + it('flags a spec that mocks the runtime in a scratch dir without the link', () => { + const bad = `mock.module('@getknext/grpc/runtime', () => r);\nconst d = emptyDir('x');`; + expect(violations({ 'bad.test.ts': bad })).toEqual(['bad.test.ts']); + expect(violations({ 'ok.test.ts': `${bad}\nlinkWorkspaceRuntime(d);` })).toEqual([]); + }); + + it('every spec in this directory obeys it', () => { + const files: Record = {}; + for (const f of readdirSync(DIR).filter((n) => n.endsWith('.test.ts'))) { + if (f === 'glue-runtime-link-guard.test.ts') continue; + files[f] = readFileSync(join(DIR, f), 'utf8'); + } + expect(Object.keys(files).length).toBeGreaterThan(10); + expect(violations(files)).toEqual([]); + }); +}); diff --git a/packages/grpc/src/__tests__/helpers.ts b/packages/grpc/src/__tests__/helpers.ts index 8e6ee7b8f..73c193a2a 100644 --- a/packages/grpc/src/__tests__/helpers.ts +++ b/packages/grpc/src/__tests__/helpers.ts @@ -5,7 +5,17 @@ * file calls {@link removeScratchDirs} from its `afterAll`. */ -import { cpSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { + cpSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join, relative, resolve } from 'node:path'; @@ -27,6 +37,36 @@ export function removeScratchDirs(): void { scratchDirs.length = 0; } +/** + * Make `@getknext/grpc/runtime` RESOLVE from a scratch zone outside the repo. + * `mock.module` alone is order-dependent: Bun keys the mock under the bare name + * only while `packages/grpc/dist` is absent; once a build has left dist on disk + * it keys the mock to the dist path and a bare import from /tmp misses it. + */ +export function linkWorkspaceRuntime(dir: string): void { + const modules = join(dir, 'node_modules'); + mkdirSync(modules, { recursive: true }); + const deps = join(PKG_DIR, 'node_modules'); + for (const entry of readdirSync(deps)) { + if (entry === '.bin' || entry === '@getknext') continue; + symlinkSync(join(deps, entry), join(modules, entry)); + } + const stub = join(modules, '@getknext', 'grpc'); + mkdirSync(stub, { recursive: true }); + writeFileSync( + join(stub, 'package.json'), + JSON.stringify({ + name: '@getknext/grpc', + type: 'module', + exports: { './runtime': './runtime.ts' }, + }), + ); + writeFileSync( + join(stub, 'runtime.ts'), + `export * from ${JSON.stringify(join(PKG_DIR, 'src', 'runtime'))};\n`, + ); +} + /** A fresh copy of the fixture zone (proto + config + committed output). */ export function copyFixtureZone(prefix: string): string { const dir = emptyDir(prefix); diff --git a/packages/grpc/src/__tests__/zone-signer-glue.test.ts b/packages/grpc/src/__tests__/zone-signer-glue.test.ts index 4607b5c00..6135d98fa 100644 --- a/packages/grpc/src/__tests__/zone-signer-glue.test.ts +++ b/packages/grpc/src/__tests__/zone-signer-glue.test.ts @@ -26,7 +26,7 @@ import { CatalogService } from '../../fixtures/zone/src/generated/grpc/orders/v1 import { OrdersService } from '../../fixtures/zone/src/generated/grpc/orders/v1/orders_pb'; import { generateFiles } from '../generate'; import * as runtime from '../runtime'; -import { emptyDir, FIXTURE_ZONE, PKG_DIR, removeScratchDirs } from './helpers'; +import { emptyDir, FIXTURE_ZONE, linkWorkspaceRuntime, removeScratchDirs } from './helpers'; const ISSUER = 'knext:zone:shop/storefront'; const AUDIENCE = 'knext:fn:shop/orders'; @@ -168,7 +168,12 @@ beforeAll(async () => { } `, ); - symlinkSync(join(PKG_DIR, 'node_modules'), join(dir, 'node_modules')); + // The glue imports `@getknext/grpc/runtime`. `mock.module` alone is not enough + // from a scratch dir outside the repo: Bun keys the mock under the bare name + // only while `packages/grpc/dist` is absent; once any earlier test has built + // dist it keys the mock to the dist path and the bare import misses it. Link + // the workspace runtime so the import resolves for real either way. + linkWorkspaceRuntime(dir); const out = join(dir, 'src', 'generated', 'grpc', 'orders', 'v1'); server_ = await import(join(out, 'orders_knext_server.ts')); actions = await import(join(out, 'orders_knext_actions.ts'));