diff --git a/apps/zbugs/src/local-mutate.test.ts b/apps/zbugs/src/local-mutate.test.ts new file mode 100644 index 0000000000..83698081b1 --- /dev/null +++ b/apps/zbugs/src/local-mutate.test.ts @@ -0,0 +1,90 @@ +import {expect, test, vi, beforeEach, afterEach} from 'vitest'; +import {zeroForTest} from '@rocicorp/zero/testing'; +import {builder, schema} from '../shared/schema.ts'; +import {mutators} from '../shared/mutators.ts'; +import {defineMutator, defineMutators} from '@rocicorp/zero'; +import {z} from 'zod/mini'; + +const userSchema = z.object({ + id: z.string(), + login: z.string(), + name: z.string(), + role: z.union([z.literal('user'), z.literal('crew')]), +}); + +// Use the merge overload to inherit context type from the base mutators +const mutatorsForTest = defineMutators(mutators, { + user: { + create: defineMutator(userSchema, async ({tx, args}) => { + await tx.mutate.user.insert({ + id: args.id, + login: args.login, + name: args.name, + role: args.role, + avatar: '', + }); + }), + }, +}); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-15T12:00:00Z')); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +test('local mutate', async () => { + const zero = zeroForTest({ + cacheURL: null, + kvStore: 'mem', + schema, + mutators: mutatorsForTest, + userID: 'user-1', + // oxlint-disable-next-line no-explicit-any + context: {sub: 'user-1', role: 'user'} as any, + }); + + await zero.mutate( + mutatorsForTest.user.create({ + id: 'user-1', + login: 'holden', + name: 'James Holden', + role: 'user', + }), + ).client; + + await zero.mutate( + mutatorsForTest.issue.create({ + id: 'issue-1', + title: 'Test Issue', + description: 'This is a test issue', + projectID: 'project-1', + created: Date.now(), + modified: Date.now(), + }), + ).client; + + const issues = await zero.run(builder.issue); + + expect(issues).toMatchInlineSnapshot(` + [ + { + "assigneeID": null, + "created": 1736942400000, + "creatorID": "user-1", + "description": "This is a test issue", + "id": "issue-1", + "modified": 1736942400000, + "open": true, + "projectID": "project-1", + "shortID": null, + "title": "Test Issue", + "visibility": "public", + Symbol(rc): 1, + }, + ] + `); +}); diff --git a/apps/zbugs/tsconfig.json b/apps/zbugs/tsconfig.json index 2a9b2b2711..c4ebea037e 100644 --- a/apps/zbugs/tsconfig.json +++ b/apps/zbugs/tsconfig.json @@ -4,7 +4,8 @@ "jsx": "react-jsx", "paths": { "@rocicorp/zero": ["../../packages/zero/src/zero.ts"], - "@rocicorp/zero/react": ["../../packages/zero/src/react.ts"] + "@rocicorp/zero/react": ["../../packages/zero/src/react.ts"], + "@rocicorp/zero/testing": ["../../packages/zero/src/testing.ts"] }, "declaration": true }, diff --git a/apps/zbugs/vitest.config.ts b/apps/zbugs/vitest.config.ts index 1f2c5a943a..94d194e70d 100644 --- a/apps/zbugs/vitest.config.ts +++ b/apps/zbugs/vitest.config.ts @@ -19,6 +19,10 @@ export function configForVersion(version: number, url: string) { plugins: [tsconfigPaths()], resolve: { alias: [ + { + find: '@rocicorp/zero/testing', + replacement: resolve(packagesDir, 'zero/src/testing.ts'), + }, { find: '@rocicorp/zero', replacement: resolve(packagesDir, 'zero/src/zero.ts'), @@ -47,7 +51,9 @@ export function configForVersion(version: number, url: string) { export function configForNoPg(url: string) { const name = nameFromURL(url); + return mergeConfig(config, { + plugins: [tsconfigPaths()], test: { name: `${name}/no-pg`, browser: {enabled: false}, diff --git a/packages/zero/package.json b/packages/zero/package.json index 69415f7af1..204aec6f9b 100644 --- a/packages/zero/package.json +++ b/packages/zero/package.json @@ -175,6 +175,10 @@ "types": "./out/zero/src/sqlite.d.ts", "default": "./out/zero/src/sqlite.js" }, + "./testing": { + "types": "./out/zero/src/testing.d.ts", + "default": "./out/zero/src/testing.js" + }, "./zqlite": { "types": "./out/zero/src/zqlite.d.ts", "default": "./out/zero/src/zqlite.js" diff --git a/packages/zero/src/testing.ts b/packages/zero/src/testing.ts new file mode 100644 index 0000000000..c7e0578e8b --- /dev/null +++ b/packages/zero/src/testing.ts @@ -0,0 +1 @@ +export {zeroForTest} from '../../zero-client/src/client/test-utils.ts'; diff --git a/packages/zero/tool/build.ts b/packages/zero/tool/build.ts index 771216b618..8715e9326f 100644 --- a/packages/zero/tool/build.ts +++ b/packages/zero/tool/build.ts @@ -31,6 +31,11 @@ const define = { 'process.env.DISABLE_MUTATION_RECOVERY': 'true', }; +const testingDefine = { + ...define, + TESTING: 'true', +}; + // Vite config helper functions async function getPackageJSON() { const content = await readFile(resolve('package.json'), 'utf-8'); @@ -53,10 +58,14 @@ function extractOutPath(path: string): string | undefined { function extractEntries( entries: Record, getEntryName: (key: string, outPath: string) => string, + excludeKeys: Set = new Set(), ): Record { const entryPoints: Record = {}; for (const [key, value] of Object.entries(entries)) { + if (excludeKeys.has(key)) { + continue; + } const path = typeof value === 'string' ? value : (value as {default?: string}).default; @@ -94,18 +103,37 @@ function getWorkerEntryPoints(): Record { return entryPoints; } +// Entry points that need TESTING=true +const testingExports = new Set(['./testing']); + async function getAllEntryPoints(): Promise> { const packageJSON = await getPackageJSON(); return { - ...extractEntries(packageJSON.exports ?? {}, (key, outPath) => - key === '.' ? 'zero/src/zero' : outPath, + ...extractEntries( + packageJSON.exports ?? {}, + (key, outPath) => (key === '.' ? 'zero/src/zero' : outPath), + testingExports, ), ...extractEntries(packageJSON.bin ?? {}, (_, outPath) => outPath), ...getWorkerEntryPoints(), }; } +async function getTestingEntryPoints(): Promise> { + const packageJSON = await getPackageJSON(); + + return extractEntries( + packageJSON.exports ?? {}, + (key, outPath) => (key === '.' ? 'zero/src/zero' : outPath), + new Set( + Object.keys(packageJSON.exports ?? {}).filter( + k => !testingExports.has(k), + ), + ), + ); +} + const baseConfig: InlineConfig = { configFile: false, logLevel: 'warn', @@ -143,6 +171,58 @@ async function getViteConfig(): Promise { }; } +// Modules that should be kept external in the testing build to share +// symbol instances with the main bundle +const testingExternalModules = ['query-internals']; + +async function getTestingViteConfig(): Promise { + return { + ...baseConfig, + define: testingDefine, + build: { + ...baseConfig.build, + rollupOptions: { + external: (id: string) => { + // Check standard externals + if (external.some(ext => id === ext || id.startsWith(ext + '/'))) { + return true; + } + // Keep certain modules external so testing bundle shares symbols + // with the main bundle (e.g., queryInternalsTag) + return testingExternalModules.some(mod => id.includes(mod)); + }, + input: await getTestingEntryPoints(), + output: { + format: 'es', + entryFileNames: '[name].js', + chunkFileNames: 'chunks/[name]-[hash].js', + // Don't preserve modules for testing - bundle dependencies so they + // get the TESTING=true define + preserveModules: false, + // Rewrite external module paths to point to the built output + paths: (id: string) => { + for (const mod of testingExternalModules) { + if (id.includes(mod)) { + // Convert absolute path to relative path from testing.js output + // testing.js is at out/zero/src/testing.js + // query-internals.js is at out/zql/src/query/query-internals.js + // Correct relative path: ../../zql/src/query/query-internals.js + // Vite prepends ../../ for output at zero/src/, so we just return + // the package-relative path + const match = id.match(/packages\/([^/]+\/src\/.+)\.ts$/); + if (match) { + return `${match[1]}.js`; + } + } + } + return id; + }, + }, + }, + }, + }; +} + // Bundle size dashboard config: single entry, no code splitting, minified // Uses esbuild's dropLabels to strip BUNDLE_SIZE labeled code blocks const bundleSizeConfig: InlineConfig = { @@ -233,8 +313,11 @@ async function build() { // Watch mode: run vite and tsc in watch mode const viteConfig = await getViteConfig(); viteConfig.build = {...viteConfig.build, watch: {}}; + const testingConfig = await getTestingViteConfig(); + testingConfig.build = {...testingConfig.build, watch: {}}; await Promise.all([ runViteBuild(viteConfig, 'vite build (watch)'), + runViteBuild(testingConfig, 'vite build (testing watch)'), exec( 'tsc -p tsconfig.client.json --watch --preserveWatchOutput', 'client dts (watch)', @@ -247,8 +330,10 @@ async function build() { } else { // Normal build: use inline vite config + type declarations const viteConfig = await getViteConfig(); + const testingConfig = await getTestingViteConfig(); await Promise.all([ runViteBuild(viteConfig, 'vite build'), + runViteBuild(testingConfig, 'vite build (testing)'), exec('tsc -p tsconfig.client.json', 'client dts'), exec('tsc -p tsconfig.server.json', 'server dts'), ]); diff --git a/packages/zero/tsconfig.client.json b/packages/zero/tsconfig.client.json index 2f5adb7c0f..889cd3d77c 100644 --- a/packages/zero/tsconfig.client.json +++ b/packages/zero/tsconfig.client.json @@ -8,6 +8,7 @@ "src/react-native.ts", "src/sqlite.ts", "src/expo-sqlite.ts", - "src/op-sqlite.ts" + "src/op-sqlite.ts", + "src/testing.ts" ] }