fix(browser): classify export-web-scripts capture selection failures by cause

export_web_scripts reported a missing capture, a directory selected as
the capture, and an unreadable capture as an artifact io failure ("Script
export failed for <capture> -> <output>: ENOENT ..."), unlike its sibling
inspect_web_network_capture, which already reports these as invalid
input or access_denied.

Read the selected capture in its own step: ENOENT, ENOTDIR and EISDIR
become an invalid capture_path issue, and EACCES or EPERM becomes
access_denied. Failures while writing the export keep their existing
classification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-09 09:16:59 +00:00
co-authored by Claude Opus 5.5
parent f4c99ab8a5
commit 8c5e34cd37
2 changed files with 99 additions and 7 deletions
+38 -3
View File
@@ -8,6 +8,7 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { publishWebScripts } from "../browser/assets/PublishWebScripts.js";
import { selectScriptCapture } from "../browser/assets/ScriptCaptureAdapters.js";
import {
AnalysisAccessDeniedError,
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
@@ -60,9 +61,9 @@ export const exportWebScriptsValidated = async (
);
try {
options.signal?.throwIfAborted();
const bytes = await readFile(input.capture_path, {
signal: options.signal,
});
const read = await readCapture(input.capture_path, options.signal);
if (!read.ok) return read;
const bytes = read.value;
const loaded = parseCapture(bytes);
if (!loaded.ok) return loaded;
options.signal?.throwIfAborted();
@@ -142,6 +143,40 @@ export const exportWebScriptsValidated = async (
}
};
/**
* Read the caller-selected capture. A missing, non-file or unreadable
* selection is a caller or host-permission failure, not an export failure.
*/
const readCapture = async (
path: string,
signal: AbortSignal | undefined,
): Promise<Result<Buffer, AnalysisError>> => {
try {
return ok(await readFile(path, { signal }));
} catch (cause: unknown) {
const code =
cause instanceof Error && "code" in cause ? String(cause.code) : "";
if (code === "EACCES" || code === "EPERM")
return err(
new AnalysisAccessDeniedError(OPERATION, path, code, { cause }),
);
if (code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR")
return err(
new AnalysisInputError(OPERATION, { cause }, [
{
path: ["capture_path"],
reason: "invalid_value",
message:
code === "EISDIR"
? `Selected capture is a directory, not a file: ${path}`
: `Selected capture could not be read (${code}): ${path}`,
},
]),
);
throw cause;
}
};
const parseCapture = (
bytes: Buffer,
): Result<ReturnType<typeof selectScriptCapture>, AnalysisError> => {
@@ -1,6 +1,8 @@
import { createHash } from "node:crypto";
import {
access,
chmod,
mkdir,
readFile,
readdir,
symlink,
@@ -8,13 +10,14 @@ import {
} from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, onTestFinished } from "vitest";
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
import { exportWebScripts } from "../../../src/application/WebScriptExportService.js";
import { publishWebScripts } from "../../../src/browser/assets/PublishWebScripts.js";
import { selectScriptCapture } from "../../../src/browser/assets/ScriptCaptureAdapters.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { webScriptExportResultSchema } from "../../../src/domain/webScriptExport.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
@@ -247,9 +250,18 @@ describe("captured script publication failures and cleanup", () => {
capture_path: `${input.capture_path}.missing`,
});
if (missing.ok) throw new Error("Expected unavailable input");
expect(missing.error.userMessage).toContain(
`${input.capture_path}.missing`,
);
expect(projectAnalysisError(missing.error)).toMatchObject({
code: "invalid_request",
details: {
issues: [
{
path: ["capture_path"],
reason: "invalid_value",
message: expect.stringContaining(`${input.capture_path}.missing`),
},
],
},
});
const relative = await exportWebScripts({
...input,
capture_path: "capture.json",
@@ -258,3 +270,48 @@ describe("captured script publication failures and cleanup", () => {
expect(relative.error._tag).toBe("AnalysisInputError");
});
});
describe("captured script selection failures", () => {
it("reports a directory selected as the capture as invalid input", async () => {
const { root, input } = await setup();
const directory = join(root, "captures");
await mkdir(directory);
const result = await exportWebScripts({
...input,
capture_path: directory,
});
if (result.ok) throw new Error("Expected invalid input");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
details: {
issues: [
{
path: ["capture_path"],
reason: "invalid_value",
message: expect.stringContaining(directory),
},
],
},
});
await expect(access(input.output_directory)).rejects.toMatchObject({
code: "ENOENT",
});
});
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports an unreadable capture as a host access denial",
async () => {
const { input } = await setup();
await chmod(input.capture_path, 0o000);
onTestFinished(() => chmod(input.capture_path, 0o600));
const result = await exportWebScripts(input);
if (result.ok) throw new Error("Expected access denial");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "access_denied",
details: { path: input.capture_path, system_code: "EACCES" },
});
},
);
});