diff --git a/.changeset/converter-sandbox-runtime.md b/.changeset/converter-sandbox-runtime.md new file mode 100644 index 0000000..bcb951b --- /dev/null +++ b/.changeset/converter-sandbox-runtime.md @@ -0,0 +1,5 @@ +--- +'@deckflow/deckuse': patch +--- + +Inherit host .NET extraction settings instead of forcing a fresh temporary cache. Share converter execution across render and monitor, preserve license arguments and native failure details, and report browser probe cleanup failures independently from write/launch availability. diff --git a/docs/verification-converter-sandbox.md b/docs/verification-converter-sandbox.md new file mode 100644 index 0000000..469aac8 --- /dev/null +++ b/docs/verification-converter-sandbox.md @@ -0,0 +1,29 @@ +# Converter startup in restricted hosts + +## Changes + +Deckuse no longer injects a temporary `DOTNET_BUNDLE_EXTRACT_BASE_DIR`. Explicit settings are inherited unchanged; otherwise the runtime chooses its default. `extractionBase: null` with `extractionSource: runtime-default` means the actual runtime path was not observed. Render and monitor share binary selection, arguments, bounded execution and native error classification. Errors preserve exit code/signal and identify directory, write or commit extraction failures when the runtime message permits it. `retryable: false` disallows unchanged automatic retries, not recovery after a host configuration change. + +Browser preflight reports cleanup failures in `cleanupWarnings`, with the retained probe path. A failed write remains the primary failure even when cleanup also fails. A successful write plus failed cleanup does not establish that launching a browser is impossible. Actual launch remains separately tested, and launch failure caching excludes varying cleanup paths. + +The companion deck2html change ships complete self-contained multi-file runtimes. All platform wrappers, packaging whitelists, postinstall, local installation, build and edition-scan scripts use that layout. No installation hook runs the converter or warms caches. CLI and API signatures remain unchanged; consumers should obtain the executable through `getBinaryPath()`. + +## Verification performed + +- Deckuse build, TypeScript no-emit check, changed-source ESLint and diff whitespace checks passed. +- Six targeted test files passed (45 tests), then the added real-process monitor environment regression passed with its suite (17 tests; 46 distinct targeted cases overall). +- Built the macOS arm64 multi-file runtime and passed the edition scan. +- Packed the actual platform and main npm packages, installed the tarballs into an isolated job, and tested help/version plus PPTX and DOCX conversion. A final package-smoke run also passed both installed JavaScript APIs (`convert` and `convertSync`). +- The package smoke also ran native conversions in a macOS sandbox denying all writes to the selected extraction path. No extraction directory was created. This tests bundle independence, not every WorkBuddy policy. +- Deckuse preflight with a real Playwright browser passed. The installed runtime produced PPTX (1280×720) and DOCX (816×1056) PNGs through deckuse. +- Against the old installed 0.6.2 single-file converter using its existing cache, both sample page screenshots were pixel-identical. The PPTX fixture reported existing unresolved-font warnings in the test environment; this is not native Office fidelity certification. + +Local artifacts: `../deck2html/artifacts/runtime-package-GS2zsc/` contains package-test results, installed packages, per-format command JSON and screenshots. The final CLI/API package-smoke artifacts are in `../deck2html/artifacts/runtime-package-fu3oXZ/`. Artifacts are ignored by Git and retained for inspection. + +## Release gates still outstanding + +The four-platform `runtime-package.yml` workflow has been added but was not run remotely. Windows x64, Linux x64 and macOS x64 execution, plus the original WorkBuddy policy, remain unverified. The local .NET build also reported existing ImageSharp 3.1.12 NuGet advisory warnings; dependency remediation is outside this change. + +Do not publish the currently versioned 0.6.2 payload over the existing release. Assign a new deck2html version using its version script, run the four-platform package tests and release that version first. Then raise deckuse's `@deckflow/deck2html` minimum to the published version and regenerate the package-manager lockfile. The current dependency/lockfile deliberately remains installable against the existing registry; the new runtime was exercised via `DECKUSE_DECK2HTML_BINARY`. No registry publishing was performed. + +For target-host verification, fix the user, policy, input, binary hash and environment; compare CLI and deckuse with distinct test outputs. Preserve user caches. Do not cycle directories or repeat equivalent denied operations. Report converter startup, browser launch and cleanup independently. diff --git a/scripts/assert-no-commercial-leak.mjs b/scripts/assert-no-commercial-leak.mjs index eba9baa..c610fd3 100644 --- a/scripts/assert-no-commercial-leak.mjs +++ b/scripts/assert-no-commercial-leak.mjs @@ -130,7 +130,14 @@ const underSource = (p) => p === sourceRoot || p.startsWith(`${sourceRoot}${sep} const sourceFiles = allFiles.filter((p) => sourceExt.test(p) && underSource(p)); for (const file of sourceFiles) { - const text = readFileSync(file, 'utf8'); + const originalText = readFileSync(file, 'utf8'); + // The public deck2html API already accepts a license path. Permit exactly one + // argument-map entry forwarding it to that dependency, not Deckuse activation, + // verification, CLI parsing, or any other license implementation. + const text = + relative(root, file).split(sep).join('/') === 'src/deck2html-exec.ts' + ? originalText.replace("['license', '--license'],", '') + : originalText; for (const marker of FORBIDDEN_LICENSE_MARKERS) { if (text.includes(marker)) { errors.push(`Forbidden license/certificate marker "${marker}" in ${relative(root, file)}`); diff --git a/scripts/check-agent-package.mjs b/scripts/check-agent-package.mjs index 1e1b746..f387f91 100644 --- a/scripts/check-agent-package.mjs +++ b/scripts/check-agent-package.mjs @@ -171,18 +171,23 @@ await writeFile( ); const timeToOperationsMs = performance.now() - start; await writeFile(generatedNames, JSON.stringify(inventory)); -for (const [fault, code] of [ - ['create', 'BROWSER_CACHE_UNWRITABLE'], - ['cleanup', 'BROWSER_PREFLIGHT_CLEANUP_FAILED'], -]) { - const result = probeCli(['render', '--preflight'], fault); - if ( - result.status !== 1 || - result.envelope.error.code !== code || - (fault === 'cleanup' && result.envelope.error.details.previewSupported !== false) - ) - throw new Error('Incorrect browser preflight diagnostic: ' + JSON.stringify(result)); -} +const createProbe = probeCli(['render', '--preflight'], 'create'); +if (createProbe.status !== 1 || createProbe.envelope.error?.code !== 'BROWSER_CACHE_UNWRITABLE') + throw new Error('Incorrect browser create diagnostic: ' + JSON.stringify(createProbe)); +const cleanupProbe = probeCli(['render', '--preflight'], 'cleanup'); +const cleanupWarning = cleanupProbe.envelope.data?.cleanupWarnings?.[0]; +if ( + cleanupProbe.status !== 0 || + cleanupProbe.envelope.ok !== true || + cleanupProbe.envelope.data?.cacheWritable !== true || + cleanupProbe.envelope.data?.launch !== 'not-probed' || + cleanupWarning?.code !== 'BROWSER_PREFLIGHT_CLEANUP_FAILED' || + cleanupWarning.details.stage !== 'profile-cleanup' || + cleanupWarning.details.systemCode !== 'EACCES' || + cleanupWarning.details.retryable !== false || + cleanupWarning.details.retainedPath !== cleanupWarning.details.path +) + throw new Error('Incorrect browser cleanup warning: ' + JSON.stringify(cleanupProbe)); const compactDeck = JSON.parse( execFileSync( process.execPath, diff --git a/skills/deckuse/modules/verification-recovery.md b/skills/deckuse/modules/verification-recovery.md index c33bccd..8fae6b6 100644 --- a/skills/deckuse/modules/verification-recovery.md +++ b/skills/deckuse/modules/verification-recovery.md @@ -40,7 +40,7 @@ Finish with the output file, completed checks, known limitations and any unresol Give each logical apply a stable `--request-id` and the inspected `--expect-revision`. Save the result to a fresh report file. If the process times out, stdout closes, or a shell command loses the response, run `deckuse status --workspace ./ws --request-id --json`. A committed receipt reports its original revision, and `current` reports whether it still matches the current write history. Reuse the ID and the same input for a retry only when continuing that request. Treat stale receipts and conflicting inputs as a reason to inspect the current state before making another write. -If a render logs `RENDER_CLEANUP_FAILED`, check the screenshot result and the reported temporary directory separately. Preserve successful PNGs. A denied cleanup path does not require another apply or conversion. If the converter itself reports `RENDER_CONVERTER_EXTRACTION_FAILED`, use `render --preflight` with an allowed writable extraction directory, then resume rendering. +If a render logs `RENDER_CLEANUP_FAILED`, check the screenshot result and the reported temporary directory separately. Preserve successful PNGs. A denied cleanup path does not require another apply or conversion. If the converter itself reports `RENDER_CONVERTER_EXTRACTION_FAILED`, do not retry unchanged or cycle through writable directories: bundle startup also needs host-approved commit/rename and sometimes delete operations. Prefer a self-contained multi-file deck2html package, or have the host correct its extraction configuration before running `render --preflight` again. `retryable: false` prevents unchanged automatic retries; it does not prohibit recovery after configuration changes. Browser preflight `cleanupWarnings` describe retained probe paths separately from write and launch availability. A `no-op` receipt records a successful request with no content commit or revision advance. It can be replayed only while its base commit, revision and source content still match. diff --git a/src/deck2html-exec.ts b/src/deck2html-exec.ts index b0a2f53..cc8e4a6 100644 --- a/src/deck2html-exec.ts +++ b/src/deck2html-exec.ts @@ -1,7 +1,7 @@ import { RenderFailure } from './render-browser.js'; -import { access, chmod, constants, lstat, mkdir } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { access, chmod, constants } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { execFile } from 'node:child_process'; import deck2html from '@deckflow/deck2html'; /** npm may install platform binaries without +x; restore before convert. */ @@ -24,41 +24,62 @@ export const ensureDeck2HtmlExecutable = async (): Promise => { } }; -const defaultExtractionBase = () => - join(tmpdir(), `deckuse-dotnet-${String(process.getuid?.() ?? 'user')}`); -const extractionBase = () => - process.env['DOTNET_BUNDLE_EXTRACT_BASE_DIR'] ?? defaultExtractionBase(); +const selectedBinary = () => process.env['DECKUSE_DECK2HTML_BINARY'] ?? deck2html.getBinaryPath(); -async function converterEnvironment() { - const base = extractionBase(); - if (!process.env['DOTNET_BUNDLE_EXTRACT_BASE_DIR']) { - try { - await mkdir(base, { recursive: true, mode: 0o700 }); - const info = await lstat(base); - const uid = process.getuid?.(); - if ( - !info.isDirectory() || - (uid !== undefined && (info.uid !== uid || (info.mode & 0o022) !== 0)) - ) - throw new Error( - 'Default extraction cache must be a directory owned by the current user and protected from other writers', +function extractionConfiguration() { + const base = process.env['DOTNET_BUNDLE_EXTRACT_BASE_DIR']; + return { + extractionBase: base === '' ? null : (base ?? null), + extractionSource: base ? 'explicit' : 'runtime-default', + }; +} + +/** Inherit host policy and runtime defaults; never relocate or warm extraction caches. */ +async function runConverter(args: string[], preflight = false) { + const binary = resolve(selectedBinary()); + await ensureDeck2HtmlExecutable(); + const env = { ...process.env }; + return new Promise<{ stdout: string; stderr: string }>((done, reject) => { + execFile( + binary, + args, + { + env, + timeout: preflight ? 5000 : 120_000, + maxBuffer: preflight ? 65536 : 64 * 1024 * 1024, + killSignal: 'SIGKILL', + }, + (error, stdout, stderr) => { + if (!error) { + done({ stdout, stderr }); + return; + } + const failure = converterFailure(stderr.trim() || stdout.trim() || error.message, { + binary, + exitCode: typeof error.code === 'number' ? error.code : null, + systemCode: typeof error.code === 'string' ? error.code : undefined, + signal: error.signal ?? null, + killed: error.killed ?? false, + }); + reject( + preflight && failure.code !== 'RENDER_CONVERTER_EXTRACTION_FAILED' + ? new RenderFailure('RENDER_CONVERTER_STARTUP_FAILED', failure.message, { + ...failure.details, + stage: 'converter-startup', + }) + : failure, ); - } catch (error) { - throw converterFailure(`Failed to create extraction directory ${base}: ${String(error)}`); - } - } - return { ...process.env, DOTNET_BUNDLE_EXTRACT_BASE_DIR: base }; + }, + ); + }); } /** Explicit native binary selection for testing or deploying a companion engine build. */ export const convertWithConfiguredBinary: typeof deck2html.convert = async (input, options) => { - const binary = process.env['DECKUSE_DECK2HTML_BINARY'] ?? deck2html.getBinaryPath(); - const env = await converterEnvironment(); - const { execFile } = await import('node:child_process'); - const { resolve, join } = await import('node:path'); const args = [resolve(input), '-o', resolve(options.output)]; for (const [key, flag] of [ ['mode', '--mode'], + ['license', '--license'], ['fontService', '--font-service'], ['pages', '--pages'], ] as const) { @@ -75,43 +96,43 @@ export const convertWithConfiguredBinary: typeof deck2html.convert = async (inpu ['noLazySlides', '--no-lazy-slides'], ] as const) if (options[key]) args.push(flag); - return new Promise((done, reject) => - execFile( - resolve(binary), - args, - { env, maxBuffer: 64 * 1024 * 1024, timeout: 120_000 }, - (error, stdout, stderr) => { - if (error && typeof error.code !== 'number') { - reject(new Error(error.message, { cause: error })); - return; - } - done({ - indexHtmlPath: join(resolve(options.output), 'index.html'), - exitCode: typeof error?.code === 'number' ? error.code : 0, - stdout, - stderr, - }); - }, - ), - ); + const { stdout, stderr } = await runConverter(args); + return { + indexHtmlPath: join(resolve(options.output), 'index.html'), + exitCode: 0, + stdout, + stderr, + }; }; /** Identify a shared .NET bundle setup failure before treating it as a page error. */ -export function converterFailure(message: string): RenderFailure { +export function converterFailure( + message: string, + context: Record = {}, +): RenderFailure { const extraction = - /bundle.{0,80}extract|extract.{0,80}bundle|DOTNET_BUNDLE_EXTRACT_BASE_DIR|failed to (?:commit extracted files|create extraction directory)/i.test( + /failure processing application bundle|bundle.{0,80}extract|extract.{0,80}bundle|DOTNET_BUNDLE_EXTRACT_BASE_DIR|failed to (?:commit extracted files|create extraction directory)/i.test( message, ); + const extractionStage = /failed to commit extracted files/i.test(message) + ? 'commit' + : /failed to (?:create|determine).*dir|failed to determine location/i.test(message) + ? 'directory' + : /failed to open file|I\/O failure.*writ/i.test(message) + ? 'write' + : 'unknown'; return new RenderFailure( extraction ? 'RENDER_CONVERTER_EXTRACTION_FAILED' : 'RENDER_CONVERSION_FAILED', message, { + ...extractionConfiguration(), + ...context, stage: extraction ? 'converter-extraction' : 'convert', ...(extraction ? { retryable: false, - extractionBase: extractionBase(), - hint: 'Verify the converter can create and commit files in its extraction directory under the host policy before retrying preview.', + extractionStage, + hint: 'Do not retry unchanged or cycle through extraction directories. Bundle startup needs host-approved write and commit operations (including rename and possibly delete), not just a writable directory. Use a non-bundled runtime distribution or have the host correct its configuration before retrying.', } : {}), }, @@ -120,35 +141,10 @@ export function converterFailure(message: string): RenderFailure { /** Run the selected binary through runtime startup without converting user content. */ export async function converterPreflight() { - const binary = process.env['DECKUSE_DECK2HTML_BINARY'] ?? deck2html.getBinaryPath(); - const { execFile } = await import('node:child_process'); - const { resolve } = await import('node:path'); - await ensureDeck2HtmlExecutable(); - const env = await converterEnvironment(); - const version = await new Promise((done, reject) => { - execFile( - resolve(binary), - ['--version'], - { env, timeout: 5000, maxBuffer: 65536, killSignal: 'SIGKILL' }, - (error, stdout, stderr) => { - if (error) { - const message = stderr.trim() || error.message; - const failure = converterFailure(message); - reject( - failure.code === 'RENDER_CONVERTER_EXTRACTION_FAILED' - ? failure - : new RenderFailure('RENDER_CONVERTER_STARTUP_FAILED', message, { - stage: 'converter-startup', - }), - ); - } else done(stdout.trim()); - }, - ); - }); + const { stdout } = await runConverter(['--version'], true); return { - binary, - version, - extractionBase: extractionBase(), - extractionSource: process.env['DOTNET_BUNDLE_EXTRACT_BASE_DIR'] ? 'explicit' : 'deckuse-temp', + binary: resolve(selectedBinary()), + version: stdout.trim(), + ...extractionConfiguration(), }; } diff --git a/src/monitor.ts b/src/monitor.ts index 76ea8e6..22eeb50 100644 --- a/src/monitor.ts +++ b/src/monitor.ts @@ -16,7 +16,7 @@ import { operationsPath, previewDir, } from './workspace/index.js'; -import { ensureDeck2HtmlExecutable } from './deck2html-exec.js'; +import { convertWithConfiguredBinary, ensureDeck2HtmlExecutable } from './deck2html-exec.js'; const KEEPALIVE_MS = 15_000; const DEBOUNCE_MS = 80; @@ -503,7 +503,7 @@ export const startMonitor = async ( const fullPreviewRoot = previewDir(absoluteWorkspace); await mkdir(fullPreviewRoot, { recursive: true }); let realFullPreviewRoot = await realpath(fullPreviewRoot); - const converter = options.dependencies?.convert ?? deck2html.convert; + const converter = options.dependencies?.convert ?? convertWithConfiguredBinary; const createWatcher = options.dependencies?.watch ?? watch; const debounceMs = options.dependencies?.debounceMs ?? DEBOUNCE_MS; const keepaliveMs = options.dependencies?.keepaliveMs ?? KEEPALIVE_MS; diff --git a/src/render-browser.ts b/src/render-browser.ts index 8026abd..3edceec 100644 --- a/src/render-browser.ts +++ b/src/render-browser.ts @@ -98,21 +98,30 @@ async function checkBrowserPreflight() { details('profile-write', probeFile, cause), ); } + const cleanupWarnings: { code: string; message: string; details: Record }[] = []; try { await timed('cleanup-browser-preflight', () => rm(probe, { recursive: true })); } catch (cause) { - throw new RenderFailure('BROWSER_PREFLIGHT_CLEANUP_FAILED', String(cause), { - ...details('profile-cleanup', probe, cause), - retryable: false, - previewSupported: false, - ...(writeFailure ? { writeFailure: writeFailure.details } : {}), + cleanupWarnings.push({ + code: 'BROWSER_PREFLIGHT_CLEANUP_FAILED', + message: String(cause), + details: { + ...details('profile-cleanup', probe, cause), + retryable: false, + retainedPath: probe, + }, }); } - if (writeFailure) throw writeFailure; + if (writeFailure) + throw new RenderFailure(writeFailure.code, writeFailure.message, { + ...writeFailure.details, + cleanupWarnings, + }); return { ...config, executableStatus, cacheWritable: true, + cleanupWarnings, dependencyCompatibility: config.provider === 'headless-shell' ? 'playwright-managed' : 'unverified', launch: 'not-probed', @@ -125,7 +134,11 @@ export async function launchRenderBrowser( timeout?: number, ): Promise { const config = await browserPreflight(); - const key = JSON.stringify(config); + for (const warning of config.cleanupWarnings) + process.stderr.write( + `deckuse: ${warning.code} (${String(warning.details['retainedPath'])}): ${warning.message}\n`, + ); + const key = JSON.stringify({ ...config, cleanupWarnings: undefined }); const previous = failed.get(key); if (previous && process.env['DECKUSE_BROWSER_RETRY'] !== '1') throw previous; try { diff --git a/test/cli/commercial-leak-guard.test.ts b/test/cli/commercial-leak-guard.test.ts new file mode 100644 index 0000000..07b4c94 --- /dev/null +++ b/test/cli/commercial-leak-guard.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from 'vitest'; +import { mkdtemp, mkdir, copyFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +it('allows only the dependency argument mapping while rejecting other license markers', async () => { + const root = await mkdtemp(join(tmpdir(), 'deckuse-commercial-guard-')); + await mkdir(join(root, 'src/edition-config'), { recursive: true }); + await copyFile('src/edition-config/index.ts', join(root, 'src/edition-config/index.ts')); + const file = join(root, 'src/deck2html-exec.ts'); + const mapping = "const argumentsMap = [['license', '--license'],];"; + const check = () => + spawnSync(process.execPath, [resolve('scripts/assert-no-commercial-leak.mjs')], { + cwd: root, + encoding: 'utf8', + }); + await writeFile(file, mapping); + expect(check().status).toBe(0); + for (const extra of [ + "const flag = '--license';", + 'function verifyLicense() {}', + 'function ensureActivated() {}', + ]) { + await writeFile(file, mapping + extra); + const result = check(); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Forbidden license/certificate marker'); + } + await writeFile(file, mapping); + await writeFile(join(root, 'src/other.ts'), mapping); + expect(check().status).toBe(1); +}); diff --git a/test/cli/deck2html-exec.test.ts b/test/cli/deck2html-exec.test.ts new file mode 100644 index 0000000..ba44b48 --- /dev/null +++ b/test/cli/deck2html-exec.test.ts @@ -0,0 +1,93 @@ +import { afterEach, expect, it, vi } from 'vitest'; +const { execFile, access } = vi.hoisted(() => ({ execFile: vi.fn(), access: vi.fn() })); +vi.mock('node:child_process', () => ({ execFile })); +vi.mock('node:fs/promises', async (original) => ({ ...(await original()), access })); +vi.mock('@deckflow/deck2html', () => ({ default: { getBinaryPath: () => '/converter' } })); +import { + converterPreflight, + convertWithConfiguredBinary, + converterFailure, +} from '../../src/deck2html-exec.js'; +afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); +}); +it.each([undefined, '/approved/cache'])( + 'inherits extraction configuration %s for startup and conversion', + async (base) => { + vi.stubEnv('DOTNET_BUNDLE_EXTRACT_BASE_DIR', base); + vi.stubEnv('DECKUSE_DECK2HTML_BINARY', '/custom/converter'); + execFile.mockImplementation((_bin, _args, _options, callback) => callback(null, '0.6.2\n', '')); + expect(await converterPreflight()).toMatchObject({ + binary: '/custom/converter', + extractionBase: base ?? null, + extractionSource: base ? 'explicit' : 'runtime-default', + }); + await convertWithConfiguredBinary('/input.pptx', { + output: '/output', + license: '/license', + pages: '1', + noLazySlides: true, + }); + for (const call of execFile.mock.calls) { + expect(call[0]).toBe('/custom/converter'); + expect(call[2].env).not.toBe(process.env); + expect(call[2].env.DOTNET_BUNDLE_EXTRACT_BASE_DIR).toBe(base); + } + expect(execFile.mock.calls[1][1]).toEqual([ + '/input.pptx', + '-o', + '/output', + '--license', + '/license', + '--pages', + '1', + '--no-lazy-slides', + ]); + expect(process.env.DOTNET_BUNDLE_EXTRACT_BASE_DIR).toBe(base); + }, +); +it.each([ + ['Failed to create extraction directory /cache', 'directory'], + ['Failure processing application bundle.\nFailed to open file /cache/file for writing.', 'write'], + [ + 'Failure processing application bundle.\nFailed to commit extracted files to directory [/cache].', + 'commit', + ], +])('classifies %s without claiming a policy denial', (message, extractionStage) => { + expect(converterFailure(message)).toMatchObject({ + code: 'RENDER_CONVERTER_EXTRACTION_FAILED', + details: { extractionStage, retryable: false }, + }); +}); +it.each([false, true])('preserves native failure context (preflight=%s)', async (preflight) => { + execFile.mockImplementation((_bin, _args, _options, callback) => + callback( + { code: 159, signal: null }, + '', + 'Failed to commit extracted files to directory [/cache]', + ), + ); + await expect( + preflight ? converterPreflight() : convertWithConfiguredBinary('/input', { output: '/output' }), + ).rejects.toMatchObject({ + code: 'RENDER_CONVERTER_EXTRACTION_FAILED', + details: { binary: '/converter', exitCode: 159, signal: null }, + }); +}); +it.each([false, true])( + 'does not report a killed converter as success (preflight=%s)', + async (preflight) => { + execFile.mockImplementation((_bin, _args, _options, callback) => + callback({ code: null, signal: 'SIGKILL', killed: true, message: 'timed out' }, '', ''), + ); + await expect( + preflight + ? converterPreflight() + : convertWithConfiguredBinary('/input', { output: '/output' }), + ).rejects.toMatchObject({ + code: preflight ? 'RENDER_CONVERTER_STARTUP_FAILED' : 'RENDER_CONVERSION_FAILED', + details: { signal: 'SIGKILL', killed: true, exitCode: null }, + }); + }, +); diff --git a/test/cli/monitor.test.ts b/test/cli/monitor.test.ts index 6df6066..82b7587 100644 --- a/test/cli/monitor.test.ts +++ b/test/cli/monitor.test.ts @@ -1,7 +1,16 @@ import { Script } from 'node:vm'; import { EventEmitter } from 'node:events'; import { randomUUID } from 'node:crypto'; -import { mkdir, mkdtemp, readFile, rename, symlink, unlink, writeFile } from 'node:fs/promises'; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rename, + symlink, + unlink, + writeFile, +} from 'node:fs/promises'; import { request } from 'node:http'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -894,3 +903,45 @@ describe('monitor', () => { await monitor.close(); }); }); + +it.skipIf(process.platform === 'win32')( + 'uses the configured converter and inherited extraction environment in monitor', + async () => { + const workspace = await workspaceFixture(); + const binary = join(workspace, 'converter.cjs'); + const record = join(workspace, 'invocation.json'); + await writeFile( + binary, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const out = args[args.indexOf('-o') + 1]; +fs.mkdirSync(out, { recursive: true }); +fs.writeFileSync(path.join(out, 'index.html'), 'configured converter'); +fs.writeFileSync(${JSON.stringify(record)}, JSON.stringify({ args, base: process.env.DOTNET_BUNDLE_EXTRACT_BASE_DIR })); +`, + ); + await chmod(binary, 0o755); + vi.stubEnv('DECKUSE_DECK2HTML_BINARY', binary); + vi.stubEnv('DOTNET_BUNDLE_EXTRACT_BASE_DIR', '/host-approved-cache'); + const watcher = Object.assign(new EventEmitter(), { close: vi.fn() }); + const monitor = await startMonitor(workspace, { + port: 0, + dependencies: { watch: () => watcher }, + }); + const client = await subscribe(monitor.url); + try { + await eventually(async () => { + const invocation = JSON.parse(await readFile(record, 'utf8')); + expect(invocation.base).toBe('/host-approved-cache'); + expect(invocation.args).toContain('--pages'); + }); + await eventually(() => expect(client.events.join('')).toContain('render')); + } finally { + client.close(); + await monitor.close(); + vi.unstubAllEnvs(); + } + }, +); diff --git a/test/cli/render-browser.test.ts b/test/cli/render-browser.test.ts index fff7936..4ec361e 100644 --- a/test/cli/render-browser.test.ts +++ b/test/cli/render-browser.test.ts @@ -63,7 +63,6 @@ it('probes a trivial page with bounded launch and closes the browser on page tim it.each([ ['mkdtemp', 'BROWSER_CACHE_UNWRITABLE', 'profile-create'], ['writeFile', 'BROWSER_CACHE_UNWRITABLE', 'profile-write'], - ['rm', 'BROWSER_PREFLIGHT_CLEANUP_FAILED', 'profile-cleanup'], ] as const)('reports a denied %s probe at its own stage', async (method, code, stage) => { const fs = (await import('node:fs')).default; const { syncBuiltinESMExports } = await import('node:module'); @@ -86,3 +85,48 @@ it.each([ syncBuiltinESMExports(); } }); + +it('retains denied cleanup as a warning and preserves a primary write failure', async () => { + const fs = (await import('node:fs')).default; + const { syncBuiltinESMExports } = await import('node:module'); + vi.stubEnv('DECKUSE_BROWSER_PROVIDER', 'headless-shell'); + vi.stubEnv('DECKUSE_BROWSER_EXECUTION', 'unknown'); + const original = { + mkdtemp: fs.promises.mkdtemp, + rm: fs.promises.rm, + writeFile: fs.promises.writeFile, + }; + const denied = Object.assign(new Error('denied'), { code: 'EPERM' }); + const rm = vi.fn().mockRejectedValue(denied); + Object.assign(fs.promises, { + mkdtemp: async () => '/probe-retained', + writeFile: async () => {}, + rm, + }); + syncBuiltinESMExports(); + try { + await expect(browserPreflight()).resolves.toMatchObject({ + cacheWritable: true, + cleanupWarnings: [ + { code: 'BROWSER_PREFLIGHT_CLEANUP_FAILED', details: { retainedPath: '/probe-retained' } }, + ], + }); + Object.assign(fs.promises, { + writeFile: async () => { + throw denied; + }, + }); + syncBuiltinESMExports(); + await expect(browserPreflight()).rejects.toMatchObject({ + code: 'BROWSER_CACHE_UNWRITABLE', + details: { + stage: 'profile-write', + cleanupWarnings: [{ code: 'BROWSER_PREFLIGHT_CLEANUP_FAILED' }], + }, + }); + expect(rm).toHaveBeenCalledTimes(2); + } finally { + Object.assign(fs.promises, original); + syncBuiltinESMExports(); + } +});