Files
device-tools/tests/core/device.test.ts
T
zemion 0ae5c7b7af
Verify / verify (push) Canceled after 0s
Release Device Tools 0.2.0
2026-09-02 13:08:36 +02:00

348 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/);
});
});