diff --git a/frontend/scripts/test-ws-outbox.mjs b/frontend/scripts/test-ws-outbox.mjs index 516396e..96c28d8 100644 --- a/frontend/scripts/test-ws-outbox.mjs +++ b/frontend/scripts/test-ws-outbox.mjs @@ -5,7 +5,7 @@ import { transform } from "esbuild"; const source = await readFile(new URL("../src/shared/api/ws.ts", import.meta.url), "utf8"); const { code } = await transform(source, { loader: "ts", format: "esm" }); const moduleUrl = `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`; -const { callChannel, stationChannel } = await import(moduleUrl); +const { callChannel, randomUuid, stationChannel } = await import(moduleUrl); const mergeSource = await readFile(new URL("../src/features/kio-card/merge.ts", import.meta.url), "utf8"); const { code: mergeCode } = await transform(mergeSource, { loader: "ts", format: "esm" }); const { applyPatch, applyState, edit, empty } = await import( @@ -119,4 +119,19 @@ socket.receive({ type: "station.state", snapshot: { card_id: "card-1", reply_tex socket = reconnect(socket); assert.deepEqual(socket.sent, [], "matching persisted station snapshot clears the reply"); +// Стенд по http:// — не защищённый контекст: randomUUID у браузера нет. +const nativeRandomUuid = Object.getOwnPropertyDescriptor(globalThis.crypto, "randomUUID"); +Object.defineProperty(globalThis.crypto, "randomUUID", { value: undefined, configurable: true }); +try { + const ids = Array.from({ length: 200 }, () => randomUuid()); + for (const id of ids) { + assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, "UUID v4 without randomUUID"); + } + assert.equal(new Set(ids).size, ids.length, "UUIDs without randomUUID do not repeat"); +} finally { + if (nativeRandomUuid) Object.defineProperty(globalThis.crypto, "randomUUID", nativeRandomUuid); + else delete globalThis.crypto.randomUUID; +} +assert.match(randomUuid(), /^[0-9a-f-]{36}$/, "native randomUUID is used when present"); + console.log("WebSocket durable outbox replay/ack tests passed"); diff --git a/frontend/src/features/debrief/FindingReview.tsx b/frontend/src/features/debrief/FindingReview.tsx index ef47f23..580d6af 100644 --- a/frontend/src/features/debrief/FindingReview.tsx +++ b/frontend/src/features/debrief/FindingReview.tsx @@ -5,6 +5,7 @@ import { useRef, useState } from "react"; +import { randomUuid } from "@/shared/api/ws"; import type { ErrorCode, FindingDecision, SessionReport } from "@/shared/types/generated"; const DDS_CODES: ErrorCode[] = ["D1", "D2", "D3", "D4", "D5", "D6"]; @@ -77,7 +78,7 @@ export function FindingReview({ report, onChange }: { setError("Для отметки нужны факт и норма."); return; } - pendingId.current ??= crypto.randomUUID(); + pendingId.current ??= randomUuid(); const clientId = pendingId.current; void run(async () => { const updated = await send(base, { diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx index 2cc8806..a2a97ec 100644 --- a/frontend/src/pages/instructor/Instructor.tsx +++ b/frontend/src/pages/instructor/Instructor.tsx @@ -25,6 +25,7 @@ import { } from "@/shared/api/http"; import type { Signal } from "@/shared/api/http"; import { sessionIdFromUrl } from "@/shared/api/session"; +import { randomUuid } from "@/shared/api/ws"; import type { Level, SessionMode, SessionReport } from "@/shared/types/generated"; const MODES: { value: SessionMode; label: string; hint: string }[] = [ @@ -214,7 +215,7 @@ export function Instructor() { function startLesson() { if (startPendingId || toStart.current || startDisabledReason) return; if (trainees.data?.length && (!traineeId || (traineeId === "__demo__" && !DEMO_MODE))) return; - const id = crypto.randomUUID(); + const id = randomUuid(); // Номер занятия живёт в адресе: монитор и АРМ курсанта открываются ссылкой. window.history.replaceState(null, "", `/instructor?session=${id}`); toStart.current = id; diff --git a/frontend/src/shared/api/ws.ts b/frontend/src/shared/api/ws.ts index 6012c6e..9179e8b 100644 --- a/frontend/src/shared/api/ws.ts +++ b/frontend/src/shared/api/ws.ts @@ -34,6 +34,18 @@ const BUFFER_MAX_ITEMS = 100; interface Buffered { key: string; commandId: string; event: Out; at: number } +/** UUID v4 для номера занятия и команд. `crypto.randomUUID` браузер отдаёт только + * в защищённом контексте (HTTPS, localhost): стенд по `http://` в классе + * его не видит, и запуск занятия молча падал. `getRandomValues` есть везде. */ +export function randomUuid(): string { + if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + export class Channel { private socket: WebSocket | null = null; private attempt = 0; @@ -127,7 +139,7 @@ export class Channel existing.at = Date.now(); pending = existing; } else { - pending = { key, commandId: crypto.randomUUID(), event, at: Date.now() }; + pending = { key, commandId: randomUuid(), event, at: Date.now() }; this.buffered.push(pending); if (this.buffered.length > BUFFER_MAX_ITEMS) this.buffered.shift(); } @@ -196,7 +208,7 @@ export const stationChannel = ( if (event.type === "card.reply") return `card.reply:${event.card_id}`; // Non-replacement station actions are each their own replayable command. // The server deduplicates by this ID inside its persisted session snapshot. - return `command:${crypto.randomUUID()}`; + return `command:${randomUuid()}`; }, isAcknowledged: (incoming, pending) => incoming.type === "station.state" && pending.type === "card.reply"