348 lines
10 KiB
TypeScript
348 lines
10 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
||
import {
|
||
collectPassiveCapabilities,
|
||
createRedactedReport,
|
||
evaluateRequirementProfile,
|
||
exportMediaCapabilityResult,
|
||
exportReportCsv,
|
||
exportReportJson,
|
||
parseRequirementProfile,
|
||
probeMediaCapabilities,
|
||
runProbe,
|
||
type Capability,
|
||
type DeviceEnvironment,
|
||
type ProbeResult,
|
||
} from "../../src/core/device";
|
||
|
||
function environment(
|
||
navigatorOverrides: Record<string, unknown> = {},
|
||
): DeviceEnvironment {
|
||
const fakeWindow = {
|
||
innerWidth: 1234,
|
||
innerHeight: 777,
|
||
devicePixelRatio: 2.25,
|
||
top: null,
|
||
visualViewport: { width: 1200, height: 700 },
|
||
matchMedia: (query: string) => ({ matches: query.includes("fine") }),
|
||
WebGLRenderingContext: function WebGLRenderingContext() {},
|
||
Worker: function Worker() {},
|
||
indexedDB: {},
|
||
caches: {},
|
||
} as unknown as Window;
|
||
Object.defineProperty(fakeWindow, "top", { value: fakeWindow });
|
||
const fakeDocument = {
|
||
fullscreenEnabled: true,
|
||
pictureInPictureEnabled: true,
|
||
createElement: (name: string) =>
|
||
name === "video" || name === "audio"
|
||
? {
|
||
canPlayType: (mime: string) =>
|
||
mime.includes("opus") ? "probably" : "",
|
||
}
|
||
: {},
|
||
} as unknown as Document;
|
||
return {
|
||
window: fakeWindow,
|
||
document: fakeDocument,
|
||
navigator: {
|
||
onLine: true,
|
||
maxTouchPoints: 5,
|
||
mediaDevices: {
|
||
getSupportedConstraints: () => ({
|
||
width: true,
|
||
echoCancellation: true,
|
||
}),
|
||
},
|
||
...navigatorOverrides,
|
||
} as unknown as DeviceEnvironment["navigator"],
|
||
screen: {
|
||
width: 2560,
|
||
height: 1440,
|
||
availWidth: 2500,
|
||
availHeight: 1400,
|
||
colorDepth: 24,
|
||
orientation: { type: "landscape-primary" },
|
||
} as Screen,
|
||
secureContext: true,
|
||
isolated: false,
|
||
};
|
||
}
|
||
|
||
describe("passive device inventory", () => {
|
||
it("reports display, input, constraints, codecs and API presence without probing", () => {
|
||
const capabilities = collectPassiveCapabilities(environment());
|
||
expect(capabilities.find((item) => item.id === "viewport")?.value).toBe(
|
||
"1234 × 777 CSS px",
|
||
);
|
||
expect(capabilities.find((item) => item.id === "touch")?.state).toBe(
|
||
"available",
|
||
);
|
||
expect(
|
||
capabilities.find((item) => item.id === "media-constraints")?.value,
|
||
).toContain("echoCancellation");
|
||
expect(capabilities.find((item) => item.id === "codec-opus")?.value).toBe(
|
||
"Probably",
|
||
);
|
||
expect(capabilities.find((item) => item.id === "camera-api")?.state).toBe(
|
||
"unavailable",
|
||
);
|
||
expect(
|
||
capabilities.find((item) => item.id === "camera-api")?.availability,
|
||
).toBe("missing-api");
|
||
});
|
||
|
||
it("distinguishes insecure context and Permissions Policy failures", async () => {
|
||
const insecure = environment();
|
||
insecure.secureContext = false;
|
||
expect(
|
||
collectPassiveCapabilities(insecure).find(
|
||
(item) => item.id === "camera-api",
|
||
),
|
||
).toMatchObject({ availability: "insecure-context" });
|
||
expect(await runProbe("camera", insecure)).toMatchObject({
|
||
state: "unsupported",
|
||
availability: "insecure-context",
|
||
});
|
||
|
||
const blocked = environment({
|
||
mediaDevices: { getUserMedia: vi.fn() },
|
||
});
|
||
Object.assign(blocked.document, {
|
||
permissionsPolicy: {
|
||
allowsFeature: (feature: string) => feature !== "camera",
|
||
},
|
||
});
|
||
expect(await runProbe("camera", blocked)).toMatchObject({
|
||
state: "denied",
|
||
availability: "permissions-policy",
|
||
});
|
||
});
|
||
|
||
it("distinguishes missing devices from denied permission", async () => {
|
||
const missing = await runProbe(
|
||
"camera",
|
||
environment({
|
||
mediaDevices: {
|
||
getUserMedia: async () => {
|
||
throw new DOMException("No camera", "NotFoundError");
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
expect(missing).toMatchObject({ availability: "device-or-os" });
|
||
const denied = await runProbe(
|
||
"camera",
|
||
environment({
|
||
mediaDevices: {
|
||
getUserMedia: async () => {
|
||
throw new DOMException("Denied", "NotAllowedError");
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
expect(denied).toMatchObject({ availability: "user-permission" });
|
||
});
|
||
});
|
||
|
||
describe("explicit probes", () => {
|
||
it("reads storage locally and preserves only buckets in a report", async () => {
|
||
const result = await runProbe(
|
||
"storage",
|
||
environment({
|
||
storage: {
|
||
estimate: async () => ({ usage: 500_000_000, quota: 2_000_000_000 }),
|
||
},
|
||
}),
|
||
);
|
||
expect(result.state).toBe("complete");
|
||
expect(result.details[0]?.value).toContain("MiB");
|
||
const report = createRedactedReport([], [result]);
|
||
const serialized = exportReportJson(report);
|
||
expect(serialized).not.toContain("476.84");
|
||
expect(serialized).toContain("25-to-49%");
|
||
});
|
||
|
||
it("stops every user-media track immediately", async () => {
|
||
const stop = vi.fn();
|
||
const result = await runProbe(
|
||
"camera",
|
||
environment({
|
||
mediaDevices: {
|
||
getUserMedia: async () => ({ getTracks: () => [{ stop }, { stop }] }),
|
||
},
|
||
}),
|
||
);
|
||
expect(result.state).toBe("complete");
|
||
expect(stop).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it("queries permission state without treating unsupported names as failure", async () => {
|
||
const query = vi.fn(async ({ name }: { name: string }) => {
|
||
if (name === "camera") throw new TypeError("not supported");
|
||
return { state: "prompt" };
|
||
});
|
||
const result = await runProbe(
|
||
"permissions",
|
||
environment({ permissions: { query } }),
|
||
);
|
||
expect(result.state).toBe("complete");
|
||
expect(result.details.find((item) => item.label === "camera")?.value).toBe(
|
||
"unsupported",
|
||
);
|
||
expect(query).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("redacted exports", () => {
|
||
it("never emits exact high-entropy capability values", () => {
|
||
const capabilities: Capability[] = [
|
||
{
|
||
id: "viewport",
|
||
group: "Display",
|
||
label: "Viewport",
|
||
state: "available",
|
||
value: "1234 × 777 CSS px",
|
||
privacy: "high",
|
||
explanation: "",
|
||
},
|
||
{
|
||
id: "dpr",
|
||
group: "Display",
|
||
label: "DPR",
|
||
state: "available",
|
||
value: "2.25",
|
||
privacy: "high",
|
||
explanation: "",
|
||
},
|
||
{
|
||
id: "touch",
|
||
group: "Input",
|
||
label: "Touch",
|
||
state: "available",
|
||
value: "5 maximum point(s)",
|
||
privacy: "medium",
|
||
explanation: "",
|
||
},
|
||
];
|
||
const probe: ProbeResult = {
|
||
id: "media-devices",
|
||
state: "complete",
|
||
summary: "done",
|
||
details: [{ label: "videoinput", value: "7", redacted: "many" }],
|
||
};
|
||
const report = createRedactedReport(capabilities, [probe]);
|
||
const json = exportReportJson(report);
|
||
expect(json).not.toContain("1234");
|
||
expect(json).not.toContain("2.25");
|
||
expect(json).toContain("medium");
|
||
expect(json).toContain("multi-touch");
|
||
expect(exportReportCsv(report)).toContain("capability,viewport");
|
||
expect(report.policy.join(" ")).toContain("No stable identifier");
|
||
});
|
||
});
|
||
|
||
describe("application requirement profiles", () => {
|
||
it("combines legacy requirements with required and optional capabilities", () => {
|
||
const profile = parseRequirementProfile({
|
||
id: "de.example.tool",
|
||
name: "Example",
|
||
requirements: { secureContext: true, workers: true },
|
||
capabilities: {
|
||
required: ["file-system", "workers"],
|
||
optional: ["webgpu-api", "workers"],
|
||
},
|
||
});
|
||
expect(profile.required).toEqual([
|
||
"file-system",
|
||
"secure-context",
|
||
"workers",
|
||
]);
|
||
expect(profile.optional).toEqual(["webgpu-api"]);
|
||
|
||
const evaluation = evaluateRequirementProfile(
|
||
profile,
|
||
collectPassiveCapabilities(environment()),
|
||
);
|
||
expect(evaluation.status).toBe("blocked");
|
||
expect(
|
||
evaluation.requirements.find((item) => item.id === "workers")?.state,
|
||
).toBe("available");
|
||
expect(
|
||
evaluation.requirements.find((item) => item.id === "file-system")
|
||
?.remediation,
|
||
).toContain("browser/version");
|
||
});
|
||
|
||
it("reports unknown required identifiers and rejects malformed profiles", () => {
|
||
const profile = parseRequirementProfile(
|
||
JSON.stringify({ capabilities: { required: ["future-api"] } }),
|
||
);
|
||
const result = evaluateRequirementProfile(profile, []);
|
||
expect(result.status).toBe("blocked");
|
||
expect(result.requirements[0]).toMatchObject({ state: "unknown" });
|
||
expect(() =>
|
||
parseRequirementProfile({ capabilities: { required: ["Bad ID"] } }),
|
||
).toThrow(/capability ID/);
|
||
});
|
||
});
|
||
|
||
describe("MediaCapabilities lab", () => {
|
||
it("runs only the requested configuration and exports bucketed numbers", async () => {
|
||
const decodingInfo = vi.fn(async () => ({
|
||
supported: true,
|
||
smooth: false,
|
||
powerEfficient: true,
|
||
}));
|
||
const result = await probeMediaCapabilities(
|
||
{
|
||
kind: "video",
|
||
contentType: 'video/mp4; codecs="avc1.42E01E"',
|
||
width: 1920,
|
||
height: 1080,
|
||
bitrate: 8_765_432,
|
||
framerate: 30,
|
||
},
|
||
environment({ mediaCapabilities: { decodingInfo } }),
|
||
);
|
||
expect(decodingInfo).toHaveBeenCalledOnce();
|
||
expect(result).toMatchObject({
|
||
state: "complete",
|
||
supported: true,
|
||
smooth: false,
|
||
powerEfficient: true,
|
||
});
|
||
const exported = exportMediaCapabilityResult(result);
|
||
expect(exported).toContain("Full-HD");
|
||
expect(exported).toContain("2-to-10-Mbit/s");
|
||
expect(exported).not.toContain("8765432");
|
||
expect(exported).not.toContain("1920");
|
||
});
|
||
|
||
it("handles unsupported browsers and validates dangerous input", async () => {
|
||
const unsupported = await probeMediaCapabilities(
|
||
{
|
||
kind: "audio",
|
||
contentType: 'audio/webm; codecs="opus"',
|
||
channels: "2",
|
||
bitrate: 192_000,
|
||
samplerate: 48_000,
|
||
},
|
||
environment(),
|
||
);
|
||
expect(unsupported.state).toBe("unsupported");
|
||
await expect(
|
||
probeMediaCapabilities(
|
||
{
|
||
kind: "video",
|
||
contentType: "audio/mp4\ninvalid",
|
||
width: 1920,
|
||
height: 1080,
|
||
bitrate: 8_000_000,
|
||
framerate: 30,
|
||
},
|
||
environment(),
|
||
),
|
||
).rejects.toThrow(/video MIME type/);
|
||
});
|
||
});
|