diff --git a/docs/guide/auth.md b/docs/guide/auth.md index e66866cf2..fac1aa526 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -186,7 +186,7 @@ For the full set of available options, see the official [Better Auth options ref ## Database and Migrations -Auth tables live alongside your app's tables. During local development, Void creates them automatically. +Auth tables live alongside your app's tables. During local development, Void creates them automatically. While the dev server runs, saving `auth.ts`, or a local file it imports, updates the auth schema Void generates for your app. New auth tables and columns are created in the local database the next time the dev server starts. Void platforms create auth tables automatically. For a direct Cloudflare deploy, include them in your checked-in migrations: diff --git a/packages/void/src/auth/config.ts b/packages/void/src/auth/config.ts index c18d64339..181da39d7 100644 --- a/packages/void/src/auth/config.ts +++ b/packages/void/src/auth/config.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; -import { parseSync } from 'vite'; +import { isRunnableDevEnvironment, parseSync, type ViteDevServer } from 'vite'; import { readProjectPaths, type ProjectPaths } from '../project-paths.ts'; import type { VoidAuthConfig } from '../runtime/auth.ts'; import type { AstNode, AstProgram } from '../shared/ast.ts'; @@ -43,12 +43,36 @@ export function findVoidAuthConfig( return null; } +/** + * The dev server evaluates the auth config in this Node environment. A native + * `import()` would keep the first evaluation for the life of the process; the + * module runner reads the config again once its files are invalidated, and the + * module graph lists the local files the config imports. + */ +export const AUTH_CONFIG_ENVIRONMENT = 'void_auth_config'; + +export type ModuleImporter = (path: string) => Promise>; + +/** The dev server's importer for the auth config, when it has the environment. */ +export function devAuthConfigImporter(server: ViteDevServer): ModuleImporter | undefined { + const environment = server.environments[AUTH_CONFIG_ENVIRONMENT]; + return environment && isRunnableDevEnvironment(environment) + ? (path) => environment.runner.import(path) + : undefined; +} + +/** + * Evaluate the auth config. A native `import()` keeps its first evaluation for + * the life of the process, which suits one-off commands; the dev server passes + * its module runner. + */ export async function loadVoidAuthConfig( configPath: string | null, + importModule: ModuleImporter = (path) => import(pathToFileURL(path).href), ): Promise { if (!configPath) { return undefined; } - const mod = await import(pathToFileURL(configPath).href); + const mod = await importModule(configPath); return (mod.default ?? mod.auth ?? mod.config) as VoidAuthConfig | undefined; } diff --git a/packages/void/src/auth/node.ts b/packages/void/src/auth/node.ts index 161519fc1..c739401ec 100644 --- a/packages/void/src/auth/node.ts +++ b/packages/void/src/auth/node.ts @@ -11,7 +11,7 @@ import { } from '../runtime/auth-migrations.ts'; import type { VoidAuthDialect, VoidAuthEnv } from '../runtime/better-auth-shared.ts'; import { loadLocalEnv } from '../shared/env.ts'; -import { findVoidAuthConfig, loadVoidAuthConfig } from './config.ts'; +import { findVoidAuthConfig, loadVoidAuthConfig, type ModuleImporter } from './config.ts'; import { resolveVoidAuthMigrationSchema } from './drizzle.ts'; export async function runLocalVoidAuthMigrations( @@ -25,8 +25,10 @@ export async function runLocalVoidAuthMigrations( * shell value counts as unset. */ databaseUrl?: string, + /** The dev server's runner, so a restarted server sees the current config. */ + importModule?: ModuleImporter, ): Promise { - const authConfig = await loadVoidAuthConfig(configPath); + const authConfig = await loadVoidAuthConfig(configPath, importModule); const runtime = { dialect, providers: readConfig(root).auth?.providers ?? null, diff --git a/packages/void/src/plugin-migration.ts b/packages/void/src/plugin-migration.ts index 220feb1b2..4138f3d67 100644 --- a/packages/void/src/plugin-migration.ts +++ b/packages/void/src/plugin-migration.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs'; import { join } from 'pathslash'; import type { Plugin } from 'vite'; +import { devAuthConfigImporter } from './auth/config.ts'; import type { DatabaseDialect } from './config.ts'; import { voidLog, voidError } from './log.ts'; import { afterServerReady } from './plugin-dev-config.ts'; @@ -52,6 +53,7 @@ export function migrationPlugin( databaseId, auth?.configPath, databaseUrl, + devAuthConfigImporter(server), ); }; // MySQL runs Better Auth's migrations under the migration lock of the database they change. diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index f7050ce46..40858dc3d 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -2,7 +2,14 @@ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { dirname, join, relative, resolve } from 'pathslash'; -import { normalizePath, type Plugin } from 'vite'; +import { + createRunnableDevEnvironment, + isRunnableDevEnvironment, + normalizePath, + type Plugin, + type ResolvedConfig, + type RunnableDevEnvironment, +} from 'vite'; import type { ApplicationAnalysis } from './application-analysis.ts'; import { completeApplicationAnalysis } from './application-routing.ts'; import { @@ -16,7 +23,7 @@ import { isGeneratedAssetHeaders, resolveAssetWorkerBypass, } from './asset-worker-bypass.ts'; -import { loadVoidAuthConfig } from './auth/config.ts'; +import { AUTH_CONFIG_ENVIRONMENT, loadVoidAuthConfig } from './auth/config.ts'; import { resolveDistDir, resolveWorkerDirName } from './cli/build-output-dir.ts'; import { lintRoutingPhases, mergeRoutingRules } from './cli/headers.ts'; import { stampClientAssetCachePolicy } from './client-asset-manifest.ts'; @@ -144,6 +151,10 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug const pagesDir = paths.pagesDir; const ssrDir = paths.ssrDir; const authConfigPaths = new Set(paths.authConfigCandidates.map((path) => normalizePath(path))); + let authConfigEnvironment: RunnableDevEnvironment | undefined; + // Set when a file the auth config loaded changes, or a load fails, so the + // next load starts from an empty module graph. + let authConfigStale = false; const envFilePaths = new Set(paths.envFileCandidates.map((path) => normalizePath(path))); const normalizedRoutesDir = normalizePath(routesDir); const normalizedMiddlewareDir = normalizePath(middlewareDir); @@ -318,6 +329,26 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug return parts.join('|'); } + async function loadAuthConfig(configPath: string | null) { + const environment = authConfigEnvironment; + if (authConfigStale && environment) { + // The runner checks each import against the graph. Invalidate all of + // it: a failed import stays in it under the specifier it could not + // resolve, not under a file path. + environment.moduleGraph.invalidateAll(); + } + authConfigStale = false; + try { + return await loadVoidAuthConfig( + configPath, + environment && ((path) => environment.runner.import(path)), + ); + } catch (error) { + authConfigStale = true; + throw error; + } + } + async function writeAuthDatabaseFiles(): Promise { if (!compileOptions.auth) { return; @@ -329,7 +360,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug const authRuntime: VoidAuthRuntimeOptions = { dialect: compileOptions.auth.dialect, providers: compileOptions.auth.providers, - userConfig: await loadVoidAuthConfig(compileOptions.auth.authConfigPath), + userConfig: await loadAuthConfig(compileOptions.auth.authConfigPath), }; const local = resolvedConfig?.command === 'serve' ? loadLocalEnv(root) : {}; const env = { ...local, ...process.env } as Record; @@ -387,7 +418,11 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } function isPotentialAuthConfigPath(path: string): boolean { - return authConfigPaths.has(normalizePath(path)); + const normalized = normalizePath(path); + return ( + authConfigPaths.has(normalized) || + (authConfigEnvironment?.moduleGraph.getModulesByFile(normalized)?.size ?? 0) > 0 + ); } function isPotentialEnvFilePath(path: string): boolean { @@ -603,6 +638,25 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug config(_, env) { previewing = env.isPreview === true; routeEnvironment = env.command === 'serve' && !env.isPreview ? 'dev' : 'prod'; + // Dev only, so builds never see the environment. + const authConfigEnvironmentConfig = + routeEnvironment === 'dev' + ? { + environments: { + [AUTH_CONFIG_ENVIRONMENT]: { + consumer: 'server' as const, + dev: { + createEnvironment: (name: string, config: ResolvedConfig) => + // Void invalidates it on saves; no HMR client to run. + createRunnableDevEnvironment(name, config, { + hot: false, + runnerOptions: { hmr: false }, + }), + }, + }, + }, + } + : {}; compileOptions.hasLive = options.live !== undefined; compileOptions.managedDispatch = !options.target && env.command === 'build' && Boolean(process.env.VOID_DEPLOY_PROJECT_ID); @@ -622,12 +676,12 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } }); - return { resolve: { alias: resolvedAliases } }; + return { ...authConfigEnvironmentConfig, resolve: { alias: resolvedAliases } }; } const entries = validateSsrEntries(root, paths); if (env.command !== 'build') { - return; + return authConfigEnvironmentConfig; } return { @@ -841,13 +895,32 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug }, }, + hotUpdate() { + // Void invalidates this environment itself; Vite's update pass would + // only log a page reload that nothing receives. + if (this.environment.name === AUTH_CONFIG_ENVIRONMENT) { + return []; + } + }, + configureServer(devServer) { + const authEnvironment = devServer.environments[AUTH_CONFIG_ENVIRONMENT]; + authConfigEnvironment = + authEnvironment && isRunnableDevEnvironment(authEnvironment) ? authEnvironment : undefined; + // Watch the source convention directories for file changes const watcher = devServer.watcher; const handleFileChange = async (path: string, event: 'add' | 'change' | 'unlink') => { const normalizedPath = normalizePath(path); invalidateSourceAnalysis(normalizedPath); + const authConfigChanged = isPotentialAuthConfigPath(normalizedPath); + if (authConfigChanged) { + authConfigStale = true; + } + // After a failed load, a new file may be the import that was missing. + const authImportCreated = + event === 'add' && authConfigStale && compileOptions.auth !== null; const sourceConventionChanged = isInDirectory(normalizedPath, normalizedRoutesDir) || @@ -856,7 +929,8 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug isInDirectory(normalizedPath, normalizedQueuesDir) || isInDirectory(normalizedPath, normalizedEmailDir) || isInDirectory(normalizedPath, normalizedPagesDir) || - isPotentialAuthConfigPath(normalizedPath) || + authConfigChanged || + authImportCreated || isPotentialEnvFilePath(normalizedPath); if (!sourceConventionChanged && !ssrEntryAbsPaths.has(normalizedPath)) { return; @@ -869,7 +943,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug // not the client — so we must iterate all environments. const forceRegeneration = event !== 'change' || - isPotentialAuthConfigPath(normalizedPath) || + authConfigChanged || isPotentialEnvFilePath(normalizedPath) || ssrEntryAbsPaths.has(normalizedPath); diff --git a/packages/void/test/unit/auth-config-loader.test.ts b/packages/void/test/unit/auth-config-loader.test.ts new file mode 100644 index 000000000..5b89442b6 --- /dev/null +++ b/packages/void/test/unit/auth-config-loader.test.ts @@ -0,0 +1,84 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRunnableDevEnvironment, createServer } from 'vite'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + AUTH_CONFIG_ENVIRONMENT, + devAuthConfigImporter, + loadVoidAuthConfig, +} from '../../src/auth/config.ts'; + +const tempDirs: Array = []; + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'void-auth-config-')); + tempDirs.push(dir); + return dir; +} + +describe('loadVoidAuthConfig', () => { + afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }); + } + }); + + it('returns undefined without a config file', async () => { + const importModule = vi.fn(); + expect(await loadVoidAuthConfig(null, importModule)).toBeUndefined(); + expect(importModule).not.toHaveBeenCalled(); + }); + + it.each([ + ['default', "export default { marker: 'default' };", 'default'], + ['auth', "export const auth = { marker: 'auth' };", 'auth'], + ['config', "export const config = { marker: 'config' };", 'config'], + ])('reads the %s export', async (_name, source, marker) => { + const path = join(createTempDir(), 'auth.ts'); + writeFileSync(path, source); + expect(await loadVoidAuthConfig(path)).toEqual({ marker }); + }); + + it('evaluates the config through the module importer it is given', async () => { + const importModule = vi.fn(async () => ({ auth: { marker: 'runner' } })); + expect(await loadVoidAuthConfig('/app/auth.ts', importModule)).toEqual({ marker: 'runner' }); + expect(importModule).toHaveBeenCalledWith('/app/auth.ts'); + }); + + it("imports through the dev server's auth config environment when it has one", async () => { + const dir = createTempDir(); + const path = join(dir, 'auth.ts'); + writeFileSync(path, "export default { marker: 'first' };"); + const server = await createServer({ + root: dir, + configFile: false, + logLevel: 'silent', + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, watch: null }, + environments: { + [AUTH_CONFIG_ENVIRONMENT]: { + consumer: 'server', + dev: { createEnvironment: (name, config) => createRunnableDevEnvironment(name, config) }, + }, + }, + }); + try { + const importModule = devAuthConfigImporter(server)!; + expect(await loadVoidAuthConfig(path, importModule)).toEqual({ marker: 'first' }); + } finally { + await server.close(); + } + const plain = await createServer({ + root: dir, + configFile: false, + logLevel: 'silent', + server: { middlewareMode: true, ws: false, watch: null }, + }); + try { + expect(devAuthConfigImporter(plain)).toBeUndefined(); + } finally { + await plain.close(); + } + }); +}); diff --git a/packages/void/test/unit/local-d1.test.ts b/packages/void/test/unit/local-d1.test.ts index 2538155cd..0a8099786 100644 --- a/packages/void/test/unit/local-d1.test.ts +++ b/packages/void/test/unit/local-d1.test.ts @@ -41,7 +41,7 @@ async function runGeneratedMigrationPlugin(plugins: ReturnType name === 'void:migrations'); expect(plugin).toBeTruthy(); const configureServer = hookHandler(plugin?.configureServer); - await (configureServer as (server: unknown) => Promise).call({}, {}); + await (configureServer as (server: unknown) => Promise).call({}, { environments: {} }); } afterEach(() => { diff --git a/packages/void/test/unit/plugin-migration.test.ts b/packages/void/test/unit/plugin-migration.test.ts index 6c0a7cb5d..094a8be9f 100644 --- a/packages/void/test/unit/plugin-migration.test.ts +++ b/packages/void/test/unit/plugin-migration.test.ts @@ -40,10 +40,17 @@ vi.mock('mysql2/promise', () => ({ })); const authFailure = vi.hoisted(() => ({ error: undefined as Error | undefined })); +const authImporters = vi.hoisted((): Array => []); + +vi.mock('../../src/auth/config.ts', async (importOriginal) => ({ + ...(await importOriginal()), + devAuthConfigImporter: (server: { authImporter?: unknown }) => server.authImporter, +})); vi.mock('../../src/auth/node.ts', () => ({ runLocalVoidAuthMigrations: async (...args: Array) => { mysqlQueries.push('Better Auth migrations'); + authImporters.push(args[6]); const databaseUrl = args[5]; mysqlSteps.push( `auth ${typeof databaseUrl === 'string' ? new URL(databaseUrl).pathname.slice(1) : '(no URL)'}`, @@ -78,16 +85,19 @@ afterEach(() => { } }); -async function runConfigureServer(plugin: ReturnType) { +async function runConfigureServer( + plugin: ReturnType, + server: Record = { environments: {} }, +) { const hook = plugin.configureServer as ConfigureServerHook | undefined; if (!hook) { return; } if (typeof hook === 'function') { - await hook.call({}, {}); + await hook.call({}, server); return; } - await hook.handler.call({}, {}); + await hook.handler.call({}, server); } describe('migrationPlugin persistence roots', () => { @@ -113,6 +123,24 @@ describe('migrationPlugin persistence roots', () => { }); }); +describe('migrationPlugin Better Auth config', () => { + it("loads the auth config through the dev server's runner, so a restarted server reads it again", async () => { + const root = createProjectRoot(); + const authImporter = vi.fn(); + authImporters.length = 0; + + await runConfigureServer( + migrationPlugin(root, 'sqlite', 'local', join(root, '.void'), { + enabled: true, + configPath: null, + }), + { environments: {}, authImporter }, + ); + + expect(authImporters).toEqual([authImporter]); + }); +}); + describe('migrationPlugin with MySQL and Better Auth', () => { // Better Auth reads the schema, then adds what is missing, so two runs at once both add it. // An app without migration files reads DATABASE_URL like Better Auth: the shell before .env. diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index 975b73bfd..94cd85282 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -1,7 +1,21 @@ -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import type { ConfigEnv, ResolvedConfig, UserConfig } from 'vite'; +import { dirname, join } from 'node:path'; +import { + createServer, + type ConfigEnv, + type ResolvedConfig, + type RunnableDevEnvironment, + type UserConfig, +} from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { analyzeApplicationSync } from '../../src/application-analysis.ts'; import { GENERATED_ASSET_HEADERS_MARKER } from '../../src/asset-worker-bypass.ts'; @@ -61,7 +75,10 @@ async function runBuildStart(plugin: RoutingPlugin): Promise { await (getHookHandler(plugin.buildStart) as () => Promise).call({} as never); } -function fakeDevServer(moduleIds: Array = []) { +function fakeDevServer( + moduleIds: Array = [], + extraEnvironments: Record = {}, +) { const handlers = new Map Promise | void>>(); const invalidated: Array = []; const restart = vi.fn(async () => {}); @@ -75,6 +92,7 @@ function fakeDevServer(moduleIds: Array = []) { }, }, environments: { + ...extraEnvironments, void_worker: { moduleGraph: { getModuleById(id: string) { @@ -97,8 +115,12 @@ function fakeDevServer(moduleIds: Array = []) { return { server, invalidated, restart, emit, logError }; } -function startDevServer(plugin: RoutingPlugin, moduleIds?: Array) { - const dev = fakeDevServer(moduleIds); +function startDevServer( + plugin: RoutingPlugin, + moduleIds?: Array, + extraEnvironments?: Record, +) { + const dev = fakeDevServer(moduleIds, extraEnvironments); (getHookHandler(plugin.configureServer) as (server: unknown) => void)(dev.server); return dev; } @@ -678,6 +700,120 @@ describe('routingPlugin', () => { }, ); + it.each([ + { command: 'serve' as const, isPreview: false, declared: true }, + { command: 'serve' as const, isPreview: true, declared: false }, + { command: 'build' as const, isPreview: false, declared: false }, + ])( + 'declares the auth config environment for $command (preview $isPreview): $declared', + ({ command, isPreview, declared }) => { + const plugin = createRoutingPlugin(createTempRoot(), { isAuthEnabled: () => false }); + const config = ( + getHookHandler(plugin.config) as (config: UserConfig, env: ConfigEnv) => UserConfig | void + )({}, { command, mode: 'development', isPreview }); + expect(Object.keys(config?.environments ?? {})).toEqual(declared ? ['void_auth_config'] : []); + }, + ); + + it('leaves the auth config environment out of Vite hot updates', () => { + const plugin = createRoutingPlugin(createTempRoot(), { isAuthEnabled: () => true }); + const hotUpdate = getHookHandler(plugin.hotUpdate) as (this: { + environment: { name: string }; + }) => unknown; + expect(hotUpdate.call({ environment: { name: 'void_auth_config' } })).toEqual([]); + expect(hotUpdate.call({ environment: { name: 'void_worker' } })).toBeUndefined(); + }); + + it('regenerates auth files when the auth config or a file it imports changes', async () => { + // Vite's module graph keys files by real path, as the watcher reports them for a real root; + // `.native` also expands Windows short names, as Vite does. + const root = realpathSync.native(createTempRoot()); + const routePath = join(root, 'routes', 'health.ts'); + writeFileSync(routePath, 'export const GET = () => new Response();'); + const authPath = readProjectPaths(root).authConfigCandidates[0]!; + const helperPath = join(dirname(authPath), 'auth-fields.ts'); + const extraPath = join(dirname(authPath), 'auth-extra.ts'); + // The type-only export makes the file an auth config without loading void/auth. + const authSource = (imports: string, fields: string) => + "export type {} from 'void/auth';\n" + + imports + + '\nexport default ({ defaults }) => ({ ...defaults, user: { additionalFields: { ' + + fields + + ' } } });'; + const helperImport = "import { field } from './auth-fields.ts';"; + writeFileSync(helperPath, "export const field = 'nickname';"); + writeFileSync(authPath, authSource(helperImport, "[field]: { type: 'string' }")); + + const plugin = createRoutingPlugin(root, { isAuthEnabled: () => true }); + // A real dev server, so Vite runs its own watcher handling for the auth + // environment; events are sent by hand. + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + plugin, + // Holds back Vite's own handling of each save, so recovery cannot rely on it. + { + name: 'slow-watch-change', + async watchChange() { + await new Promise((resolve) => setTimeout(resolve, 100)); + }, + }, + ], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, watch: null, preTransformRequests: false }, + }); + try { + const logError = vi.spyOn(server.config.logger, 'error'); + const environment = server.environments.void_auth_config as RunnableDevEnvironment; + const imports = vi.spyOn(environment.runner, 'import'); + const schemaPath = join(root, '.void', 'better-auth-schema.ts'); + const schema = () => readFileSync(schemaPath, 'utf8'); + const save = (event: 'add' | 'change', path: string, source: string) => { + writeFileSync(path, source); + server.watcher.emit(event, path); + }; + await vi.waitFor(() => expect(schema()).toContain('"nickname"')); + + save( + 'change', + authPath, + authSource(helperImport, "[field]: { type: 'string' }, bio: { type: 'string' }"), + ); + await vi.waitFor(() => expect(schema()).toContain('"bio"')); + + save('change', helperPath, "export const field = 'displayName';"); + await vi.waitFor(() => expect(schema()).toContain('"displayName"')); + expect(schema()).not.toContain('"nickname"'); + + // A save outside the config's files does not load it again. + const loads = imports.mock.calls.length; + writeFileSync(routePath, 'export const GET = () => new Response("updated");'); + server.watcher.emit('change', routePath); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(imports).toHaveBeenCalledTimes(loads); + + // A syntax error in a helper is reported, and fixing it regenerates. + save('change', helperPath, 'export const field ='); + await vi.waitFor(() => expect(logError).toHaveBeenCalledTimes(1)); + save('change', helperPath, "export const field = 'avatar';"); + await vi.waitFor(() => expect(schema()).toContain('"avatar"')); + + // A load that fails on a missing file retries once the file is created. + save( + 'change', + authPath, + authSource("import { extra } from './auth-extra.ts';", "[extra]: { type: 'string' }"), + ); + await vi.waitFor(() => expect(logError).toHaveBeenCalledTimes(2)); + save('add', extraPath, "export const extra = 'created';"); + await vi.waitFor(() => expect(schema()).toContain('"created"')); + } finally { + await server.close(); + } + }); + it('rescans sourceDir route changes during dev', async () => { const root = createBareTempRoot(); writeFileSync(join(root, 'void.json'), JSON.stringify({ sourceDir: 'src' })); diff --git a/platform/packages/api/test/integration/platform-status.test.ts b/platform/packages/api/test/integration/platform-status.test.ts index 829015590..30d1ff38b 100644 --- a/platform/packages/api/test/integration/platform-status.test.ts +++ b/platform/packages/api/test/integration/platform-status.test.ts @@ -326,4 +326,6 @@ it.each([390, 1280])( await browser.close(); } }, + // Launching Chromium alone can take longer than the default under a full parallel run. + 30_000, );