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:
co-authored by
Claude Opus 5.5
parent
f4c99ab8a5
commit
8c5e34cd37
@@ -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" },
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user