* feat(browser): trace captured website module imports Compose verified exported scripts, inert native import syntax and a narrow Chromium resolution port. Preserve query/fragment identity, import-map context, all reported capture candidates and explicit unknown execution. Share named CLI/MCP contracts and retain native reports, diagnostics and cleanup failures. Add focused boundary regressions and a real module-loading oracle to the existing conditional Chrome lane. No captured application code is executed by module tracing, and no selected website asset is refetched. Refs #851, #774 Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * fix(browser): preserve module contexts and source bindings Keep capture completeness inline, validate actual source text against its digest, and separate unsupported URL contexts from malformed input. Use the importer origin for native module resolution and the selected map base independently, including mixed HTTP/HTTPS and escaped HTML attributes. Refs #851, #774 Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
200 lines
6.9 KiB
TypeScript
200 lines
6.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { WebModuleTraceService } from "./WebModuleTraceService.js";
|
|
import { ok } from "../domain/result.js";
|
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
|
import {
|
|
webModuleTraceResultSchema,
|
|
type WebModuleResolution,
|
|
} from "../domain/webModuleTrace.js";
|
|
import {
|
|
webModuleArtifactsFixture,
|
|
webModuleResolverFixture,
|
|
} from "../../tests/fixtures/webModuleTrace.js";
|
|
|
|
const args = { manifest_path: "/analysis/manifest.json", script_index: 0 };
|
|
it.each([
|
|
{ ...args, importer_url: "file:///tmp/main.js" },
|
|
{ ...args, import_map: { path: "/map.json", base_url: "file:///tmp/maps/" } },
|
|
])(
|
|
"distinguishes valid unsupported URL contexts from malformed input: %j",
|
|
async (input) => {
|
|
const result = await new WebModuleTraceService(
|
|
{
|
|
load: () => {
|
|
throw new Error("must not acquire artifacts");
|
|
},
|
|
},
|
|
webModuleResolverFixture,
|
|
).trace(input);
|
|
if (result.ok) throw new Error("expected unsupported context");
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "capability_unavailable",
|
|
message: expect.stringContaining("file:"),
|
|
});
|
|
},
|
|
);
|
|
it("rejects source text changed across the artifact port while retaining the original digest", async () => {
|
|
const artifacts = {
|
|
...webModuleArtifactsFixture("import(name)"),
|
|
source: "import(other)",
|
|
};
|
|
const result = await new WebModuleTraceService(
|
|
{ load: () => Promise.resolve(ok(artifacts)) },
|
|
{
|
|
resolve: () => {
|
|
throw new Error("must not acquire engine");
|
|
},
|
|
},
|
|
).trace(args);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError");
|
|
});
|
|
it("rejects sparse resolver arrays rather than relabeling literal imports as computed unknowns", async () => {
|
|
const artifacts = webModuleArtifactsFixture('import "./dep.js";');
|
|
const service = new WebModuleTraceService(
|
|
{ load: () => Promise.resolve(ok(artifacts)) },
|
|
{
|
|
resolve: () =>
|
|
Promise.resolve(
|
|
ok({
|
|
engine: { id: "fixture", version: "1" },
|
|
resolutions: new Array<WebModuleResolution>(1),
|
|
diagnostics: [],
|
|
rawResult: {},
|
|
}),
|
|
),
|
|
},
|
|
);
|
|
const result = await service.trace(args);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError");
|
|
});
|
|
describe("selected module trace workflow", () => {
|
|
it("composes selected source, derived resolution and explicit computed/execution unknowns", async () => {
|
|
const artifacts = webModuleArtifactsFixture();
|
|
const service = new WebModuleTraceService(
|
|
{ load: () => Promise.resolve(ok(artifacts)) },
|
|
webModuleResolverFixture,
|
|
);
|
|
const response = await service.trace(args);
|
|
if (!response.ok) throw response.error;
|
|
const result = webModuleTraceResultSchema.parse(
|
|
response.value.normalized_result,
|
|
);
|
|
expect(result.importer).toEqual({
|
|
url: artifacts.manifest.scripts[0]?.url,
|
|
basis: "reported-source-url",
|
|
});
|
|
expect(result.manifest.capture_completeness).toEqual(
|
|
artifacts.manifest.capture_completeness,
|
|
);
|
|
expect(result.imports[0]?.resolution).toEqual({
|
|
state: "resolved",
|
|
url: "https://app.test/dep.js",
|
|
});
|
|
expect(result.imports[1]?.resolution).toEqual({
|
|
state: "unknown",
|
|
reason: "computed-specifier",
|
|
});
|
|
expect(result.imports.every((item) => item.execution === "unknown")).toBe(
|
|
true,
|
|
);
|
|
expect(response.value.subject).toMatchObject({
|
|
local_path: artifacts.sourceFile.path,
|
|
digest: { sha256: artifacts.sourceFile.sha256 },
|
|
});
|
|
});
|
|
it.each([
|
|
{ ...args, manifest_path: "relative.json" },
|
|
{
|
|
...args,
|
|
import_map: { path: "relative.json", base_url: "https://app.test/" },
|
|
},
|
|
{ ...args, importer_url: "not a URL" },
|
|
{ ...args, import_map: { path: "/map.json", base_url: "relative" } },
|
|
])(
|
|
"rejects malformed context before acquiring artifacts: %j",
|
|
async (input) => {
|
|
const service = new WebModuleTraceService(
|
|
{
|
|
load: () => {
|
|
throw new Error("must not acquire");
|
|
},
|
|
},
|
|
webModuleResolverFixture,
|
|
);
|
|
const response = await service.trace(input);
|
|
if (response.ok) throw new Error("expected invalid input");
|
|
expect(projectAnalysisError(response.error)).toMatchObject({
|
|
code: "invalid_request",
|
|
details: {
|
|
issues: [expect.objectContaining({ reason: "invalid_format" })],
|
|
},
|
|
});
|
|
},
|
|
);
|
|
it("handles computed-only sources without acquiring the optional engine", async () => {
|
|
const artifacts = webModuleArtifactsFixture("import(name)");
|
|
const service = new WebModuleTraceService(
|
|
{ load: () => Promise.resolve(ok(artifacts)) },
|
|
{
|
|
resolve: () => {
|
|
throw new Error("engine must not start");
|
|
},
|
|
},
|
|
);
|
|
const result = await service.trace(args);
|
|
if (!result.ok) throw result.error;
|
|
expect(
|
|
webModuleTraceResultSchema.parse(result.value.normalized_result).engine,
|
|
).toBeNull();
|
|
});
|
|
it("rejects incomplete resolution rather than fabricating unknown values", async () => {
|
|
const artifacts = webModuleArtifactsFixture();
|
|
const service = new WebModuleTraceService(
|
|
{ load: () => Promise.resolve(ok(artifacts)) },
|
|
{
|
|
resolve: () =>
|
|
Promise.resolve(
|
|
ok({
|
|
engine: { id: "fixture", version: "1" },
|
|
resolutions: [],
|
|
diagnostics: [],
|
|
rawResult: {},
|
|
}),
|
|
),
|
|
},
|
|
);
|
|
const response = await service.trace(args);
|
|
expect(response.ok).toBe(false);
|
|
if (!response.ok) expect(response.error._tag).toBe("AnalysisOutputError");
|
|
});
|
|
it("preserves an explicit selected importer context", async () => {
|
|
const artifacts = webModuleArtifactsFixture();
|
|
const result = await new WebModuleTraceService(
|
|
{ load: () => Promise.resolve(ok(artifacts)) },
|
|
webModuleResolverFixture,
|
|
).trace({ ...args, importer_url: "https://selected.test/base/main.js" });
|
|
if (!result.ok) throw result.error;
|
|
expect(
|
|
webModuleTraceResultSchema.parse(result.value.normalized_result)
|
|
.imports[0]?.resolution,
|
|
).toEqual({ state: "resolved", url: "https://selected.test/base/dep.js" });
|
|
});
|
|
it("honors cancellation before any artifacts are acquired", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const service = new WebModuleTraceService(
|
|
{
|
|
load: () => {
|
|
throw new Error("must not acquire");
|
|
},
|
|
},
|
|
webModuleResolverFixture,
|
|
);
|
|
const result = await service.trace(args, { signal: controller.signal });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
});
|
|
});
|