300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
import { join } from "node:path";
|
|
import { runInNewContext } from "node:vm";
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
fetchJsonBefore,
|
|
isExactGateProfile,
|
|
profileCleanupScript,
|
|
waitForSample,
|
|
type CleanupResult,
|
|
} from "../scripts/browser-gate-harness";
|
|
import {
|
|
captureOneViewerIdentity,
|
|
finalizeGate,
|
|
hasNoCriticalSenderErrors,
|
|
hasOneViewerPionProof,
|
|
retainsFirstBridgeSend,
|
|
retainsOneViewerIdentity,
|
|
verifyFinalSenderEvidence,
|
|
type SenderIdentityEvidence,
|
|
type ViewerIdentityEvidence,
|
|
} from "../scripts/native-one-viewer-gate-core";
|
|
import {
|
|
animatedSourceUrl,
|
|
parseGateMode,
|
|
senderProbe,
|
|
} from "../scripts/native-one-viewer-gate";
|
|
|
|
const viewer: ViewerIdentityEvidence = {
|
|
socketCount: 1, socketOrdinal: 1, authGeneration: 1,
|
|
connectionCount: 1, connectionOrdinal: 1, pcCount: 1, pcOrdinal: 1,
|
|
trackCount: 1, trackOrdinal: 1, consistent: true,
|
|
};
|
|
const sender: SenderIdentityEvidence = {
|
|
peerCount: 1, maxPeers: 1, slot: 1, edgeGeneration: 1,
|
|
};
|
|
const clean: CleanupResult = {
|
|
browserExited: true, nativeExited: true, serverClosed: true,
|
|
portsClosed: true, profileRemoved: true,
|
|
};
|
|
|
|
interface BridgeProbeSnapshot {
|
|
nativeHardwareStarting: boolean;
|
|
nativeHardwareActive: boolean;
|
|
nativeAdapterLuid: string | null;
|
|
bridgeGeneration: number;
|
|
binarySendAttempts: number;
|
|
binarySendSucceeded: number;
|
|
binarySendFailed: number;
|
|
diagnosticsObserved: boolean;
|
|
postSendDiagnosticsObserved: boolean;
|
|
postSendDiagnosticsSequence: number;
|
|
fatalEvents: number;
|
|
encoderInstances: number;
|
|
config: { codec: string; videoSource: string } | null;
|
|
}
|
|
|
|
class ProbeWebSocket {
|
|
failBinary = false;
|
|
private readonly listeners = new Map<string, Array<(event: { data: unknown }) => void>>();
|
|
|
|
constructor(..._args: unknown[]) {}
|
|
addEventListener(type: string, listener: (event: { data: unknown }) => void): void {
|
|
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
|
}
|
|
emitMessage(data: unknown): void {
|
|
for (const listener of this.listeners.get("message") ?? []) listener({ data });
|
|
}
|
|
send(value: unknown): void {
|
|
if (this.failBinary && value instanceof ArrayBuffer) throw new Error("not retained");
|
|
}
|
|
}
|
|
|
|
function bridgeProbeContext(): {
|
|
WebSocket: typeof ProbeWebSocket;
|
|
snapshot: () => BridgeProbeSnapshot;
|
|
} {
|
|
const context: Record<string, unknown> = {
|
|
ArrayBuffer,
|
|
URL,
|
|
structuredClone,
|
|
location: { href: "http://127.0.0.1/" },
|
|
navigator: { mediaDevices: { getDisplayMedia: async () => ({}) } },
|
|
WebSocket: ProbeWebSocket,
|
|
};
|
|
runInNewContext(senderProbe(), context);
|
|
return {
|
|
WebSocket: context.WebSocket as typeof ProbeWebSocket,
|
|
snapshot: (context.__NATIVE_ONE_VIEWER_GATE__ as { snapshot(): BridgeProbeSnapshot }).snapshot,
|
|
};
|
|
}
|
|
|
|
describe("native one-viewer gate invariants", () => {
|
|
it("selects the explicit native H.264 source without changing its RTP codec", () => {
|
|
expect(parseGateMode("native-h264")).toBe("native-h264");
|
|
expect(parseGateMode("h264")).toBe("h264");
|
|
expect(parseGateMode("unsupported")).toBe("vp8");
|
|
});
|
|
|
|
it("keeps the fixed native target title and an audible process-tree source", () => {
|
|
const source = decodeURIComponent(animatedSourceUrl());
|
|
expect(source).toContain("<title>Native Gate Source</title>");
|
|
expect(source).toContain("createOscillator()");
|
|
});
|
|
|
|
it("retains the exact native hardware lifecycle without creating a browser encoder", () => {
|
|
const probe = bridgeProbeContext();
|
|
const socket = new probe.WebSocket("ws://127.0.0.1/media");
|
|
socket.send(JSON.stringify({ kind: "config", codec: "h264", videoSource: "native-window-h264",
|
|
width: 1280, height: 720, fps: 30, bitrate: 3_000_000, encoderInstances: 1 }));
|
|
socket.emitMessage(JSON.stringify({ kind: "native-video-state", status: {
|
|
state: "starting", hardwareOnly: true, adapterIndex: 0, adapterName: "GPU",
|
|
adapterLuid: "0:1", mftIndex: 0, mftName: "Hardware H264", mftClsid: "clsid",
|
|
} }));
|
|
socket.emitMessage(JSON.stringify({ kind: "native-video-state", status: {
|
|
state: "active", hardwareOnly: true, profileLevelId: "42c01f",
|
|
width: 1280, height: 720, fps: 30,
|
|
} }));
|
|
expect(probe.snapshot()).toMatchObject({
|
|
nativeHardwareStarting: true,
|
|
nativeHardwareActive: true,
|
|
nativeAdapterLuid: "0:1",
|
|
encoderInstances: 0,
|
|
config: { codec: "h264", videoSource: "native-window-h264" },
|
|
});
|
|
});
|
|
|
|
it("rejects cumulative evidence from a rebuilt Viewer PC", () => {
|
|
const baseline = captureOneViewerIdentity(viewer, sender);
|
|
expect(baseline).not.toBeNull();
|
|
expect(retainsOneViewerIdentity(baseline!, {
|
|
...viewer, connectionCount: 2, connectionOrdinal: 2, pcCount: 2, pcOrdinal: 2,
|
|
}, sender)).toBe(false);
|
|
});
|
|
|
|
it("rejects a missing Pion slot", () => {
|
|
expect(captureOneViewerIdentity(viewer, { ...sender, slot: 0 })).toBeNull();
|
|
});
|
|
|
|
it("accepts fresh or same-generation cumulative Pion evidence only with Viewer growth", () => {
|
|
const before = { viewerPackets: 10, viewerDecoded: 10, viewerRendered: 10,
|
|
pionPackets: 20, pionBytes: 2_000 };
|
|
const advanced = { viewerPackets: 11, viewerDecoded: 11, viewerRendered: 11,
|
|
pionPackets: 21, pionBytes: 2_100 };
|
|
expect(hasOneViewerPionProof(true, before, advanced)).toBe(true);
|
|
expect(hasOneViewerPionProof(true, before, { ...advanced,
|
|
pionPackets: before.pionPackets, pionBytes: before.pionBytes })).toBe(true);
|
|
expect(hasOneViewerPionProof(true, before, { ...before,
|
|
pionPackets: advanced.pionPackets, pionBytes: advanced.pionBytes })).toBe(false);
|
|
expect(hasOneViewerPionProof(false, before, advanced)).toBe(false);
|
|
});
|
|
|
|
it("records only the current local bridge binary-send outcome", () => {
|
|
const successful = bridgeProbeContext();
|
|
const first = new successful.WebSocket("ws://127.0.0.1/media");
|
|
first.send("config");
|
|
first.send(new ArrayBuffer(1));
|
|
first.emitMessage(JSON.stringify({ kind: "diagnostics", media: {
|
|
framesWritten: 0, sourceRtpPacketsWritten: 0, sourceRtpBytesWritten: 0,
|
|
} }));
|
|
first.emitMessage(JSON.stringify({ kind: "diagnostics", media: null }));
|
|
first.emitMessage(JSON.stringify({ kind: "fatal" }));
|
|
first.emitMessage(JSON.stringify({ kind: "fatal" }));
|
|
expect(successful.snapshot()).toMatchObject({
|
|
bridgeGeneration: 1,
|
|
binarySendAttempts: 1,
|
|
binarySendSucceeded: 1,
|
|
binarySendFailed: 0,
|
|
diagnosticsObserved: true,
|
|
postSendDiagnosticsObserved: true,
|
|
postSendDiagnosticsSequence: 2,
|
|
fatalEvents: 1,
|
|
});
|
|
|
|
const replacement = new successful.WebSocket("ws://127.0.0.1/media");
|
|
replacement.send(new ArrayBuffer(1));
|
|
expect(successful.snapshot()).toMatchObject({
|
|
bridgeGeneration: 2,
|
|
binarySendAttempts: 1,
|
|
binarySendSucceeded: 1,
|
|
binarySendFailed: 0,
|
|
});
|
|
|
|
const failed = bridgeProbeContext();
|
|
const throwing = new failed.WebSocket("ws://127.0.0.1/media");
|
|
throwing.failBinary = true;
|
|
expect(() => throwing.send(new ArrayBuffer(1))).toThrow("not retained");
|
|
expect(failed.snapshot()).toMatchObject({
|
|
bridgeGeneration: 1,
|
|
binarySendAttempts: 1,
|
|
binarySendSucceeded: 0,
|
|
binarySendFailed: 1,
|
|
});
|
|
});
|
|
|
|
it("rejects a late bridge replacement at every post-start gate", () => {
|
|
const senderStart = {
|
|
bridgeGeneration: 1,
|
|
binarySendAttempts: 1,
|
|
binarySendSucceeded: 1,
|
|
binarySendFailed: 0,
|
|
};
|
|
expect(retainsFirstBridgeSend(senderStart)).toBe(true);
|
|
|
|
const replaced = { ...senderStart, bridgeGeneration: 2 };
|
|
expect({
|
|
viewerSignal: retainsFirstBridgeSend(replaced),
|
|
viewerMedia: retainsFirstBridgeSend(replaced),
|
|
finalSuccess: retainsFirstBridgeSend(replaced),
|
|
}).toEqual({ viewerSignal: false, viewerMedia: false, finalSuccess: false });
|
|
});
|
|
|
|
it("fails final sender evidence closed on late critical markers or sample failure", async () => {
|
|
const cleanSender = { fatalEvents: 0, encoderErrors: 0 };
|
|
expect(hasNoCriticalSenderErrors(cleanSender)).toBe(true);
|
|
await expect(verifyFinalSenderEvidence(
|
|
async () => ({ ...cleanSender, fatalEvents: 1 }),
|
|
(evidence) => evidence,
|
|
)).resolves.toBe(false);
|
|
await expect(verifyFinalSenderEvidence(
|
|
async () => cleanSender,
|
|
() => ({ ...cleanSender, encoderErrors: 1 }),
|
|
)).resolves.toBe(false);
|
|
await expect(verifyFinalSenderEvidence(
|
|
async () => { throw new Error("snapshot unavailable"); },
|
|
(evidence) => evidence,
|
|
)).resolves.toBe(false);
|
|
await expect(verifyFinalSenderEvidence(
|
|
async () => cleanSender,
|
|
(evidence) => evidence,
|
|
)).resolves.toBe(true);
|
|
});
|
|
|
|
it("bounds Chrome version headers and body with one abort deadline", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const signals: AbortSignal[] = [];
|
|
const headers = fetchJsonBefore("http://gate.invalid", Date.now() + 25,
|
|
async (_url, init) => {
|
|
signals.push(init.signal);
|
|
return new Promise<never>(() => undefined);
|
|
});
|
|
const body = fetchJsonBefore("http://gate.invalid", Date.now() + 25,
|
|
async (_url, init) => {
|
|
signals.push(init.signal);
|
|
return { ok: true, json: () => new Promise<never>(() => undefined) };
|
|
});
|
|
const headerAssertion = expect(headers).rejects.toThrow("Operation deadline exceeded");
|
|
const bodyAssertion = expect(body).rejects.toThrow("Operation deadline exceeded");
|
|
await vi.advanceTimersByTimeAsync(30);
|
|
await Promise.all([headerAssertion, bodyAssertion]);
|
|
expect(signals).toHaveLength(2);
|
|
expect(signals.every((signal) => signal.aborted)).toBe(true);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("bounds a hung browser sample by the stage deadline", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const pending = waitForSample(() => new Promise<never>(() => undefined), () => true, 25, 5);
|
|
const assertion = expect(pending).rejects.toThrow("Bounded browser stage timed out");
|
|
await vi.advanceTimersByTimeAsync(30);
|
|
await assertion;
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("cannot pass before cleanup and fails when profile removal fails", async () => {
|
|
const report = { status: "failed" as const, failedStage: "viewer-media" as string | null,
|
|
stages: {} };
|
|
let release!: (result: CleanupResult) => void;
|
|
const cleanup = new Promise<CleanupResult>((resolve) => { release = resolve; });
|
|
const finishing = finalizeGate(report, true, () => cleanup);
|
|
await Promise.resolve();
|
|
expect(report.status).toBe("failed");
|
|
release(clean);
|
|
await finishing;
|
|
expect(report).toMatchObject({ status: "passed", failedStage: null });
|
|
|
|
const failed = { status: "failed" as const, failedStage: null as string | null, stages: {} };
|
|
await finalizeGate(failed, true, async () => ({ ...clean, profileRemoved: false }));
|
|
expect(failed).toMatchObject({ status: "failed", failedStage: "cleanup" });
|
|
});
|
|
|
|
it("accepts only the exact task profile directly under system Temp", () => {
|
|
const temp = join("C:", "Temp");
|
|
expect(isExactGateProfile(join(temp, "screener-native-one-viewer-a1B_2-"), temp)).toBe(true);
|
|
expect(isExactGateProfile(join(temp, "screener-access-privacy-a1B_2-"), temp)).toBe(true);
|
|
expect(isExactGateProfile(join(temp, "nested", "screener-native-one-viewer-a1B_2-"), temp)).toBe(false);
|
|
expect(isExactGateProfile(join(temp, "other-a1B_2-"), temp)).toBe(false);
|
|
expect(profileCleanupScript).toContain("EnumerateFileSystemEntries($parent, $name");
|
|
expect(profileCleanupScript).toContain("GetAttributes($root)");
|
|
expect(profileCleanupScript).not.toMatch(/\[IO\.(?:File|Directory)\]::Exists/);
|
|
});
|
|
});
|