diff --git a/packages/devcontainer-node/src/devcontainerCli.d.ts b/packages/devcontainer-node/src/devcontainerCli.d.ts index 91bb942..628a1b4 100644 --- a/packages/devcontainer-node/src/devcontainerCli.d.ts +++ b/packages/devcontainer-node/src/devcontainerCli.d.ts @@ -41,3 +41,9 @@ export function forwardPorts( }, ): Promise export function disposeForwardPorts(): void + +export function getLifecycleProgress(workspaceFolder: string): { + errorMessage?: string + output: string + running: boolean +} diff --git a/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts b/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts index d92788e..5c27bca 100644 --- a/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts +++ b/packages/devcontainer-node/src/parts/DevContainerCli/DevContainerCli.ts @@ -1,9 +1,13 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { ErrorResult } from '../SerializeError/SerializeError.ts' import * as CliError from '../CliError/CliError.ts' import * as CliJson from '../CliJson/CliJson.ts' import * as ContainerFileSystem from '../ContainerFileSystem/ContainerFileSystem.ts' import * as ForwardPorts from '../ForwardPorts/ForwardPorts.ts' +import * as LifecycleCommands from '../LifecycleCommands/LifecycleCommands.ts' import * as RunProcess from '../RunProcess/RunProcess.ts' export interface CliCommandSuccess { @@ -43,6 +47,11 @@ export interface WorkspaceOptions { workspaceFolder: string } +interface CliUpOptions extends WorkspaceOptions { + overrideConfig?: string + skipNonBlockingCommands?: boolean +} + export interface ExecOptions extends WorkspaceOptions { args?: readonly string[] command: string @@ -86,10 +95,14 @@ export const getCliReadConfigurationArgs = ({ export const getCliUpArgs = ({ containerCli = dockerPath, + overrideConfig, + skipNonBlockingCommands = false, workspaceFolder, -}: WorkspaceOptions) => { +}: CliUpOptions) => { return [ 'up', + ...(skipNonBlockingCommands ? ['--skip-non-blocking-commands'] : []), + ...(overrideConfig ? ['--override-config', overrideConfig] : []), '--workspace-folder', workspaceFolder, '--no-lockfile', @@ -103,6 +116,19 @@ export const getCliUpArgs = ({ ] } +export const getDefaultWaitForOverride = ( + configuration: unknown, +): Record | undefined => { + if ( + !configuration || + typeof configuration !== 'object' || + Object.hasOwn(configuration, 'waitFor') + ) { + return undefined + } + return { ...configuration, waitFor: 'postCreateCommand' } +} + export const getCliExecArgs = ({ args = [], command, @@ -275,18 +301,54 @@ export const cliReadConfiguration = (options: WorkspaceOptions) => { export const cliUp = async ( options: WorkspaceOptions, ): Promise => { - const result = await runDevcontainerCli( - 'DevContainerNode.cliUp', - [getDevcontainerCliPath(), ...getCliUpArgs(options)], - options.containerCli, - options.onOutput, - ) + const configurationResult = await cliReadConfiguration(options) + const configuration = ( + configurationResult.json as { configuration?: unknown } | undefined + )?.configuration + const hasExplicitWaitFor = + configuration !== null && + typeof configuration === 'object' && + Object.hasOwn(configuration, 'waitFor') + const waitForOverride = configurationResult.ok + ? getDefaultWaitForOverride(configuration) + : undefined + const skipNonBlockingCommands = hasExplicitWaitFor || Boolean(waitForOverride) + let overrideConfigDirectory: string | undefined + let result: CliCommandResult + try { + let overrideConfig: string | undefined + if (waitForOverride) { + overrideConfigDirectory = await mkdtemp( + join(tmpdir(), 'lvce-devcontainer-up-'), + ) + overrideConfig = join(overrideConfigDirectory, 'devcontainer.json') + await writeFile(overrideConfig, JSON.stringify(waitForOverride)) + } + result = await runDevcontainerCli( + 'DevContainerNode.cliUp', + [ + getDevcontainerCliPath(), + ...getCliUpArgs({ + ...options, + overrideConfig, + skipNonBlockingCommands, + }), + ], + options.containerCli, + options.onOutput, + ) + } finally { + if (overrideConfigDirectory) { + await rm(overrideConfigDirectory, { force: true, recursive: true }) + } + } if (!result.ok) return result const json = result.json as { containerId: string remoteUser?: string mergedConfiguration?: { forwardPorts?: unknown } } + let setupErrorCode = 'DEVCONTAINER_FORWARD_PORTS_ERROR' try { await ForwardPorts.ensure({ containerCli: options.containerCli ?? dockerPath, @@ -295,11 +357,20 @@ export const cliUp = async ( remoteUser: json.remoteUser, workspaceFolder: options.workspaceFolder, }) + if (skipNonBlockingCommands) { + setupErrorCode = 'DEVCONTAINER_LIFECYCLE_ERROR' + await LifecycleCommands.start( + options.workspaceFolder, + json.containerId, + getDevcontainerCliPath(), + options.containerCli ?? dockerPath, + ) + } return result } catch (error) { return { commandName: result.commandName, - errorCode: 'DEVCONTAINER_FORWARD_PORTS_ERROR', + errorCode: setupErrorCode, errorMessage: error instanceof Error ? error.message : String(error), errorStack: undefined, json: result.json, @@ -317,7 +388,11 @@ export const forwardPorts = ( remoteUser?: string }, ): Promise => ForwardPorts.ensure(options) -export const disposeForwardPorts = ForwardPorts.dispose +export const disposeForwardPorts = () => { + LifecycleCommands.dispose() + ForwardPorts.dispose() +} +export const getLifecycleProgress = LifecycleCommands.getProgress export const cliExec = (options: ExecOptions) => { return runDevcontainerCommand( @@ -328,6 +403,7 @@ export const cliExec = (options: ExecOptions) => { } export const dockerStopContainer = async (options: ContainerOptions) => { + LifecycleCommands.remove(options.containerId) await ForwardPorts.remove(options.containerId) return runDocker( 'DevContainerNode.dockerStopContainer', @@ -337,6 +413,7 @@ export const dockerStopContainer = async (options: ContainerOptions) => { } export const dockerRemoveContainer = async (options: ContainerOptions) => { + LifecycleCommands.remove(options.containerId) await ForwardPorts.remove(options.containerId) return runDocker( 'DevContainerNode.dockerRemoveContainer', diff --git a/packages/devcontainer-node/src/parts/LifecycleCommands/LifecycleCommands.ts b/packages/devcontainer-node/src/parts/LifecycleCommands/LifecycleCommands.ts new file mode 100644 index 0000000..e48bd1f --- /dev/null +++ b/packages/devcontainer-node/src/parts/LifecycleCommands/LifecycleCommands.ts @@ -0,0 +1,119 @@ +import type { ChildProcessWithoutNullStreams } from 'node:child_process' +import { spawn } from 'node:child_process' +import { join } from 'node:path' + +interface Operation { + child: ChildProcessWithoutNullStreams + containerId: string + errorMessage?: string + output: string + running: boolean +} + +const operations = new Map() +const limit = 1024 * 1024 + +export const getProgress = (workspaceFolder: string) => { + const value = operations.get(workspaceFolder) + return { + errorMessage: value?.errorMessage, + output: value?.output ?? '', + running: value?.running ?? false, + } +} + +const terminate = (value: Operation): void => { + if (!value.running || !value.child.pid) return + if (process.platform === 'win32') { + spawn( + join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'), + ['/pid', String(value.child.pid), '/T', '/F'], + { + stdio: 'ignore', + }, + ).on('error', () => value.child.kill()) + } else { + try { + process.kill(-value.child.pid, 'SIGTERM') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error + } + } +} + +export const remove = (containerId: string): void => { + for (const [workspace, value] of operations) { + if (value.containerId !== containerId) continue + operations.delete(workspace) + terminate(value) + } +} + +export const dispose = (): void => { + for (const value of operations.values()) terminate(value) + operations.clear() +} + +export const start = async ( + workspaceFolder: string, + containerId: string, + cliPath: string, + containerCli: string, +): Promise => { + const previous = operations.get(workspaceFolder) + if (previous?.running && previous.containerId === containerId) return + if (previous) { + operations.delete(workspaceFolder) + terminate(previous) + } + const child = spawn( + process.execPath, + [ + cliPath, + 'run-user-commands', + '--workspace-folder', + workspaceFolder, + '--container-id', + containerId, + '--docker-path', + containerCli, + '--log-format', + 'text', + ], + { + detached: process.platform !== 'win32', + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + }, + ) + const value: Operation = { child, containerId, output: '', running: true } + operations.set(workspaceFolder, value) + const append = (chunk: string) => { + value.output = (value.output + chunk).slice(-limit) + } + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', append) + child.stderr.on('data', append) + child.stdin.on('error', (error: NodeJS.ErrnoException) => { + if (error.code !== 'EPIPE') append(String(error)) + }) + child.stdin.end() + child.on('error', (error) => { + value.running = false + value.errorMessage = `Devcontainer lifecycle commands failed: ${error.message}` + append(value.errorMessage) + }) + child.once('close', (code, signal) => { + value.running = false + if (operations.get(workspaceFolder) !== value) return + if (code !== 0) { + const reason = signal ?? `exit code ${code}` + value.errorMessage = `Devcontainer lifecycle commands failed (${reason}). See Dev Containers output.` + append(`\n${value.errorMessage}\n`) + } + }) + await new Promise((resolve, reject) => { + child.once('spawn', resolve) + child.once('error', reject) + }) +} diff --git a/packages/devcontainer-node/test/DevContainerCli.test.ts b/packages/devcontainer-node/test/DevContainerCli.test.ts index 57516f4..2795465 100644 --- a/packages/devcontainer-node/test/DevContainerCli.test.ts +++ b/packages/devcontainer-node/test/DevContainerCli.test.ts @@ -38,6 +38,49 @@ test('getCliUpArgs', () => { ]) }) +test('getCliUpArgs stops at the readiness boundary when requested', () => { + expect( + DevContainerCli.getCliUpArgs({ + skipNonBlockingCommands: true, + workspaceFolder: '/workspace', + }), + ).toContain('--skip-non-blocking-commands') +}) + +test('getCliUpArgs accepts a lifecycle readiness override', () => { + expect( + DevContainerCli.getCliUpArgs({ + overrideConfig: '/tmp/devcontainer-override.json', + workspaceFolder: '/workspace', + }), + ).toContain('--override-config') + expect( + DevContainerCli.getCliUpArgs({ + overrideConfig: '/tmp/devcontainer-override.json', + workspaceFolder: '/workspace', + }), + ).toContain('/tmp/devcontainer-override.json') +}) + +test('getDefaultWaitForOverride waits through post-create only when unset', () => { + expect( + DevContainerCli.getDefaultWaitForOverride({ + features: { 'example/feature': {} }, + image: 'alpine', + postCreateCommand: 'npm install', + }), + ).toEqual({ + features: { 'example/feature': {} }, + image: 'alpine', + postCreateCommand: 'npm install', + waitFor: 'postCreateCommand', + }) + expect( + DevContainerCli.getDefaultWaitForOverride({ waitFor: 'postStartCommand' }), + ).toBeUndefined() + expect(DevContainerCli.getDefaultWaitForOverride(undefined)).toBeUndefined() +}) + test('getCliExecArgs', () => { expect( DevContainerCli.getCliExecArgs({ diff --git a/packages/devcontainer-node/test/LifecycleCommands.test.ts b/packages/devcontainer-node/test/LifecycleCommands.test.ts new file mode 100644 index 0000000..cadd385 --- /dev/null +++ b/packages/devcontainer-node/test/LifecycleCommands.test.ts @@ -0,0 +1,96 @@ +import { afterEach, expect, test } from '@jest/globals' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as LifecycleCommands from '../src/parts/LifecycleCommands/LifecycleCommands.ts' + +const directories: string[] = [] +const fixture = async (script: string): Promise => { + const directory = await mkdtemp(join(tmpdir(), 'lvce-lifecycle-')) + directories.push(directory) + const path = join(directory, 'cli.cjs') + await writeFile(path, script) + return path +} +const waitFor = async (predicate: () => boolean): Promise => { + const deadline = Date.now() + 5000 + while (!predicate()) { + if (Date.now() > deadline) throw new Error('Lifecycle state did not settle') + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +afterEach(async () => { + LifecycleCommands.dispose() + await Promise.all( + directories + .splice(0) + .map((path) => rm(path, { force: true, recursive: true })), + ) +}) + +test('persistent hooks stream bounded logs without blocking or launching twice', async () => { + const path = await fixture(` + process.stdout.write(JSON.stringify(process.argv.slice(2)) + '\\n'); + process.stdout.write('x'.repeat(2 * 1024 * 1024)); + process.stdout.write('ready'); + setInterval(() => {}, 1000); + `) + await LifecycleCommands.start( + '/workspace', + 'container', + path, + '/tools/docker', + ) + await waitFor(() => + LifecycleCommands.getProgress('/workspace').output.endsWith('ready'), + ) + const before = LifecycleCommands.getProgress('/workspace') + expect(before.running).toBe(true) + expect(before.output).toHaveLength(1024 * 1024) + await LifecycleCommands.start( + '/workspace', + 'container', + path, + '/tools/docker', + ) + expect(LifecycleCommands.getProgress('/workspace')).toEqual(before) + LifecycleCommands.remove('container') + expect(LifecycleCommands.getProgress('/workspace')).toEqual({ + output: '', + running: false, + }) +}) + +test('deferred failures retain diagnostics and allow another operation', async () => { + const path = await fixture(` + console.log(JSON.stringify(process.argv.slice(2))); + console.error('postStartCommand failed'); + process.exitCode = 7; + `) + await LifecycleCommands.start( + '/workspace', + 'container', + path, + '/tools/docker', + ) + await waitFor(() => !LifecycleCommands.getProgress('/workspace').running) + const result = LifecycleCommands.getProgress('/workspace') + expect(result.errorMessage).toContain('exit code 7') + expect(result.output).toContain('postStartCommand failed') + expect(result.output).toContain('run-user-commands') + expect(result.output).toContain('/tools/docker') + await LifecycleCommands.start( + '/workspace', + 'container', + await fixture(`console.log('repaired')`), + '/tools/docker', + ) + await waitFor(() => !LifecycleCommands.getProgress('/workspace').running) + expect( + LifecycleCommands.getProgress('/workspace').errorMessage, + ).toBeUndefined() + expect(LifecycleCommands.getProgress('/workspace').output).toContain( + 'repaired', + ) +}) diff --git a/packages/devcontainer-worker/src/devcontainerWorkerModule.ts b/packages/devcontainer-worker/src/devcontainerWorkerModule.ts index 0418df4..807cba6 100644 --- a/packages/devcontainer-worker/src/devcontainerWorkerModule.ts +++ b/packages/devcontainer-worker/src/devcontainerWorkerModule.ts @@ -7,6 +7,7 @@ import * as DevContainerCommandType from './parts/DevContainerCommandType/DevCon import * as DevContainerNodeClient from './parts/DevContainerNodeClient/DevContainerNodeClient.ts' import * as GetDockerInstallCommand from './parts/GetDockerInstallCommand/GetDockerInstallCommand.ts' import * as Progress from './parts/Progress/Progress.ts' +import * as WorkspaceFolder from './parts/WorkspaceFolder/WorkspaceFolder.ts' let initialized = false const initialize = () => { @@ -85,6 +86,10 @@ export const commandMap = { 'DevContainer.disposeTerminal': ContainerTerminalSession.dispose, 'DevContainer.getDockerInstallCommand': () => GetDockerInstallCommand.getDockerInstallCommand(process.platform), + 'DevContainer.getLifecycleProgress': async (workspaceFolder: string) => + DevContainerCli.getLifecycleProgress( + await WorkspaceFolder.toPath(workspaceFolder), + ), 'DevContainer.getProgress': Progress.getProgress, 'DevContainer.getTerminalSpawnOptions': getTerminalSpawnOptions, [DevContainerCommandType.DevContainerDetect]: detect, diff --git a/packages/e2e/fixtures/reopen-persistent/.devcontainer/Dockerfile b/packages/e2e/fixtures/reopen-persistent/.devcontainer/Dockerfile new file mode 100644 index 0000000..022fd8f --- /dev/null +++ b/packages/e2e/fixtures/reopen-persistent/.devcontainer/Dockerfile @@ -0,0 +1,2 @@ +FROM mcr.microsoft.com/devcontainers/base:ubuntu24.04 +RUN mkdir /container-workspace && printf 'created inside the devcontainer\n' > /container-workspace/container-only.txt diff --git a/packages/e2e/fixtures/reopen-persistent/.devcontainer/devcontainer.json b/packages/e2e/fixtures/reopen-persistent/.devcontainer/devcontainer.json new file mode 100644 index 0000000..dec1ecd --- /dev/null +++ b/packages/e2e/fixtures/reopen-persistent/.devcontainer/devcontainer.json @@ -0,0 +1,10 @@ +{ + "name": "Reopen persistent post-start command", + "build": { + "dockerfile": "Dockerfile" + }, + "workspaceMount": "source=${localWorkspaceFolder},target=/host-workspace,type=bind", + "workspaceFolder": "/container-workspace", + "remoteUser": "root", + "postStartCommand": "sh -c 'echo started > /tmp/devcontainer-post-start && echo $$ > /tmp/devcontainer-post-start.pid && exec sleep 300'" +} diff --git a/packages/e2e/fixtures/reopen-persistent/host-only.txt b/packages/e2e/fixtures/reopen-persistent/host-only.txt new file mode 100644 index 0000000..007bfb9 --- /dev/null +++ b/packages/e2e/fixtures/reopen-persistent/host-only.txt @@ -0,0 +1 @@ +This file exists only in the host workspace. diff --git a/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts b/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts new file mode 100644 index 0000000..8f019a3 --- /dev/null +++ b/packages/e2e/src/devcontainer.reopen-persistent-post-start.ts @@ -0,0 +1,88 @@ +import type { Test } from '@lvce-editor/test-worker' +import { getWorkspaceUri } from '../helpers/getWorkspaceUri.ts' +import { waitForContainerWorkspace } from '../helpers/waitForContainerWorkspace.ts' + +export const name = 'devcontainer.reopen-persistent-post-start' + +export const test: Test = async ({ + Command, + Devcontainer, + expect, + Locator, + QuickPick, + Workspace, +}) => { + const localUri = await getWorkspaceUri({ Command }, 'reopen-persistent') + await Workspace.setPath(localUri) + try { + await QuickPick.open() + await QuickPick.setValue('>Dev Containers: Reopen in Container') + await QuickPick.selectItem('Dev Containers: Reopen in Container') + const workspaceUri = await waitForContainerWorkspace({ Command }) + const containerFile = Locator( + '.Explorer .TreeItem[aria-label="container-only.txt"]', + ) + await expect(containerFile).toBeVisible() + // The readiness boundary precedes the deferred hook. Wait for its actual + // marker, then assert it is alive while the workspace is usable. + await Devcontainer.exec('sh', [ + '-c', + 'for i in $(seq 1 200); do test -f /tmp/devcontainer-post-start && exit 0; sleep 0.1; done; exit 1', + ]) + await Devcontainer.shouldHaveExecOutput( + 'cat', + ['/tmp/devcontainer-post-start'], + 'started\n', + ) + await Devcontainer.shouldHaveExecOutput( + 'sh', + [ + '-c', + 'kill -0 "$(cat /tmp/devcontainer-post-start.pid)" && echo running', + ], + 'running\n', + ) + await Devcontainer.shouldHaveExecOutput( + 'cat', + ['/container-workspace/container-only.txt'], + 'created inside the devcontainer\n', + ) + // Workspace handoff closes the panel; reopening its existing channel must + // show the deferred hook's retained output. + await Command.execute('Layout.openOutput', 'dev-containers') + const output = Locator('.Output') + await expect(output).toContainText('Running the postStartCommand') + await Command.execute('Layout.showPanel', 'Terminals') + const terminal = Locator('.XtermTerminal') + await expect(terminal).toBeVisible() + await expect(terminal).toContainText('# ') + await Command.execute( + 'Terminals.sendText', + 'printf "persistent-%s" terminal > /tmp/terminal-acceptance; printf "terminal-%s\\n" ready\r', + ) + await expect(terminal).toContainText('terminal-ready') + await Devcontainer.shouldHaveExecOutput( + 'cat', + ['/tmp/terminal-acceptance'], + 'persistent-terminal', + ) + const pid = await Devcontainer.exec('cat', [ + '/tmp/devcontainer-post-start.pid', + ]) + await Command.executeExtensionCommand('devcontainer.openWorkspace') + await waitForContainerWorkspace({ Command }) + await Devcontainer.shouldHaveExecOutput( + 'cat', + ['/tmp/devcontainer-post-start.pid'], + pid, + ) + if ((await Command.execute('Workspace.getUri')) !== workspaceUri) { + throw new Error( + 'The persistent post-start command blocked workspace handoff', + ) + } + } finally { + await Workspace.setPath(localUri) + await Devcontainer.remove() + } +} diff --git a/packages/extension/src/parts/Progress/Progress.ts b/packages/extension/src/parts/Progress/Progress.ts index 269ef40..c10d3d6 100644 --- a/packages/extension/src/parts/Progress/Progress.ts +++ b/packages/extension/src/parts/Progress/Progress.ts @@ -2,11 +2,13 @@ import { createOutputChannel, executeCommand, openOutputView, + showNotification, } from '@lvce-editor/api' import * as Rpc from '../Rpc/Rpc.ts' let output: ReturnType | undefined let busy = false +let monitorGeneration = 0 const refreshOutput = async (): Promise => { try { @@ -31,6 +33,7 @@ export const run = async ( throw new Error('A Dev Containers operation is already in progress') } busy = true + const generation = ++monitorGeneration try { output ||= createOutputChannel('dev-containers') const channel = output @@ -73,11 +76,19 @@ export const run = async ( } const polling = poll() try { - return await Rpc.invoke(method, { + const result = await Rpc.invoke(method, { containerCli, progressId, workspaceFolder, }) + if (result && typeof result === 'object' && 'ok' in result && result.ok) { + // Lifecycle commands have their own lifetime; they do not hold the + // startup guard while the editor is connected to the container. + setTimeout(() => { + void monitorLifecycle(workspaceFolder, generation) + }, 0) + } + return result } finally { finished = true completed.resolve() @@ -92,3 +103,34 @@ export const run = async ( busy = false } } + +const monitorLifecycle = async ( + workspaceFolder: string, + generation: number, +): Promise => { + let previous = '' + try { + while (generation === monitorGeneration) { + const state = (await Rpc.invoke( + 'DevContainer.getLifecycleProgress', + workspaceFolder, + )) as { errorMessage?: string; output: string; running: boolean } + if (generation !== monitorGeneration) return + if (state.output !== previous) { + const text = state.output.startsWith(previous) + ? state.output.slice(previous.length) + : state.output + previous = state.output + await appendLine(text) + } + if (state.errorMessage) + await showNotification('error', state.errorMessage) + if (!state.running) return + await new Promise((resolve) => setTimeout(resolve, 250)) + } + } catch (error) { + if (generation === monitorGeneration) { + await appendLine(`Unable to read lifecycle progress: ${String(error)}`) + } + } +} diff --git a/packages/extension/test/DevContainerProgress.test.ts b/packages/extension/test/DevContainerProgress.test.ts index 3f39c64..fe64d14 100644 --- a/packages/extension/test/DevContainerProgress.test.ts +++ b/packages/extension/test/DevContainerProgress.test.ts @@ -3,6 +3,12 @@ import { mock, test } from 'node:test' const events: string[] = [] const pending = Promise.withResolvers() +let startupResult: unknown = pending.promise +let lifecycle = { + errorMessage: undefined as string | undefined, + output: '', + running: false, +} mock.module('@lvce-editor/api', { namedExports: { closeUri: async () => {}, @@ -20,7 +26,9 @@ mock.module('@lvce-editor/api', { events.push('output opened') }, openUri: async () => {}, - showNotification: async () => {}, + showNotification: async (_type: string, message: string) => { + events.push(message) + }, }, }) mock.module('../src/parts/Workspace/Workspace.ts', { @@ -30,8 +38,9 @@ mock.module('../src/parts/Rpc/Rpc.ts', { namedExports: { invoke: async (method: string) => { if (method === 'DevContainer.getProgress') return 'Building layer 1\n' + if (method === 'DevContainer.getLifecycleProgress') return lifecycle events.push('build started') - return pending.promise + return startupResult }, }, }) @@ -61,3 +70,25 @@ await test('reopen opens output and displays logs before the build finishes', as ) } }) + +await test('deferred lifecycle output and failures remain visible after startup releases its guard', async () => { + const { run } = await import('../src/parts/Progress/Progress.ts') + startupResult = { ok: true } + lifecycle = { + errorMessage: undefined, + output: 'server started', + running: true, + } + await run('DevContainer.openWorkspace', 'file:///workspace', 'docker') + await new Promise((resolve) => setTimeout(resolve, 30)) + assert.ok(events.includes('server started')) + lifecycle = { + errorMessage: 'postStart failed', + output: 'server started\nexit 7', + running: false, + } + await new Promise((resolve) => setTimeout(resolve, 300)) + assert.ok(events.includes('postStart failed')) + assert.ok(events.some((text) => text.includes('exit 7'))) + await run('DevContainer.openWorkspace', 'file:///workspace', 'docker') +})