diff --git a/docs/developer/development.md b/docs/developer/development.md index 42152e3a..b45f6da6 100644 --- a/docs/developer/development.md +++ b/docs/developer/development.md @@ -144,21 +144,29 @@ npm run dev npm run dev -- --instance dev ``` -The loop watches maintained application sources and brand assets, coalesces -rapid edits, and runs the same complete build and staged-layout verifier. A -successful build asks only the addressed process to quit through Electron's -normal shutdown lifecycle, waits for its durability barrier to finish, then -launches the same checkout, state root, name, and icon. It prints `ready` only -after the replacement publishes its healthy local service. A failed build or -startup prints a concise diagnostic, leaves the watcher active, and tries again -after the next relevant edit. Replacement windows appear without activating -Markover, so rebuilds do not take focus from the application currently in use; -click Markover when the replacement is ready to resume interactive QA. -Generated output, dependency directories, Git -metadata, and instance state do not trigger rebuilds. Keep only one loop per -instance and use `npm start` for deterministic one-shot work. End the loop with -Ctrl-C; it asks the addressed instance to quit through the same managed -shutdown path and waits for that process before returning. +The loop performs one complete build and addressed-bundle preparation when it +starts. If the selected instance is already running under this live loop, the +new watcher attaches to it; an older non-live instance is replaced once so it +can load the development renderer safely. + +After that startup, CSS, HTML, renderer, preload, and renderer-only dependency +edits build into a separate worktree-local renderer directory. The directory is +published only after every asset succeeds, then the existing Electron process +reloads the existing `BrowserWindow`. The native window is never closed or +recreated, so its size, position, visibility, and focus remain unchanged. A +failed renderer build leaves the displayed renderer and last published assets +untouched, and the next valid edit retries normally. + +An edit used by Electron's main process or local backend prints the message +`restart required` and leaves the running window untouched. Stop and restart the loop +when that change should enter the application; the loop never turns a runtime +edit into an automatic app restart. Watcher implementation updates hand the +running app to the replacement watcher without quitting it. Generated output, +dependency directories, Git metadata, and instance state do not trigger +rebuilds. Keep only one loop per instance and use `npm start` for deterministic +one-shot work. End the loop with Ctrl-C; it asks the addressed instance to quit +through the managed durability path and waits for that process before +returning. ## Development review links diff --git a/eslint.config.js b/eslint.config.js index e73e4ac2..82f82f98 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -25,6 +25,7 @@ const rendererGlobals = { module.exports = defineConfig([ { ignores: [ + '.markover/**', 'build/**', 'dist/**', 'evals/**/results/**', diff --git a/scripts/development-renderer.ts b/scripts/development-renderer.ts new file mode 100644 index 00000000..65a75b76 --- /dev/null +++ b/scripts/development-renderer.ts @@ -0,0 +1,186 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import { + build as esbuild, + type BuildOptions, + type Metafile +} from 'esbuild' + +import { brandAssetNames } from './app-layout' + +const defaultProjectDirectory = path.resolve(__dirname, '../..') + +const staticInputs = [ + 'src/index.html', + 'src/styles.css', + ...brandAssetNames.map((name) => `design/brand/${name}`) +] as const + +export type DevelopmentRendererBuild = ( + options: BuildOptions +) => Promise<{ metafile: Metafile }> + +export interface DevelopmentRendererOptions { + build?: DevelopmentRendererBuild + projectDirectory?: string + publishedDirectory: string +} + +export interface DevelopmentRendererResult { + inputPaths: string[] + publishedDirectory: string +} + +function normalizeProjectPath( + projectDirectory: string, + inputPath: string +): string { + const relative = path.isAbsolute(inputPath) + ? path.relative(projectDirectory, inputPath) + : inputPath + return relative.split(path.sep).join('/').replace(/^\.\//, '') +} + +async function copyFile(source: string, destination: string): Promise { + await fs.mkdir(path.dirname(destination), { recursive: true }) + await fs.copyFile(source, destination) +} + +function errorCode(error: unknown): unknown { + if (error !== null && typeof error === 'object' && 'code' in error) { + return error.code + } + return null +} + +async function movePublishedAside( + publishedDirectory: string, + parent: string, + name: string +): Promise { + const previousDirectory = await fs.mkdtemp( + path.join(parent, `.${name}.previous-`) + ) + await fs.rmdir(previousDirectory) + try { + await fs.rename(publishedDirectory, previousDirectory) + return previousDirectory + } catch (error) { + if (errorCode(error) === 'ENOENT') return null + throw error + } +} + +async function replacePublishedDirectory( + stagedDirectory: string, + publishedDirectory: string +): Promise { + const parent = path.dirname(publishedDirectory) + const name = path.basename(publishedDirectory) + await fs.mkdir(parent, { recursive: true }) + + const previousDirectory = await movePublishedAside( + publishedDirectory, + parent, + name + ) + try { + await fs.rename(stagedDirectory, publishedDirectory) + } catch (error) { + if (previousDirectory !== null) { + await fs.rename(previousDirectory, publishedDirectory) + } + throw error + } + if (previousDirectory !== null) { + await fs.rm(previousDirectory, { recursive: true, force: true }) + } +} + +export async function buildDevelopmentRenderer({ + build = esbuild as DevelopmentRendererBuild, + projectDirectory = defaultProjectDirectory, + publishedDirectory +}: DevelopmentRendererOptions): Promise { + const resolvedProjectDirectory = path.resolve(projectDirectory) + const resolvedPublishedDirectory = path.resolve(publishedDirectory) + const parent = path.dirname(resolvedPublishedDirectory) + const name = path.basename(resolvedPublishedDirectory) + await fs.mkdir(parent, { recursive: true }) + const stagedDirectory = await fs.mkdtemp(path.join(parent, `.${name}.building-`)) + const sourceDirectory = path.join(stagedDirectory, 'src') + const metafiles: Metafile[] = [] + + try { + for (const input of staticInputs) { + await copyFile( + path.join(resolvedProjectDirectory, input), + path.join(stagedDirectory, input) + ) + } + + const preload = await build({ + absWorkingDir: resolvedProjectDirectory, + bundle: true, + entryPoints: ['src/preload.ts'], + external: ['electron'], + format: 'cjs', + logLevel: 'warning', + metafile: true, + outfile: path.join(sourceDirectory, 'preload.js'), + platform: 'node', + sourcemap: 'external', + sourcesContent: true, + target: 'node22' + }) + metafiles.push(preload.metafile) + + const startup = await build({ + absWorkingDir: resolvedProjectDirectory, + bundle: true, + entryPoints: ['src/startup.ts'], + format: 'iife', + logLevel: 'warning', + metafile: true, + outfile: path.join(sourceDirectory, 'startup.js'), + platform: 'browser', + sourcemap: 'external', + sourcesContent: true, + target: 'chrome150' + }) + metafiles.push(startup.metafile) + + const renderer = await build({ + absWorkingDir: resolvedProjectDirectory, + bundle: true, + entryPoints: ['src/renderer.ts'], + format: 'esm', + logLevel: 'warning', + metafile: true, + outfile: path.join(sourceDirectory, 'renderer.js'), + platform: 'browser', + sourcemap: 'external', + sourcesContent: true, + splitting: false, + target: 'chrome150' + }) + metafiles.push(renderer.metafile) + + const inputPaths = new Set(staticInputs) + for (const metafile of metafiles) { + for (const inputPath of Object.keys(metafile.inputs)) { + inputPaths.add(normalizeProjectPath(resolvedProjectDirectory, inputPath)) + } + } + + await replacePublishedDirectory(stagedDirectory, resolvedPublishedDirectory) + return { + inputPaths: [...inputPaths].sort(), + publishedDirectory: resolvedPublishedDirectory + } + } catch (error) { + await fs.rm(stagedDirectory, { recursive: true, force: true }) + throw error + } +} diff --git a/scripts/development-watch-bootstrap.js b/scripts/development-watch-bootstrap.js index b27c1be8..8b290742 100644 --- a/scripts/development-watch-bootstrap.js +++ b/scripts/development-watch-bootstrap.js @@ -121,6 +121,7 @@ let bootstrapReloadRequested = false let revision = 0 let transition = null let watcherInputs = new Set() +const watcherHandoffChanges = new Set() function normalizedBundleInput(filePath) { const absolutePath = path.isAbsolute(filePath) @@ -147,7 +148,8 @@ function scheduleWatcherStart() { }, debounceMilliseconds) } -function requestWatcherStart() { +function requestWatcherStart(filePath) { + watcherHandoffChanges.add(filePath) revision += 1 scheduleWatcherStart() } @@ -173,7 +175,7 @@ const bootstrapWatcher = watch( return } if (!started || starting || isWatcherInput(filePath)) { - requestWatcherStart() + requestWatcherStart(filePath) } else { developmentLoop.notify(filePath) } @@ -303,6 +305,10 @@ async function startWatcher() { ) started = true developmentLoop.start() + for (const filePath of watcherHandoffChanges) { + developmentLoop.notify(filePath) + } + watcherHandoffChanges.clear() } catch (error) { fail(error) process.stderr.write( diff --git a/scripts/development-watch.ts b/scripts/development-watch.ts index 0f4f0b55..62908659 100644 --- a/scripts/development-watch.ts +++ b/scripts/development-watch.ts @@ -2,13 +2,19 @@ import { spawn, type ChildProcess } from 'node:child_process' import { realpathSync, watch, type FSWatcher } from 'node:fs' import path from 'node:path' +import { build as esbuild } from 'esbuild' + import { DEVELOPMENT_CONTROL_QUIT, - DEVELOPMENT_WATCH_ENVIRONMENT + DEVELOPMENT_RENDERER_ROOT_ENVIRONMENT, + DEVELOPMENT_WATCH_ENVIRONMENT, + developmentRendererRoot } from '../src/development-control' import { + LocalServiceError, probeService, readEndpoint, + requestDevelopmentReload, requestServiceQuit } from '../src/local-client' import type { ResolvedInstance } from '../src/instance' @@ -19,6 +25,10 @@ import { resolveStartInstance, type ParsedStartArguments } from './start' +import { + buildDevelopmentRenderer, + type DevelopmentRendererResult +} from './development-renderer' const projectDirectory = path.resolve(__dirname, '../..') const DEFAULT_DEBOUNCE_MILLISECONDS = 120 @@ -48,6 +58,14 @@ const watchedFiles = new Set([ 'tsconfig.json' ]) +const restartRequiredFiles = new Set([ + '.markover/development.json', + 'package.json', + 'packages/cli/package.json', + 'tsconfig.build.json', + 'tsconfig.json' +]) + export interface DevelopmentProcess { exitCode: number | null once?: (( @@ -60,9 +78,9 @@ export interface DevelopmentProcess { } export interface DevelopmentWatchOperations { + apply: () => Promise build: () => Promise reportError?: ((error: unknown) => void) | undefined - restart: () => Promise } export interface DevelopmentWatchControllerOptions { @@ -84,12 +102,16 @@ export function isDevelopmentBuildInput(filePath: string | null): boolean { )) } +export function isDevelopmentRestartRequiredInput(filePath: string): boolean { + return restartRequiredFiles.has(normalizedRelativePath(filePath)) +} + export class DevelopmentWatchController { private readonly build: () => Promise private readonly clearTimer: typeof clearTimeout private readonly debounceMilliseconds: number private readonly reportError: (error: unknown) => void - private readonly restart: () => Promise + private readonly apply: () => Promise private readonly setTimer: typeof setTimeout private closed = false private completedRevision = 0 @@ -99,7 +121,7 @@ export class DevelopmentWatchController { private readonly idleWaiters = new Set<() => void>() constructor( - { build, reportError = () => {}, restart }: DevelopmentWatchOperations, + { apply, build, reportError = () => {} }: DevelopmentWatchOperations, { debounceMilliseconds = DEFAULT_DEBOUNCE_MILLISECONDS, setTimer = globalThis.setTimeout, @@ -107,10 +129,10 @@ export class DevelopmentWatchController { }: DevelopmentWatchControllerOptions = {} ) { this.build = build + this.apply = apply this.clearTimer = clearTimer this.debounceMilliseconds = debounceMilliseconds this.reportError = reportError - this.restart = restart this.setTimer = setTimer } @@ -157,7 +179,7 @@ export class DevelopmentWatchController { this.running = (async () => { try { await this.build() - await this.restart() + await this.apply() } catch (error) { this.reportError(error) } finally { @@ -191,6 +213,12 @@ function realPath(filePath: string): string { } export interface DevelopmentInstanceManagerOptions { + buildApplication?: (() => Promise) | undefined + buildRenderer?: ((options: { + projectDirectory: string + publishedDirectory: string + }) => Promise) | undefined + inspectRuntimeInputs?: ((checkout: string) => Promise>) | undefined checkoutDirectory?: string | undefined isProcessAlive?: ((pid: number) => boolean) | undefined launch?: (( @@ -202,6 +230,7 @@ export interface DevelopmentInstanceManagerOptions { prepare?: ((instance: ResolvedInstance) => Promise) | undefined probe?: ((endpointPath: string) => Promise) | undefined quit?: ((endpointPath: string) => Promise) | undefined + reload?: ((endpointPath: string) => Promise) | undefined readProcessEndpoint?: ((endpointPath: string) => Promise<{ pid: number }>) | undefined resolve?: (() => Promise) | undefined timeoutMilliseconds?: number | undefined @@ -228,6 +257,30 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } +async function developmentRuntimeInputs( + checkout: string +): Promise> { + const result = await esbuild({ + absWorkingDir: checkout, + bundle: true, + entryPoints: ['src/main.ts'], + external: ['electron'], + format: 'cjs', + logLevel: 'silent', + metafile: true, + packages: 'external', + platform: 'node', + sourcemap: false, + target: 'node22', + write: false + }) + return new Set(Object.keys(result.metafile.inputs).map((inputPath) => ( + normalizedRelativePath(path.isAbsolute(inputPath) + ? path.relative(checkout, inputPath) + : inputPath) + ))) +} + function targetFromInstance( instance: ResolvedInstance, checkoutDirectory: string @@ -281,7 +334,15 @@ function stoppedInstance(instance: ResolvedInstance): ResolvedInstance { export class DevelopmentInstanceManager { private activeProcess: DevelopmentProcess | null = null private readonly appArguments: readonly string[] + private readonly buildApplication: () => Promise + private readonly buildRenderer: (options: { + projectDirectory: string + publishedDirectory: string + }) => Promise private readonly isProcessAlive: (pid: number) => boolean + private readonly inspectRuntimeInputs: ( + checkout: string + ) => Promise> private readonly launch: ( instance: ResolvedInstance, appArguments: readonly string[] @@ -291,19 +352,31 @@ export class DevelopmentInstanceManager { private readonly prepare: (instance: ResolvedInstance) => Promise private readonly probe: (endpointPath: string) => Promise private readonly quit: (endpointPath: string) => Promise + private readonly reload: (endpointPath: string) => Promise private readonly readProcessEndpoint: ( endpointPath: string ) => Promise<{ pid: number }> private readonly resolve: () => Promise + private readonly rendererRoot: string private readonly target: WatchTarget private readonly timeoutMilliseconds: number private readonly wait: (milliseconds: number) => Promise + private pendingChanges = new Set() + private preparedInstance: ResolvedInstance | null = null + private rendererInputs = new Set() + private runtimeInputs = new Set() + private liveRendererReady = false + private nextAction: 'attach' | 'launch' | 'none' | 'reload' = 'none' + private restartRequiredInputs = new Set() constructor( initialInstance: ResolvedInstance, appArguments: readonly string[], { + buildApplication = runBuild, + buildRenderer = buildDevelopmentRenderer, checkoutDirectory = projectDirectory, + inspectRuntimeInputs = developmentRuntimeInputs, isProcessAlive = processIsAlive, launch = (instance, appArguments) => launchResolvedInstance( instance, @@ -312,7 +385,11 @@ export class DevelopmentInstanceManager { detached: process.platform !== 'win32', environment: { ...process.env, - [DEVELOPMENT_WATCH_ENVIRONMENT]: '1' + [DEVELOPMENT_WATCH_ENVIRONMENT]: '1', + [DEVELOPMENT_RENDERER_ROOT_ENVIRONMENT]: developmentRendererRoot( + initialInstance.checkout || checkoutDirectory, + initialInstance.identity.key + ) }, ipc: true } @@ -322,6 +399,7 @@ export class DevelopmentInstanceManager { prepare = prepareResolvedInstance, probe = probeService, quit = requestServiceQuit, + reload = requestDevelopmentReload, readProcessEndpoint = readEndpoint, resolve, timeoutMilliseconds = DEFAULT_TRANSITION_TIMEOUT_MILLISECONDS, @@ -330,18 +408,26 @@ export class DevelopmentInstanceManager { ) { this.target = targetFromInstance(initialInstance, checkoutDirectory) this.appArguments = appArguments + this.buildApplication = buildApplication + this.buildRenderer = buildRenderer this.isProcessAlive = isProcessAlive + this.inspectRuntimeInputs = inspectRuntimeInputs this.launch = launch this.now = now this.pollMilliseconds = pollMilliseconds this.prepare = prepare this.probe = probe this.quit = quit + this.reload = reload this.readProcessEndpoint = readProcessEndpoint this.resolve = resolve || (() => resolveStartInstance({ selector: this.target.selector, appArguments: [...this.appArguments] })) + this.rendererRoot = developmentRendererRoot( + this.target.checkout, + this.target.identityKey + ) this.timeoutMilliseconds = timeoutMilliseconds this.wait = wait } @@ -354,17 +440,103 @@ export class DevelopmentInstanceManager { const current = await this.resolveExactInstance() this.assertRestartEligible(current) await this.prepare(current) + this.preparedInstance = current + await this.launchPreparedInstance() + } + + noteChange(filePath: string | null): void { + this.pendingChanges.add(filePath === null + ? null + : normalizedRelativePath(filePath)) + } + + async build(): Promise { + const changes = this.pendingChanges + this.pendingChanges = new Set() + const initial = !this.liveRendererReady || changes.has(null) + const current = await this.resolveExactInstance() + this.assertRestartEligible(current) + if (initial) { + await this.buildApplication() + await this.prepare(current) + this.preparedInstance = current + } + + const runtimeInputs = await this.inspectRuntimeInputs(this.target.checkout) + const renderer = await this.buildRenderer({ + projectDirectory: this.target.checkout, + publishedDirectory: this.rendererRoot + }) + this.runtimeInputs = runtimeInputs + this.rendererInputs = new Set(renderer.inputPaths) + + const changedPaths = [...changes].filter( + (filePath): filePath is string => filePath !== null + ) + const rendererChanged = initial || ( + changedPaths.some((filePath) => ( + this.rendererInputs.has(filePath) && !this.runtimeInputs.has(filePath) + )) + ) + for (const filePath of changedPaths) { + if (this.runtimeInputs.has(filePath)) this.reportRestartRequired(filePath) + } + + this.nextAction = initial + ? current.process.status === 'running' ? 'attach' : 'launch' + : rendererChanged ? 'reload' : 'none' + } + + async apply(): Promise<'launched' | 'reloaded' | 'unchanged'> { + const action = this.nextAction + this.nextAction = 'none' + if (action === 'none') return 'unchanged' + if (action === 'reload') { + await this.reload(this.target.endpointPath) + return 'reloaded' + } + if (action === 'attach') { + try { + await this.reload(this.target.endpointPath) + this.preparedInstance = null + this.liveRendererReady = true + return 'reloaded' + } catch (error) { + if (!(error instanceof LocalServiceError) || error.code !== 'NOT_FOUND') { + throw error + } + } + } + await this.launchPreparedInstance() + this.liveRendererReady = true + return 'launched' + } + + reportRestartRequired(filePath: string): void { + const normalized = normalizedRelativePath(filePath) + if (this.restartRequiredInputs.has(normalized)) return + this.restartRequiredInputs.add(normalized) + process.stderr.write( + `markover dev ${this.identityKey}: restart required for ${normalized}; the running window was left untouched.\n` + ) + } + + private async launchPreparedInstance(): Promise { + const prepared = this.preparedInstance + if (!prepared) { + throw new Error(`Cannot start ${this.identityKey}: no build is prepared.`) + } const activePid = this.liveActiveProcessPid() - let stopped = current + let stopped = prepared if (activePid !== null) { await this.stopActiveProcess() - stopped = stoppedInstance(current) - } else if (current.process.status === 'running') { + stopped = stoppedInstance(prepared) + } else if (prepared.process.status === 'running') { const endpointPid = ( - await this.readProcessEndpoint(current.service.endpointPath) + await this.readProcessEndpoint(prepared.service.endpointPath) ).pid await this.stopRunningProcess(endpointPid, endpointPid) - stopped = stoppedInstance(current) + stopped = stoppedInstance(prepared) } if ( @@ -391,6 +563,7 @@ export class DevelopmentInstanceManager { throw new Error(`Cannot restart ${this.identityKey}: Electron did not report a process ID.`) } this.activeProcess = launched + this.preparedInstance = null const readiness = this.waitForReady(stopped.service.endpointPath, launched) await (launchFailure === null ? readiness @@ -590,17 +763,18 @@ export async function main( parsed.appArguments ) const controller = new DevelopmentWatchController({ - async build() { + async apply() { + const outcome = await manager.apply() + if (outcome === 'unchanged') return process.stderr.write( - `markover dev ${manager.identityKey}: rebuilding.\n` + `markover dev ${manager.identityKey}: ready (${outcome}).\n` ) - await runBuild() }, - async restart() { - await manager.restart() + async build() { process.stderr.write( - `markover dev ${manager.identityKey}: ready.\n` + `markover dev ${manager.identityKey}: rebuilding.\n` ) + await manager.build() }, reportError(error) { process.stderr.write( @@ -620,6 +794,7 @@ export async function main( process.stderr.write( `markover dev: watching ${manager.identityKey}; awaiting a successful rebuild.\n` ) + manager.noteChange(null) controller.notify(null) } const stop = async (signal: NodeJS.Signals) => { @@ -633,7 +808,7 @@ export async function main( ) try { await controller.waitForIdle() - await manager.stop() + if (signal !== 'SIGHUP') await manager.stop() } catch (error) { process.stderr.write( `markover dev ${manager.identityKey}: shutdown failed: ${errorMessage(error)}\n` @@ -644,6 +819,21 @@ export async function main( if (!deferStart) start() return { notify(filePath) { + if (!isDevelopmentBuildInput(filePath)) return false + const normalized = filePath === null + ? null + : normalizedRelativePath(filePath) + if ( + normalized !== null && + !normalized.startsWith('src/') && + !normalized.startsWith('design/brand/') + ) { + if (isDevelopmentRestartRequiredInput(normalized)) { + manager.reportRestartRequired(normalized) + } + return true + } + manager.noteChange(filePath) return controller.notify(filePath) }, start, diff --git a/src/development-control.ts b/src/development-control.ts index 16773aec..8e862d82 100644 --- a/src/development-control.ts +++ b/src/development-control.ts @@ -1,4 +1,27 @@ +import path from 'node:path' + export const DEVELOPMENT_WATCH_ENVIRONMENT = 'MARKOVER_DEVELOPMENT_WATCH' +export const DEVELOPMENT_RENDERER_ROOT_ENVIRONMENT = + 'MARKOVER_DEVELOPMENT_RENDERER_ROOT' + +export function developmentRendererRoot( + checkout: string, + identityKey: string +): string { + if ( + !path.isAbsolute(checkout) || + !/^(?:canonical|pr-[1-9]\d*)$/.test(identityKey) + ) { + throw new Error('Development renderer identity is invalid.') + } + return path.join( + checkout, + '.markover', + 'generated', + identityKey, + 'live-renderer' + ) +} export const DEVELOPMENT_CONTROL_QUIT = { action: 'quit', diff --git a/src/local-client.ts b/src/local-client.ts index 275e087f..512eb9f5 100644 --- a/src/local-client.ts +++ b/src/local-client.ts @@ -328,3 +328,22 @@ export async function requestServiceQuit(endpointPath: string): Promise { ) } } + +export async function requestDevelopmentReload( + endpointPath: string +): Promise { + const response = await requestJson( + endpointPath, + 'POST', + '/development/reload', + null, + { timeoutMilliseconds: 30_000 } + ) + if (!isRecord(response) || response.status !== 'reloaded') { + throw new LocalServiceError( + 'INVALID_RESPONSE', + 'Markover returned an invalid development reload response.', + 200 + ) + } +} diff --git a/src/local-service.ts b/src/local-service.ts index 7000c2d7..4589b124 100644 --- a/src/local-service.ts +++ b/src/local-service.ts @@ -128,6 +128,7 @@ export interface LocalServiceOptions { artifact: ReviewArtifact, action: LocalServiceChangeAction ) => void | Promise) | undefined + onDevelopmentReload?: (() => Promise) | undefined onQuit?: (() => void) | undefined onUnauthorized?: ((event: UnauthorizedRequest) => void) | undefined interpretationPolicy?: (() => string) | undefined @@ -312,6 +313,7 @@ export async function startLocalService({ 'Review activation is unavailable.' )), onChange = () => {}, + onDevelopmentReload, onQuit = () => {}, onUnauthorized = () => {}, interpretationPolicy, @@ -431,6 +433,16 @@ export async function startLocalService({ const url = new URL(request.url || '', 'http://127.0.0.1') + if ( + request.method === 'POST' && + url.pathname === '/development/reload' && + onDevelopmentReload + ) { + await onDevelopmentReload() + sendJson(response, 200, { status: 'reloaded' }) + return + } + if ( request.method === 'POST' && url.pathname === INTERNAL_REMOTE_CREATE_PATH diff --git a/src/main.ts b/src/main.ts index 47ed8670..0df9239c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,7 +30,9 @@ import { AsyncMutationTracker } from './async-mutation-tracker' import { claudeThreadTitleSnapshot } from './claude-thread-titles' import { codexThreadTitleSnapshot } from './codex-thread-titles' import { + DEVELOPMENT_RENDERER_ROOT_ENVIRONMENT, DEVELOPMENT_WATCH_ENVIRONMENT, + developmentRendererRoot, isDevelopmentControlQuit } from './development-control' import { loadDevelopmentConfig } from './development-config' @@ -202,11 +204,26 @@ process.on('message', (message) => { const projectDirectory = path.resolve(__dirname, '..') const checkoutDirectory = addressedInstance.checkout +const developmentWatchMode = process.env[DEVELOPMENT_WATCH_ENVIRONMENT] === '1' +const rendererApplicationRoot = (() => { + if (!developmentWatchMode) return projectDirectory + if (!checkoutDirectory) { + throw new Error('Development watch mode requires its owning checkout.') + } + const expected = developmentRendererRoot( + checkoutDirectory, + addressedInstance.identity.key + ) + const configured = process.env[DEVELOPMENT_RENDERER_ROOT_ENVIRONMENT] + if (!configured || path.resolve(configured) !== expected) { + throw new Error('Development renderer root does not match this instance.') + } + return expected +})() const appIconPath = path.isAbsolute(addressedInstance.branding.iconPngPath) ? addressedInstance.branding.iconPngPath : path.join(projectDirectory, addressedInstance.branding.iconPngPath) const smokeMode = process.argv.includes('--smoke') -const developmentWatchMode = process.env[DEVELOPMENT_WATCH_ENVIRONMENT] === '1' const canonicalRefreshWindowMode = process.argv.includes( '--markover-refresh-window' ) @@ -479,6 +496,21 @@ function markRendererStartupFailed(): void { if (!startupReady) rendererStartupFailed = true } +function handleRendererLoadFailure(error: Error): void { + const failedDuringStartup = !startupReady + markRendererStartupFailed() + void (async () => { + if (!failedDuringStartup) { + process.stderr.write( + `markover renderer reload: ${error.message} Waiting for the next valid development build.\n` + ) + return + } + await failStartupBestEffort('renderer-load', error) + await showStartupFailureDialog() + })() +} + function requireActiveRendererStartup(): void { if (rendererDidFailStartup()) { throw new Error('Renderer failed before startup completed.') @@ -494,9 +526,9 @@ function settingsEnvelope(settings: MarkoverSettings): MarkoverSettingsEnvelope function loadBrandAssets(): Promise { brandAssetsPromise ||= Promise.all([ - fs.readFile(path.join(__dirname, '../design/brand/markover-mark.svg'), 'utf8'), - fs.readFile(path.join(__dirname, '../design/brand/markover-logotype.svg'), 'utf8'), - fs.readFile(path.join(__dirname, '../design/brand/markover-lockup.svg'), 'utf8') + fs.readFile(path.join(rendererApplicationRoot, 'design/brand/markover-mark.svg'), 'utf8'), + fs.readFile(path.join(rendererApplicationRoot, 'design/brand/markover-logotype.svg'), 'utf8'), + fs.readFile(path.join(rendererApplicationRoot, 'design/brand/markover-lockup.svg'), 'utf8') ]).then(([mark, logotype, lockup]) => ({ mark, logotype, lockup })) return brandAssetsPromise } @@ -1105,7 +1137,7 @@ function createWindow( icon: appIconPath, titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', webPreferences: { - preload: path.join(__dirname, 'preload.js'), + preload: path.join(rendererApplicationRoot, 'src', 'preload.js'), ...hardenedRendererWebPreferences } }) @@ -1141,10 +1173,7 @@ function createWindow( mainWindowBlurredAt = window.isFocused() ? null : mainWindowBlurredAt ?? Date.now() - rendererReadyWebContentsId = null - rendererReadyPromise = new Promise((resolve) => { - resolveRendererReady = resolve - }) + expectRendererReady() const query = { palette: startupSettings.palette, @@ -1179,14 +1208,9 @@ function createWindow( publishWindowFocusState() }) window.webContents.on('preload-error', (_event, preloadPath, error) => { - markRendererStartupFailed() - void (async () => { - await failStartupBestEffort( - 'renderer-load', - new Error(`Preload failed (${path.basename(preloadPath)}): ${error.message}`) - ) - await showStartupFailureDialog() - })() + handleRendererLoadFailure( + new Error(`Preload failed (${path.basename(preloadPath)}): ${error.message}`) + ) }) window.webContents.on('did-fail-load', ( _event, @@ -1196,14 +1220,9 @@ function createWindow( isMainFrame ) => { if (!isMainFrame || errorCode === -3) return - markRendererStartupFailed() - void (async () => { - await failStartupBestEffort( - 'renderer-load', - new Error(`Renderer load failed (${String(errorCode)}): ${errorDescription}`) - ) - await showStartupFailureDialog() - })() + handleRendererLoadFailure( + new Error(`Renderer load failed (${String(errorCode)}): ${errorDescription}`) + ) }) window.webContents.on('render-process-gone', (_event, details) => { const failedDuringStartup = !startupReady @@ -1443,6 +1462,13 @@ function markRendererReady(webContentsId: number): void { flushPendingManagedReviewNotifications() } +function expectRendererReady(): void { + rendererReadyWebContentsId = null + rendererReadyPromise = new Promise((resolve) => { + resolveRendererReady = resolve + }) +} + async function waitForRendererReady(window: BrowserWindow): Promise { if (rendererReadyWebContentsId === window.webContents.id) return await new Promise((resolve, reject) => { @@ -1465,6 +1491,42 @@ async function waitForRendererReady(window: BrowserWindow): Promise { } } +async function reloadDevelopmentRenderer(): Promise { + if (!developmentWatchMode) { + throw Object.assign( + new Error('Renderer reload is available only in development watch mode.'), + { code: 'DEVELOPMENT_RELOAD_UNAVAILABLE' } + ) + } + const window = mainWindow + if (!window || window.isDestroyed() || !startupReady) { + throw Object.assign( + new Error('The development renderer is not ready to reload.'), + { code: 'DEVELOPMENT_RELOAD_NOT_READY' } + ) + } + if (managedShutdownStarted) { + throw new Error('Managed review changes are unavailable right now.') + } + try { + setManagedRendererPause(true) + managedLocalReviewCreationsBlocked = true + await localService?.pauseMutations() + await managedLocalReviewCreations.wait() + await captureEditableManagedReviews() + managedAttachmentSavesBlocked = true + await managedAttachmentMutations.wait() + await requireManagedAutosave().flushAll() + await requireWorkspaceStore().flush() + brandAssetsPromise = null + expectRendererReady() + window.webContents.reloadIgnoringCache() + await waitForRendererReady(window) + } finally { + resumeManagedMutationsUnlessShuttingDown() + } +} + async function requestRendererActivation( reviewId: string, document: MarkoverDocument | null, @@ -1836,6 +1898,9 @@ async function startAndPublishService(): Promise { requestReviewResolutionConfirmation(artifacts, outcome) ), onActivate: activateManagedReview, + ...(developmentWatchMode + ? { onDevelopmentReload: reloadDevelopmentRenderer } + : {}), onQuit() { app.quit() }, @@ -2090,7 +2155,7 @@ if (!hasSingleInstanceLock) { protocol.handle(MARKOVER_INTERNAL_SCHEME, async (request) => { const resolved = await resolveInternalRequestFile( request.url, - projectDirectory, + rendererApplicationRoot, internalAttachments ) if (!resolved.ok) { diff --git a/test/agent-guidance.test.ts b/test/agent-guidance.test.ts index da6b66db..1bfd44c6 100644 --- a/test/agent-guidance.test.ts +++ b/test/agent-guidance.test.ts @@ -77,5 +77,7 @@ test('generic and dedicated agent guidance preserve the same semantics', async ( assert.match(development, /Agent-facing instructions must preserve the contract/) assert.match(development, /substantive engagement with discussion and concerns/) assert.match(development, /npm run dev -- --instance dev/) - assert.match(development, /same checkout, state root, name, and icon/) + assert.match(development, /existing Electron process/) + assert.match(development, /size, position, visibility, and focus remain unchanged/) + assert.match(development, /restart\s+required/) }) diff --git a/test/background-focus.test.ts b/test/background-focus.test.ts index b430b441..6e4f46c6 100644 --- a/test/background-focus.test.ts +++ b/test/background-focus.test.ts @@ -332,6 +332,9 @@ test('native startup failure dialogs survive diagnostic write failures', () => { const bestEffort = main.match( /async function failStartupBestEffort\([\s\S]*?\n\}/ )?.[0] || '' + const rendererLoadFailure = main.match( + /function handleRendererLoadFailure\([\s\S]*?\n\}/ + )?.[0] || '' const createWindow = main.slice( main.indexOf('function createWindow('), main.indexOf('function managedDocument(') @@ -343,11 +346,15 @@ test('native startup failure dialogs survive diagnostic write failures', () => { ) assert.equal( createWindow.match(/await failStartupBestEffort\(/g)?.length, - 4 + 2 ) assert.equal( createWindow.match(/await showStartupFailureDialog\(\)/g)?.length, - 4 + 2 + ) + assert.match( + rendererLoadFailure, + /const failedDuringStartup = !startupReady[\s\S]*if \(!failedDuringStartup\) \{[\s\S]*Waiting for the next valid development build[\s\S]*return[\s\S]*await failStartupBestEffort\('renderer-load', error\)[\s\S]*await showStartupFailureDialog\(\)/ ) assert.doesNotMatch(createWindow, /await failStartup\(/) }) diff --git a/test/development-control.test.ts b/test/development-control.test.ts index 2027d1b8..6c1104a4 100644 --- a/test/development-control.test.ts +++ b/test/development-control.test.ts @@ -3,12 +3,26 @@ import test from 'node:test' import { DEVELOPMENT_CONTROL_QUIT, + DEVELOPMENT_RENDERER_ROOT_ENVIRONMENT, DEVELOPMENT_WATCH_ENVIRONMENT, + developmentRendererRoot, isDevelopmentControlQuit } from '../src/development-control' test('development watch startup uses one private environment marker', () => { assert.equal(DEVELOPMENT_WATCH_ENVIRONMENT, 'MARKOVER_DEVELOPMENT_WATCH') + assert.equal( + DEVELOPMENT_RENDERER_ROOT_ENVIRONMENT, + 'MARKOVER_DEVELOPMENT_RENDERER_ROOT' + ) + assert.equal( + developmentRendererRoot('/checkouts/markover', 'pr-196'), + '/checkouts/markover/.markover/generated/pr-196/live-renderer' + ) + assert.throws( + () => developmentRendererRoot('/checkouts/markover', '../canonical'), + /identity is invalid/ + ) }) test('development quit control accepts only the exact private message', () => { diff --git a/test/development-renderer.test.ts b/test/development-renderer.test.ts new file mode 100644 index 00000000..d576bdd5 --- /dev/null +++ b/test/development-renderer.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import test, { type TestContext } from 'node:test' + +import { + buildDevelopmentRenderer, + type DevelopmentRendererBuild +} from '../scripts/development-renderer' + +async function fixture(t: TestContext): Promise<{ + project: string + published: string +}> { + const project = await fs.mkdtemp(path.join(os.tmpdir(), 'markover-renderer-')) + t.after(() => fs.rm(project, { recursive: true, force: true })) + for (const input of [ + 'src/index.html', + 'src/styles.css', + 'design/brand/markover-app-icon.png', + 'design/brand/markover-lockup.svg', + 'design/brand/markover-logotype.svg', + 'design/brand/markover-mark.svg' + ]) { + await fs.mkdir(path.dirname(path.join(project, input)), { recursive: true }) + await fs.writeFile(path.join(project, input), input) + } + return { project, published: path.join(project, 'identity', 'renderer') } +} + +function fakeBuild(inputs: readonly string[]): DevelopmentRendererBuild { + return async (options) => { + assert.equal(options.metafile, true) + const outfile = String(options.outfile) + await fs.mkdir(path.dirname(outfile), { recursive: true }) + await fs.writeFile(outfile, `built ${String(options.format)}`) + await fs.writeFile(`${outfile}.map`, 'map') + return { + errors: [], + warnings: [], + metafile: { + inputs: Object.fromEntries(inputs.map((input) => [input, { bytes: 1, imports: [] }])), + outputs: {} + } + } + } +} + +test('publishes a complete renderer directory and normalized input paths', async (t) => { + const { project, published } = await fixture(t) + const result = await buildDevelopmentRenderer({ + build: fakeBuild(['src/shared.ts', path.join(project, 'src', 'absolute.ts')]), + projectDirectory: project, + publishedDirectory: published + }) + + assert.equal(result.publishedDirectory, published) + assert.deepEqual(result.inputPaths, [ + 'design/brand/markover-app-icon.png', + 'design/brand/markover-lockup.svg', + 'design/brand/markover-logotype.svg', + 'design/brand/markover-mark.svg', + 'src/absolute.ts', + 'src/index.html', + 'src/shared.ts', + 'src/styles.css' + ]) + assert.equal(await fs.readFile(path.join(published, 'src/preload.js'), 'utf8'), 'built cjs') + assert.equal(await fs.readFile(path.join(published, 'src/startup.js'), 'utf8'), 'built iife') + assert.equal(await fs.readFile(path.join(published, 'src/renderer.js'), 'utf8'), 'built esm') + assert.equal(await fs.readFile(path.join(published, 'src/index.html'), 'utf8'), 'src/index.html') + assert.equal( + await fs.readFile(path.join(published, 'design/brand/markover-mark.svg'), 'utf8'), + 'design/brand/markover-mark.svg' + ) +}) + +test('a failed build preserves the previously published renderer', async (t) => { + const { project, published } = await fixture(t) + await fs.mkdir(published, { recursive: true }) + await fs.writeFile(path.join(published, 'current'), 'keep me') + let calls = 0 + const failingBuild: DevelopmentRendererBuild = async (options) => { + calls += 1 + if (calls === 2) throw new Error('startup did not bundle') + return fakeBuild(['src/preload.ts'])(options) + } + + await assert.rejects( + buildDevelopmentRenderer({ + build: failingBuild, + projectDirectory: project, + publishedDirectory: published + }), + /startup did not bundle/ + ) + assert.equal(await fs.readFile(path.join(published, 'current'), 'utf8'), 'keep me') + assert.deepEqual(await fs.readdir(path.dirname(published)), ['renderer']) +}) diff --git a/test/development-watch.test.ts b/test/development-watch.test.ts index c7f171f0..d155e25e 100644 --- a/test/development-watch.test.ts +++ b/test/development-watch.test.ts @@ -10,6 +10,7 @@ import { DevelopmentInstanceManager, DevelopmentWatchController, isDevelopmentBuildInput, + isDevelopmentRestartRequiredInput, type DevelopmentProcess } from '../scripts/development-watch' import { parseStartArguments, StartArgumentError } from '../scripts/start' @@ -94,7 +95,7 @@ test('development command bootstraps before the first application build', async assert.doesNotMatch(bootstrap, /npm run build/) }) -test('development watcher marks Electron replacements for inactive startup', async () => { +test('development watcher configures a private live renderer root', async () => { const source = await fs.readFile( path.join(projectDirectory, 'scripts/development-watch.ts'), 'utf8' @@ -104,6 +105,7 @@ test('development watcher marks Electron replacements for inactive startup', asy source, /environment: \{[\s\S]*\.\.\.process\.env,[\s\S]*\[DEVELOPMENT_WATCH_ENVIRONMENT\]: '1'/ ) + assert.match(source, /\[DEVELOPMENT_RENDERER_ROOT_ENVIRONMENT\]/) }) test('invalid development arguments remain non-retryable bootstrap errors', () => { @@ -277,6 +279,10 @@ test('bootstrap reloads watcher inputs and delegates application inputs', async await wait(1) assert.deepEqual(stops, ['SIGHUP']) + assert.deepEqual(notifications, [ + 'src/renderer.ts', + 'src/instance.ts' + ]) bootstrapPreflightError = 'syntax' watchCallback('change', 'scripts/development-watch-bootstrap.js') @@ -344,7 +350,25 @@ test('development build inputs exclude generated and unrelated paths', () => { ]) assert.equal(isDevelopmentBuildInput(filePath), false, filePath) }) -test('rapid changes coalesce into one build and restart', async () => { +test('development configuration inputs require an explicit restart', () => { + for (const filePath of [ + '.markover/development.json', + 'package.json', + 'packages/cli/package.json', + 'tsconfig.build.json', + 'tsconfig.json' + ]) assert.equal(isDevelopmentRestartRequiredInput(filePath), true, filePath) + + for (const filePath of [ + 'README.md', + 'docs/developer/development.md', + 'scripts/development-watch.ts', + 'src/renderer.ts', + 'test/development-watch.test.ts' + ]) assert.equal(isDevelopmentRestartRequiredInput(filePath), false, filePath) +}) + +test('rapid changes coalesce into one build and apply', async () => { let builds = 0 let restarts = 0 const controller = new DevelopmentWatchController({ @@ -352,7 +376,7 @@ test('rapid changes coalesce into one build and restart', async () => { builds += 1 return Promise.resolve() }, - restart() { + apply() { restarts += 1 return Promise.resolve() } @@ -368,7 +392,7 @@ test('rapid changes coalesce into one build and restart', async () => { controller.close() }) -test('a failed build keeps watching and the next valid change restarts', async () => { +test('a failed build keeps watching and the next valid change applies', async () => { let builds = 0 let restarts = 0 const failures: unknown[] = [] @@ -379,7 +403,7 @@ test('a failed build keeps watching and the next valid change restarts', async ( ? Promise.reject(new Error('compile failed')) : Promise.resolve() }, - restart() { + apply() { restarts += 1 return Promise.resolve() }, @@ -420,7 +444,7 @@ test('changes during a build queue one serialized follow-up cycle', async () => if (builds === 1) await firstBuild activeOperations -= 1 }, - async restart() { + async apply() { activeOperations += 1 maximumActiveOperations = Math.max( maximumActiveOperations, @@ -445,6 +469,153 @@ test('changes during a build queue one serialized follow-up cycle', async () => controller.close() }) +test('a running watch instance reloads without quitting or launching', async () => { + const events: string[] = [] + const manager = new DevelopmentInstanceManager( + canonicalInstance('running'), + [], + { + buildApplication() { + events.push('build-application') + return Promise.resolve() + }, + buildRenderer() { + events.push('build-renderer') + return Promise.resolve({ + inputPaths: ['src/renderer.ts', 'src/styles.css'], + publishedDirectory: '/renderer' + }) + }, + checkoutDirectory: '/checkouts/markover', + inspectRuntimeInputs() { + return Promise.resolve(new Set(['src/main.ts'])) + }, + launch() { + events.push('launch') + return { exitCode: null, pid: 90211, signalCode: null } + }, + prepare() { + events.push('prepare') + return Promise.resolve() + }, + reload(endpointPath) { + events.push(`reload:${endpointPath}`) + return Promise.resolve() + }, + resolve() { + return Promise.resolve(canonicalInstance('running')) + } + } + ) + + manager.noteChange(null) + await manager.build() + assert.equal(await manager.apply(), 'reloaded') + + assert.deepEqual(events, [ + 'build-application', + 'prepare', + 'build-renderer', + 'reload:/state/markover/service.json' + ]) +}) + +test('a failed initial build remains a full startup on the next edit', async () => { + let applicationBuilds = 0 + let rendererBuilds = 0 + let reloads = 0 + const manager = new DevelopmentInstanceManager( + canonicalInstance('running'), + [], + { + buildApplication() { + applicationBuilds += 1 + return Promise.resolve() + }, + buildRenderer() { + rendererBuilds += 1 + if (rendererBuilds === 1) { + return Promise.reject(new Error('renderer failed')) + } + return Promise.resolve({ + inputPaths: ['src/styles.css'], + publishedDirectory: '/renderer' + }) + }, + checkoutDirectory: '/checkouts/markover', + inspectRuntimeInputs() { + return Promise.resolve(new Set(['src/main.ts'])) + }, + prepare: () => Promise.resolve(), + reload() { + reloads += 1 + return Promise.resolve() + }, + resolve() { + return Promise.resolve(canonicalInstance('running')) + } + } + ) + + manager.noteChange(null) + await assert.rejects(manager.build(), /renderer failed/) + manager.noteChange('src/styles.css') + await manager.build() + assert.equal(await manager.apply(), 'reloaded') + + assert.equal(applicationBuilds, 2) + assert.equal(rendererBuilds, 2) + assert.equal(reloads, 1) +}) + +test('a renderer-only edit reloads alongside runtime and shared edits', async () => { + let applicationBuilds = 0 + let rendererBuilds = 0 + let reloads = 0 + const manager = new DevelopmentInstanceManager( + canonicalInstance('running'), + [], + { + buildApplication() { + applicationBuilds += 1 + return Promise.resolve() + }, + buildRenderer() { + rendererBuilds += 1 + return Promise.resolve({ + inputPaths: ['src/renderer.ts', 'src/shared.ts', 'src/styles.css'], + publishedDirectory: '/renderer' + }) + }, + checkoutDirectory: '/checkouts/markover', + inspectRuntimeInputs() { + return Promise.resolve(new Set(['src/main.ts', 'src/shared.ts'])) + }, + prepare: () => Promise.resolve(), + reload() { + reloads += 1 + return Promise.resolve() + }, + resolve() { + return Promise.resolve(canonicalInstance('running')) + } + } + ) + + manager.noteChange(null) + await manager.build() + await manager.apply() + manager.noteChange('src/styles.css') + manager.noteChange('src/main.ts') + manager.noteChange('src/shared.ts') + await manager.build() + assert.equal(await manager.apply(), 'reloaded') + + assert.equal(applicationBuilds, 1) + assert.equal(rendererBuilds, 2) + assert.equal(reloads, 2) +}) + test('restart waits for the addressed process before launching the same target', async () => { const events: string[] = [] let running = true diff --git a/test/durability-integration.test.ts b/test/durability-integration.test.ts index 387d3393..7b035fcd 100644 --- a/test/durability-integration.test.ts +++ b/test/durability-integration.test.ts @@ -98,6 +98,38 @@ test('managed quit owns the complete ordered durability barrier and escape hatch ) }) +test('development reload freezes and saves every mutable renderer surface', () => { + const main = read('src/main.ts') + const reload = main.match( + /async function reloadDevelopmentRenderer\(\): Promise \{[\s\S]*?\n\}/ + )?.[0] || '' + const ordered = [ + 'setManagedRendererPause(true)', + 'managedLocalReviewCreationsBlocked = true', + 'localService?.pauseMutations()', + 'captureEditableManagedReviews()', + 'managedAttachmentSavesBlocked = true', + 'managedAttachmentMutations.wait()', + 'requireManagedAutosave().flushAll()', + 'requireWorkspaceStore().flush()', + 'window.webContents.reloadIgnoringCache()', + 'waitForRendererReady(window)' + ].map((value) => reload.indexOf(value)) + + assert.ok(ordered.every((position) => position >= 0)) + assert.deepEqual([...ordered].sort((left, right) => left - right), ordered) + assert.match( + reload, + /finally \{[\s\S]*resumeManagedMutationsUnlessShuttingDown\(\)/ + ) + assert.doesNotMatch(reload, /reconcileRemoteGateway/) + assert.match(main, /brandAssetsPromise = null[\s\S]*reloadIgnoringCache/) + assert.match( + main, + /loadBrandAssets[\s\S]*rendererApplicationRoot[\s\S]*markover-mark\.svg/ + ) +}) + test('autosave storage failures use a dedicated persistent renderer warning', () => { const html = read('src/index.html') const main = read('src/main.ts') diff --git a/test/local-service.test.ts b/test/local-service.test.ts index 69f575f7..1d899c4d 100644 --- a/test/local-service.test.ts +++ b/test/local-service.test.ts @@ -13,6 +13,7 @@ import { LocalServiceError, probeService, readServiceConnection, + requestDevelopmentReload, requestServiceQuit, requestJson } from '../src/local-client' @@ -123,6 +124,7 @@ async function serviceFixture( await options.onChange?.(artifact, action) }, onActivate: options.onActivate, + onDevelopmentReload: options.onDevelopmentReload, onQuit: options.onQuit, onUnauthorized: options.onUnauthorized, interpretationPolicy: options.interpretationPolicy, @@ -845,6 +847,48 @@ test('done rechecks and serializes a review that becomes editable', async (t) => ) }) +test('development reload exists only when the app supplies its callback', async (t) => { + let reloads = 0 + const enabled = await serviceFixture(t, { + onDevelopmentReload() { + reloads += 1 + return Promise.resolve() + } + }) + + await requestDevelopmentReload(enabled.endpointPath) + assert.equal(reloads, 1) + + const disabled = await serviceFixture(t) + await assert.rejects( + requestDevelopmentReload(disabled.endpointPath), + (error: unknown) => hasServiceError(error, 'NOT_FOUND', 404) + ) +}) + +test('development reload allows the complete renderer durability barrier', async (t) => { + let markReloadStarted!: () => void + let releaseReload!: () => void + const reloadStarted = new Promise((resolve) => { + markReloadStarted = resolve + }) + const reloadBarrier = new Promise((resolve) => { + releaseReload = resolve + }) + const enabled = await serviceFixture(t, { + async onDevelopmentReload() { + markReloadStarted() + await reloadBarrier + } + }) + + const reload = requestDevelopmentReload(enabled.endpointPath) + await reloadStarted + await new Promise((resolve) => setTimeout(resolve, 2_100)) + releaseReload() + await reload +}) + test('authenticated quit acknowledges and invokes the app callback', async (t) => { let quits = 0 const { endpointPath } = await serviceFixture(t, {