diff --git a/.changeset/experimental-background-dev.md b/.changeset/experimental-background-dev.md new file mode 100644 index 000000000000..bdceb0d99cbe --- /dev/null +++ b/.changeset/experimental-background-dev.md @@ -0,0 +1,24 @@ +--- +'astro': major +--- + +Adds background dev server management for AI coding agents. + +When an AI coding agent is detected, `astro dev` now automatically starts the dev server as a detached background process. This prevents the dev server from blocking the agent's terminal and allows it to continue working while the server runs. + +A lock file (`.astro/dev.json`) is written when the dev server starts, recording the server's URL, port, and PID. This prevents duplicate servers from being started for the same project. + +#### New flag and subcommands + +- `astro dev --background` — Start the dev server as a background process (this is what runs automatically when an agent is detected). +- `astro dev stop` — Stop a running background dev server. +- `astro dev status` — Check if a dev server is running and display its URL, PID, and uptime. +- `astro dev logs` — View logs from a background dev server. Use `--follow` (`-f`) to stream new output as it's written. + +These allow you to start and manage dev servers programmatically and were designed with AI coding agents in mind. + +#### What should I do? + +No action is required. If you are not using an AI coding agent, `astro dev` behaves exactly as before. If you are using an agent, background mode is enabled automatically — the agent will receive the server URL and PID, and can use `astro dev stop` to shut it down. + +To opt out of automatic background mode when an agent is detected, set the environment variable `ASTRO_DEV_BACKGROUND=0` before running `astro dev`. diff --git a/packages/astro/package.json b/packages/astro/package.json index cc17f1dad21d..e5ec53df0452 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -142,6 +142,7 @@ "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", + "am-i-vibing": "^0.3.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", diff --git a/packages/astro/src/cli/dev/background.ts b/packages/astro/src/cli/dev/background.ts new file mode 100644 index 000000000000..feaebf48cf1f --- /dev/null +++ b/packages/astro/src/cli/dev/background.ts @@ -0,0 +1,150 @@ +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync, openSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { AstroLogger } from '../../core/logger/core.js'; +import type { Flags } from '../flags.js'; +import { + checkExistingServer, + getLogFileURL, + readLockFile, + removeLockFile, + isProcessAlive, + GRACEFUL_SHUTDOWN_TIMEOUT, +} from '../../core/dev/lockfile.js'; +import { resolveRoot } from '../../core/config/config.js'; + +export interface BackgroundResult { + pid: number; + url: string; + existing?: boolean; +} + +export interface BackgroundErrorResult { + error: string; + message: string; +} + +export function formatBackgroundOutput(result: BackgroundResult | BackgroundErrorResult): string { + return JSON.stringify(result); +} + +export async function background({ + flags, + logger, +}: { flags: Flags; logger: AstroLogger }): Promise { + const root = pathToFileURL(resolveRoot(flags.root) + '/'); + + // Check for existing server + const existing = checkExistingServer(root); + if (existing && !flags.force) { + logger.info('SKIP_FORMAT', `Dev server already running at ${existing.url} (pid ${existing.pid})\n` + + ' Stop: astro dev stop\n' + + ' Status: astro dev status\n' + + ' Logs: astro dev logs'); + return; + } + + // If --force, kill the existing server first + if (existing && flags.force) { + try { + process.kill(existing.pid, 'SIGTERM'); + } catch { + // Already dead + } + // Wait for graceful shutdown before escalating to SIGKILL + const deadline = Date.now() + GRACEFUL_SHUTDOWN_TIMEOUT; + while (Date.now() < deadline) { + if (!isProcessAlive(existing.pid)) break; + await new Promise((r) => setTimeout(r, 100)); + } + // If still alive after timeout, force kill + if (isProcessAlive(existing.pid)) { + try { + process.kill(existing.pid, 'SIGKILL'); + } catch { + // Already dead + } + } + removeLockFile(root); + } + + // Build the args for the child process: plain `astro dev` (no --background) + const args: string[] = ['dev']; + if (flags.port) args.push('--port', String(flags.port)); + if (flags.host != null) { + if (typeof flags.host === 'string') { + args.push('--host', flags.host); + } else { + args.push('--host'); + } + } + if (flags.config) args.push('--config', String(flags.config)); + if (flags.root) args.push('--root', String(flags.root)); + if (flags.allowedHosts) args.push('--allowed-hosts', String(flags.allowedHosts)); + if (flags.experimentalJson) args.push('--experimental-json'); + + // Open the log file for writing, ensuring the .astro directory exists + const logFileURL = getLogFileURL(root); + const logFilePath = fileURLToPath(logFileURL); + const dotAstroDir = fileURLToPath(new URL('.astro/', root)); + if (!existsSync(dotAstroDir)) { + mkdirSync(dotAstroDir, { recursive: true }); + } + const logFd = openSync(logFilePath, 'w'); + + // Find the astro binary + const rootPath = fileURLToPath(root); + const astroBin = resolve(rootPath, 'node_modules', '.bin', 'astro'); + + // Spawn the dev server as a detached child process + const child = spawn(astroBin, args, { + detached: true, + stdio: ['ignore', logFd, logFd], + cwd: rootPath, + env: { ...process.env, ASTRO_DEV_BACKGROUND: '1' }, + }); + + child.unref(); + + const childPid = child.pid; + if (!childPid) { + logger.error('SKIP_FORMAT', 'Failed to spawn background dev server process.'); + process.exit(1); + } + + // Poll the lock file to detect when the server is ready + const timeout = 30000; + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + // Check if child is still alive + if (!isProcessAlive(childPid)) { + logger.error('SKIP_FORMAT', 'Dev server process exited before becoming ready.'); + process.exit(1); + } + + // Check for the lock file (written by the child's dev server) + const lockData = readLockFile(root); + if (lockData && lockData.pid === childPid) { + logger.info('SKIP_FORMAT', `Dev server running at ${lockData.url} (pid ${lockData.pid})\n` + + ' Stop: astro dev stop\n' + + ' Status: astro dev status\n' + + ' Logs: astro dev logs'); + return; + } + + await new Promise((r) => setTimeout(r, 200)); + } + + // Timeout: kill the child and report failure + try { + process.kill(childPid, 'SIGTERM'); + } catch { + // Already dead + } + removeLockFile(root); + + logger.error('SKIP_FORMAT', `Dev server failed to start within ${timeout / 1000}s.`); + process.exit(1); +} diff --git a/packages/astro/src/cli/dev/index.ts b/packages/astro/src/cli/dev/index.ts index 60bc5c2b9264..a725677abee6 100644 --- a/packages/astro/src/cli/dev/index.ts +++ b/packages/astro/src/cli/dev/index.ts @@ -1,31 +1,49 @@ +import { isAgent } from 'am-i-vibing'; import colors from 'piccolore'; import devServer from '../../core/dev/index.js'; +import { pathToFileURL } from 'node:url'; +import { checkExistingServer, removeLockFile, writeLockFile } from '../../core/dev/lockfile.js'; +import { resolveRoot } from '../../core/config/config.js'; import { printHelp } from '../../core/messages/runtime.js'; -import { type Flags, flagsToAstroInlineConfig } from '../flags.js'; +import { type Flags, createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js'; interface DevOptions { flags: Flags; } +function isRunByAgent(): boolean { + try { + return isAgent(); + } catch { + return false; + } +} + export async function dev({ flags }: DevOptions) { if (flags.help || flags.h) { printHelp({ commandName: 'astro dev', - usage: '[...flags]', + usage: '[command] [...flags]', tables: { - Flags: [ - ['--mode', `Specify the mode of the project. Defaults to "development".`], - ['--port', `Specify which port to run on. Defaults to 4321.`], - ['--host', `Listen on all addresses, including LAN and public addresses.`], - ['--host ', `Expose on a network IP address at `], - ['--open', 'Automatically open the app in the browser on server start'], - ['--force', 'Clear the content layer cache, forcing a full rebuild.'], - [ - '--allowed-hosts', - 'Specify a comma-separated list of allowed hosts or allow any hostname.', - ], - ['--help (-h)', 'See all available flags.'], + Commands: [ + ['stop', 'Stop a running background dev server.'], + ['status', 'Check if a dev server is running.'], + ['logs [--follow]', 'View logs from a background dev server.'], + ], + Flags: [ + ['--background', 'Start the dev server as a background process.'], + ['--mode', `Specify the mode of the project. Defaults to "development".`], + ['--port', `Specify which port to run on. Defaults to 4321.`], + ['--host', `Listen on all addresses, including LAN and public addresses.`], + ['--host ', `Expose on a network IP address at `], + ['--open', 'Automatically open the app in the browser on server start'], + ['--force', 'Clear the content layer cache, forcing a full rebuild.'], + [ + '--allowed-hosts', + 'Specify a comma-separated list of allowed hosts or allow any hostname.', ], + ['--help (-h)', 'See all available flags.'], + ], }, description: `Check ${colors.cyan( 'https://docs.astro.build/en/reference/cli-reference/#astro-dev', @@ -34,7 +52,85 @@ export async function dev({ flags }: DevOptions) { return; } + // When an AI coding agent is detected, enable background mode and JSON logging automatically. + const agentDetected = !process.env.ASTRO_DEV_BACKGROUND && isRunByAgent(); + if (agentDetected) { + flags.experimentalJson = true; + } + + const logger = createLoggerFromFlags(flags); + const subcommand = flags._[3]?.toString(); + + // Handle `astro dev stop` + if (subcommand === 'stop') { + const { stop } = await import('./stop.js'); + await stop({ flags, logger }); + return; + } + + // Handle `astro dev status` + if (subcommand === 'status') { + const { status } = await import('./status.js'); + await status({ flags, logger }); + return; + } + + // Handle `astro dev logs` + if (subcommand === 'logs') { + const { logs } = await import('./logs.js'); + await logs({ flags, logger }); + return; + } + + // Handle `astro dev --background` or auto-enable when an AI coding agent is detected. + // Skip if ASTRO_DEV_BACKGROUND is set — this means we're the spawned child process + // and should run the foreground dev server, not recurse into background mode. + if (flags.background || agentDetected) { + const { background } = await import('./background.js'); + await background({ flags, logger }); + return; + } + + // Unknown subcommand — exit with an error before starting the server. + if (subcommand) { + logger.error('SKIP_FORMAT', `Unknown command: astro dev ${subcommand}\n\nRun \`astro dev --help\` to see available commands.`); + process.exit(1); + } + + // Foreground dev server: check lock file, start server, write lock file + const root = pathToFileURL(resolveRoot(flags.root) + '/'); + const existingServer = checkExistingServer(root); + if (existingServer) { + const message = [ + 'Another astro dev server is already running.', + '', + ` URL: ${existingServer.url}`, + ` PID: ${existingServer.pid}`, + '', + `Run \`astro dev stop\` to stop it, or use \`astro dev --force\` to replace it.`, + ].join('\n'); + throw new Error(message); + } + const inlineConfig = flagsToAstroInlineConfig(flags); + const server = await devServer(inlineConfig); + + // Use Vite's resolved local URL which accounts for host and protocol (http/https). + const serverUrl = new URL(server.resolvedUrls.local[0]).origin; + writeLockFile(root, { + pid: process.pid, + port: server.address.port, + url: serverUrl, + background: !!process.env.ASTRO_DEV_BACKGROUND, + startedAt: new Date().toISOString(), + }); + + // Wrap the original stop to also clean up the lock file + const originalStop = server.stop.bind(server); + server.stop = async () => { + removeLockFile(root); + await originalStop(); + }; - return await devServer(inlineConfig); + return server; } diff --git a/packages/astro/src/cli/dev/logs.ts b/packages/astro/src/cli/dev/logs.ts new file mode 100644 index 000000000000..6c6caefa4660 --- /dev/null +++ b/packages/astro/src/cli/dev/logs.ts @@ -0,0 +1,86 @@ +import { readFileSync, existsSync, statSync, createReadStream, watch } from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { AstroLogger } from '../../core/logger/core.js'; +import type { Flags } from '../flags.js'; +import { checkExistingServer, getLogFileURL, isProcessAlive } from '../../core/dev/lockfile.js'; +import { resolveRoot } from '../../core/config/config.js'; + +export async function logs({ flags, logger }: { flags: Flags; logger: AstroLogger }): Promise { + const root = pathToFileURL(resolveRoot(flags.root) + '/'); + const existing = checkExistingServer(root); + + if (!existing) { + logger.error('SKIP_FORMAT', 'No dev server is running.'); + process.exit(1); + } + + if (!existing.background) { + logger.error( + 'SKIP_FORMAT', + 'The running dev server was not started with `astro dev --background`. View logs in the terminal where it was started.', + ); + process.exit(1); + } + + const logFileURL = getLogFileURL(root); + const logFilePath = fileURLToPath(logFileURL); + if (!existsSync(logFilePath)) { + logger.error('SKIP_FORMAT', 'No log file found.'); + process.exit(1); + } + + const follow = flags.follow || flags.f; + + if (!follow) { + const content = readFileSync(logFilePath, 'utf-8'); + process.stdout.write(content); + return; + } + + // --follow mode: print existing content, then watch for new data + let offset = statSync(logFilePath).size; + + // Print existing content + if (offset > 0) { + const content = readFileSync(logFilePath, 'utf-8'); + process.stdout.write(content); + } + + // Watch the file for changes and stream new bytes + const watcher = watch(logFilePath, () => { + const currentSize = statSync(logFilePath).size; + if (currentSize > offset) { + const stream = createReadStream(logFilePath, { start: offset, encoding: 'utf-8' }); + stream.on('data', (chunk) => process.stdout.write(chunk)); + stream.on('end', () => { + offset = currentSize; + }); + } + }); + + // Periodically check if the server process is still alive + const aliveCheck = setInterval(() => { + if (!isProcessAlive(existing.pid)) { + // Read any final bytes + const currentSize = statSync(logFilePath).size; + if (currentSize > offset) { + const remaining = readFileSync(logFilePath, { encoding: 'utf-8' }).slice(offset); + process.stdout.write(remaining); + } + watcher.close(); + clearInterval(aliveCheck); + } + }, 1000); + + // Clean up on termination signals + const cleanup = () => { + watcher.close(); + clearInterval(aliveCheck); + process.exit(0); + }; + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + + // Keep the process alive + await new Promise(() => {}); +} diff --git a/packages/astro/src/cli/dev/status.ts b/packages/astro/src/cli/dev/status.ts new file mode 100644 index 000000000000..0b404fc4fa64 --- /dev/null +++ b/packages/astro/src/cli/dev/status.ts @@ -0,0 +1,39 @@ +import type { AstroLogger } from '../../core/logger/core.js'; +import type { Flags } from '../flags.js'; +import { pathToFileURL } from 'node:url'; +import { checkExistingServer } from '../../core/dev/lockfile.js'; +import { resolveRoot } from '../../core/config/config.js'; + +export interface StatusResult { + running: boolean; + pid?: number; + url?: string; + port?: number; + background?: boolean; + uptime?: number; +} + +export function formatStatusOutput(result: StatusResult): string { + return JSON.stringify(result); +} + +export async function status({ + flags, + logger, +}: { flags: Flags; logger: AstroLogger }): Promise { + const root = pathToFileURL(resolveRoot(flags.root) + '/'); + const existing = checkExistingServer(root); + + if (!existing) { + logger.info('SKIP_FORMAT', 'No dev server is running.'); + return; + } + + const startedAt = new Date(existing.startedAt).getTime(); + const uptime = Math.floor((Date.now() - startedAt) / 1000); + + logger.info( + 'SKIP_FORMAT', + `Dev server running at ${existing.url} (pid ${existing.pid}, uptime ${uptime}s${existing.background ? ', background' : ''})`, + ); +} diff --git a/packages/astro/src/cli/dev/stop.ts b/packages/astro/src/cli/dev/stop.ts new file mode 100644 index 000000000000..68b6eec76c09 --- /dev/null +++ b/packages/astro/src/cli/dev/stop.ts @@ -0,0 +1,52 @@ +import type { AstroLogger } from '../../core/logger/core.js'; +import type { Flags } from '../flags.js'; +import { pathToFileURL } from 'node:url'; +import { checkExistingServer, removeLockFile, isProcessAlive, GRACEFUL_SHUTDOWN_TIMEOUT } from '../../core/dev/lockfile.js'; +import { resolveRoot } from '../../core/config/config.js'; + +export interface StopResult { + stopped: boolean; + pid?: number; + reason?: string; +} + +export function formatStopOutput(result: StopResult): string { + return JSON.stringify(result); +} + +export async function stop({ flags, logger }: { flags: Flags; logger: AstroLogger }): Promise { + const root = pathToFileURL(resolveRoot(flags.root) + '/'); + const existing = checkExistingServer(root); + + if (!existing) { + logger.info('SKIP_FORMAT', 'No dev server is running.'); + return; + } + + try { + process.kill(existing.pid, 'SIGTERM'); + } catch { + // Process may have already exited between check and kill + } + + // Wait for graceful shutdown before escalating to SIGKILL + const deadline = Date.now() + GRACEFUL_SHUTDOWN_TIMEOUT; + while (Date.now() < deadline) { + if (!isProcessAlive(existing.pid)) break; + await new Promise((r) => setTimeout(r, 100)); + } + + // If still alive after timeout, force kill + if (isProcessAlive(existing.pid)) { + try { + process.kill(existing.pid, 'SIGKILL'); + } catch { + // Already dead + } + } + + // Clean up the lock file in case the process didn't remove it + removeLockFile(root); + + logger.info('SKIP_FORMAT', `Stopped dev server (pid ${existing.pid}).`); +} diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index c1c4d7f7b23e..0250fdb68e06 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -55,6 +55,7 @@ import { isObject } from './util-runtime.js'; import { vitePluginEnvironment } from '../vite-plugin-environment/index.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from './constants.js'; import { vitePluginChromedevtools } from '../vite-plugin-chromedevtools/index.js'; +import { vitePluginDevStatus } from '../vite-plugin-dev-status/index.js'; import { vitePluginAstroServerClient } from '../vite-plugin-overlay/index.js'; type CreateViteOptions = { @@ -228,6 +229,7 @@ export async function createVite( astroContainer(), astroHmrReloadPlugin(), vitePluginChromedevtools({ settings }), + command === 'dev' && vitePluginDevStatus(), ], publicDir: fileURLToPath(settings.config.publicDir), root: fileURLToPath(settings.config.root), diff --git a/packages/astro/src/core/dev/dev.ts b/packages/astro/src/core/dev/dev.ts index 1d8dda04fd19..8bc91004422b 100644 --- a/packages/astro/src/core/dev/dev.ts +++ b/packages/astro/src/core/dev/dev.ts @@ -27,6 +27,7 @@ import { piccoloreTextStyler } from '../../cli/infra/piccolore-text-styler.js'; export interface DevServer { address: AddressInfo; + resolvedUrls: vite.ResolvedServerUrls; handle: (req: http.IncomingMessage, res: http.ServerResponse) => void; watcher: vite.FSWatcher; stop(): Promise; @@ -120,6 +121,7 @@ export default async function dev(inlineConfig: AstroInlineConfig): Promise { + it('formats a successful stop', () => { + const result = JSON.parse(formatStopOutput({ stopped: true, pid: 12345 })); + assert.equal(result.stopped, true); + assert.equal(result.pid, 12345); + }); + + it('formats a not-running stop', () => { + const result = JSON.parse(formatStopOutput({ stopped: false, reason: 'not_running' })); + assert.equal(result.stopped, false); + assert.equal(result.reason, 'not_running'); + }); + + it('produces valid JSON', () => { + const output = formatStopOutput({ stopped: true, pid: 1 }); + assert.doesNotThrow(() => JSON.parse(output)); + }); +}); +// #endregion + +// #region formatStatusOutput +describe('formatStatusOutput', () => { + it('formats a running server', () => { + const result = JSON.parse( + formatStatusOutput({ + running: true, + pid: 12345, + url: 'http://localhost:4321', + port: 4321, + background: false, + uptime: 3600, + }), + ); + assert.equal(result.running, true); + assert.equal(result.pid, 12345); + assert.equal(result.url, 'http://localhost:4321'); + assert.equal(result.port, 4321); + assert.equal(result.uptime, 3600); + }); + + it('formats a not-running server', () => { + const result = JSON.parse(formatStatusOutput({ running: false })); + assert.equal(result.running, false); + assert.equal(result.pid, undefined); + }); + + it('produces valid JSON', () => { + const output = formatStatusOutput({ running: false }); + assert.doesNotThrow(() => JSON.parse(output)); + }); +}); +// #endregion + +// #region formatBackgroundOutput +describe('formatBackgroundOutput', () => { + it('formats a successful background start', () => { + const result = JSON.parse( + formatBackgroundOutput({ + pid: 12345, + url: 'http://localhost:4321', + }), + ); + assert.equal(result.pid, 12345); + assert.equal(result.url, 'http://localhost:4321'); + }); + + it('formats an existing server response', () => { + const result = JSON.parse( + formatBackgroundOutput({ + pid: 12345, + url: 'http://localhost:4321', + existing: true, + }), + ); + assert.equal(result.existing, true); + }); + + it('formats an error response', () => { + const result = JSON.parse( + formatBackgroundOutput({ + error: 'timeout', + message: 'Dev server failed to start within 30s.', + }), + ); + assert.equal(result.error, 'timeout'); + assert.equal(result.message, 'Dev server failed to start within 30s.'); + }); + + it('produces valid JSON', () => { + const output = formatBackgroundOutput({ pid: 1, url: 'http://localhost:4321' }); + assert.doesNotThrow(() => JSON.parse(output)); + }); +}); +// #endregion diff --git a/packages/astro/test/units/dev/lockfile.test.ts b/packages/astro/test/units/dev/lockfile.test.ts new file mode 100644 index 000000000000..7fae3f55b06d --- /dev/null +++ b/packages/astro/test/units/dev/lockfile.test.ts @@ -0,0 +1,147 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + parseLockFile, + serializeLockFile, + evaluateExistingServer, + type LockFileData, +} from '../../../dist/core/dev/lockfile.js'; + +const validData: LockFileData = { + pid: 12345, + port: 4321, + url: 'http://localhost:4321', + background: false, + startedAt: '2026-05-05T10:00:00.000Z', +}; + +// #region parseLockFile +describe('parseLockFile', () => { + it('parses valid lock file JSON', () => { + const content = JSON.stringify(validData); + const result = parseLockFile(content); + assert.deepEqual(result, validData); + }); + + it('returns null for invalid JSON', () => { + assert.equal(parseLockFile('not json'), null); + }); + + it('returns null for empty string', () => { + assert.equal(parseLockFile(''), null); + }); + + it('returns null when pid is missing', () => { + const data = { ...validData, pid: undefined }; + assert.equal(parseLockFile(JSON.stringify(data)), null); + }); + + it('returns null when pid is not a number', () => { + const data = { ...validData, pid: 'abc' }; + assert.equal(parseLockFile(JSON.stringify(data)), null); + }); + + it('returns null when port is missing', () => { + const data = { ...validData, port: undefined }; + assert.equal(parseLockFile(JSON.stringify(data)), null); + }); + + it('returns null when port is not a number', () => { + const data = { ...validData, port: 'abc' }; + assert.equal(parseLockFile(JSON.stringify(data)), null); + }); + + it('returns null when url is missing', () => { + const data = { ...validData, url: undefined }; + assert.equal(parseLockFile(JSON.stringify(data)), null); + }); + + it('returns null when url is not a string', () => { + const data = { ...validData, url: 123 }; + assert.equal(parseLockFile(JSON.stringify(data)), null); + }); + + it('returns null when background is missing', () => { + const data = { ...validData, background: undefined }; + assert.equal(parseLockFile(JSON.stringify(data)), null); + }); + + it('returns null when background is not a boolean', () => { + const data = { ...validData, background: 'true' }; + assert.equal(parseLockFile(JSON.stringify(data)), null); + }); + + it('returns null when startedAt is missing', () => { + const data = { ...validData, startedAt: undefined }; + assert.equal(parseLockFile(JSON.stringify(data)), null); + }); + + it('returns null when startedAt is not a string', () => { + const data = { ...validData, startedAt: 123 }; + assert.equal(parseLockFile(JSON.stringify(data)), null); + }); + + it('returns null for an empty object', () => { + assert.equal(parseLockFile('{}'), null); + }); + + it('returns null for an array', () => { + assert.equal(parseLockFile('[]'), null); + }); + + it('preserves extra fields without error', () => { + const data = { ...validData, extra: 'field' }; + const result = parseLockFile(JSON.stringify(data)); + assert.notEqual(result, null); + assert.equal(result!.pid, validData.pid); + }); + + it('handles background: true', () => { + const data = { ...validData, background: true }; + const result = parseLockFile(JSON.stringify(data)); + assert.notEqual(result, null); + assert.equal(result!.background, true); + }); +}); +// #endregion + +// #region serializeLockFile +describe('serializeLockFile', () => { + it('produces valid JSON that round-trips through parseLockFile', () => { + const serialized = serializeLockFile(validData); + const parsed = parseLockFile(serialized); + assert.deepEqual(parsed, validData); + }); + + it('uses tabs for indentation', () => { + const serialized = serializeLockFile(validData); + assert.ok(serialized.includes('\t')); + }); +}); +// #endregion + +// #region evaluateExistingServer +describe('evaluateExistingServer', () => { + it('returns null when data is null', () => { + assert.equal(evaluateExistingServer(null, false), null); + }); + + it('returns null when data is null even if alive is true', () => { + assert.equal(evaluateExistingServer(null, true), null); + }); + + it('returns stale: false when process is alive', () => { + const result = evaluateExistingServer(validData, true); + assert.notEqual(result, null); + assert.equal(result!.stale, false); + assert.deepEqual(result!.data, validData); + }); + + it('returns stale: true when process is not alive', () => { + const result = evaluateExistingServer(validData, false); + assert.notEqual(result, null); + assert.equal(result!.stale, true); + assert.deepEqual(result!.data, validData); + }); +}); +// #endregion diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36d2b63d5c5e..fbb83bb94a65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -551,6 +551,9 @@ importers: '@rollup/pluginutils': specifier: ^5.3.0 version: 5.3.0 + am-i-vibing: + specifier: ^0.3.0 + version: 0.3.0 aria-query: specifier: ^5.3.2 version: 5.3.2 @@ -10921,6 +10924,10 @@ packages: alpinejs@3.15.8: resolution: {integrity: sha512-zxIfCRTBGvF1CCLIOMQOxAyBuqibxSEwS6Jm1a3HGA9rgrJVcjEWlwLcQTVGAWGS8YhAsTRLVrtQ5a5QT9bSSQ==} + am-i-vibing@0.3.0: + resolution: {integrity: sha512-JTg9e3qPmVP6x8PG9/J4+eCVP6sGx9oS1pSbgf7fpPVlkHzQdRPSNMb6dBlp9BrdBNxDkctMzehPVYSnt16k4w==} + hasBin: true + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -14611,6 +14618,10 @@ packages: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} + process-ancestry@0.1.0: + resolution: {integrity: sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==} + engines: {node: '>=18.0.0'} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -21268,6 +21279,10 @@ snapshots: dependencies: '@vue/reactivity': 3.1.5 + am-i-vibing@0.3.0: + dependencies: + process-ancestry: 0.1.0 + ansi-colors@4.1.3: {} ansi-escapes@5.0.0: @@ -25496,6 +25511,8 @@ snapshots: prismjs@1.30.0: {} + process-ancestry@0.1.0: {} + process-nextick-args@2.0.1: {} process-warning@4.0.1: {}