242 lines
7.7 KiB
TypeScript
242 lines
7.7 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { GuideStore, GuideStoreError } from "./guideStore";
|
|
|
|
let recordingsDir = "";
|
|
|
|
beforeEach(async () => {
|
|
recordingsDir = await fs.mkdtemp(path.join(os.tmpdir(), "openscreen-guide-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (recordingsDir) {
|
|
await fs.rm(recordingsDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("GuideStore", () => {
|
|
it("creates and reads an empty guide session", async () => {
|
|
const store = new GuideStore(recordingsDir);
|
|
|
|
const session = await store.startSession(123);
|
|
const readSession = await store.readSession(123);
|
|
|
|
expect(session.recordingId).toBe("123");
|
|
expect(session.status).toBe("recording");
|
|
expect(session.guidePath).toBe(path.join(recordingsDir, "recording-123.guide.json"));
|
|
expect(readSession).toEqual(session);
|
|
await expect(fs.stat(session.outputDir)).resolves.toMatchObject({
|
|
isDirectory: expect.any(Function),
|
|
});
|
|
});
|
|
|
|
it("adds marker events in timeline order", async () => {
|
|
const store = new GuideStore(recordingsDir);
|
|
await store.startSession(456);
|
|
|
|
await store.addMarker({ recordingId: 456, kind: "manual", timeMs: 2000, label: "Later" });
|
|
const result = await store.addMarker({
|
|
recordingId: 456,
|
|
kind: "hotkey",
|
|
timeMs: 500,
|
|
label: "First",
|
|
normalizedX: 0.25,
|
|
normalizedY: 0.75,
|
|
});
|
|
|
|
expect(result.event.kind).toBe("hotkey");
|
|
expect(result.event).toMatchObject({
|
|
x: 0.25,
|
|
y: 0.75,
|
|
normalizedX: 0.25,
|
|
normalizedY: 0.75,
|
|
});
|
|
expect(result.session.events.map((event) => event.timeMs)).toEqual([500, 2000]);
|
|
expect(result.session.events[0]?.source).toBe("guide-hotkey");
|
|
expect(result.session.events[1]?.source).toBe("review-ui");
|
|
});
|
|
|
|
it("finalizes a session against the saved video path", async () => {
|
|
const store = new GuideStore(recordingsDir);
|
|
await store.startSession(789);
|
|
const videoPath = path.join(recordingsDir, "recording-789.mp4");
|
|
await fs.writeFile(videoPath, "");
|
|
|
|
const session = await store.finalizeEvents({ recordingId: 789, videoPath });
|
|
|
|
expect(session.status).toBe("events-ready");
|
|
expect(session.videoPath).toBe(videoPath);
|
|
expect(session.guidePath).toBe(path.join(recordingsDir, "recording-789.guide.json"));
|
|
});
|
|
|
|
it("adds cursor click events when finalizing a session", async () => {
|
|
const store = new GuideStore(recordingsDir);
|
|
await store.startSession(790);
|
|
await store.addMarker({ recordingId: 790, kind: "manual", timeMs: 250, label: "Manual" });
|
|
const videoPath = path.join(recordingsDir, "recording-790.mp4");
|
|
await fs.writeFile(videoPath, "");
|
|
await fs.writeFile(
|
|
`${videoPath}.cursor.json`,
|
|
JSON.stringify({
|
|
version: 2,
|
|
provider: "native",
|
|
assets: [],
|
|
samples: [
|
|
{ timeMs: 100, cx: 0.2, cy: 0.3, interactionType: "move" },
|
|
{ timeMs: 200, cx: 0.4, cy: 0.5, interactionType: "click" },
|
|
{ timeMs: 225, cx: 0.401, cy: 0.501, interactionType: "click" },
|
|
],
|
|
}),
|
|
"utf-8",
|
|
);
|
|
|
|
const session = await store.finalizeEvents({ recordingId: 790, videoPath });
|
|
|
|
expect(session.cursorPath).toBe(`${videoPath}.cursor.json`);
|
|
expect(session.events.map((event) => event.kind)).toEqual(["click", "manual"]);
|
|
expect(session.events[0]).toMatchObject({
|
|
timeMs: 200,
|
|
normalizedX: 0.4,
|
|
normalizedY: 0.5,
|
|
});
|
|
});
|
|
|
|
it("rejects guide artifacts outside the recordings directory", async () => {
|
|
const store = new GuideStore(recordingsDir);
|
|
await store.startSession(321);
|
|
const outsideVideoPath = path.join(path.dirname(recordingsDir), "outside.mp4");
|
|
|
|
await expect(
|
|
store.finalizeEvents({ recordingId: 321, videoPath: outsideVideoPath }),
|
|
).rejects.toMatchObject({
|
|
code: "guide-invalid-input",
|
|
});
|
|
});
|
|
|
|
it("rejects invalid guide session schema", async () => {
|
|
const store = new GuideStore(recordingsDir);
|
|
await fs.writeFile(
|
|
path.join(recordingsDir, "recording-bad.guide.json"),
|
|
JSON.stringify({ schemaVersion: 999 }),
|
|
"utf-8",
|
|
);
|
|
|
|
await expect(store.readSession("bad")).rejects.toBeInstanceOf(GuideStoreError);
|
|
await expect(store.readSession("bad")).rejects.toMatchObject({
|
|
code: "guide-invalid-schema",
|
|
});
|
|
});
|
|
|
|
it("saves a reviewed generated guide", async () => {
|
|
const store = new GuideStore(recordingsDir);
|
|
await store.startSession(654);
|
|
|
|
const session = await store.saveGuide({
|
|
recordingId: 654,
|
|
generatedGuide: {
|
|
title: "Huong dan thao tac",
|
|
steps: [
|
|
{
|
|
id: "step-1",
|
|
order: 1,
|
|
title: "Mo cai dat",
|
|
instruction: "Nhan nut Settings.",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(session.status).toBe("reviewed");
|
|
expect(session.generatedGuide?.steps).toHaveLength(1);
|
|
});
|
|
|
|
it("writes snapshots and builds candidates without OCR", async () => {
|
|
const store = new GuideStore(recordingsDir);
|
|
await store.startSession(112);
|
|
await store.addMarker({ recordingId: 112, kind: "manual", timeMs: 500, label: "Save" });
|
|
const videoPath = path.join(recordingsDir, "recording-112.mp4");
|
|
await fs.writeFile(videoPath, "");
|
|
const eventsSession = await store.finalizeEvents({ recordingId: 112, videoPath });
|
|
|
|
const session = await store.writeSnapshot({
|
|
recordingId: 112,
|
|
eventId: eventsSession.events[0]?.id ?? "",
|
|
timeMs: 1000,
|
|
offsetMs: 500,
|
|
width: 800,
|
|
height: 600,
|
|
pngBytes: new Uint8Array([137, 80, 78, 71]).buffer,
|
|
});
|
|
|
|
expect(session.status).toBe("snapshots-ready");
|
|
expect(session.snapshots).toHaveLength(1);
|
|
expect(session.candidates[0]).toMatchObject({ targetText: "Save" });
|
|
await expect(fs.readFile(session.snapshots[0]?.path ?? "")).resolves.toEqual(
|
|
Buffer.from([137, 80, 78, 71]),
|
|
);
|
|
});
|
|
|
|
it("runs OCR, generates a local draft, and exports files", async () => {
|
|
const store = new GuideStore(recordingsDir, {
|
|
ocrClient: {
|
|
recognize: async (snapshot) => [
|
|
{
|
|
id: `ocr-${snapshot.id}-1`,
|
|
snapshotId: snapshot.id,
|
|
text: "Save",
|
|
confidence: 0.95,
|
|
box: { x: 0.45, y: 0.45, width: 0.15, height: 0.08 },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
await store.startSession(113);
|
|
const videoPath = path.join(recordingsDir, "recording-113.mp4");
|
|
await fs.writeFile(videoPath, "");
|
|
await fs.writeFile(
|
|
`${videoPath}.cursor.json`,
|
|
JSON.stringify({
|
|
samples: [{ timeMs: 200, cx: 0.5, cy: 0.5, interactionType: "click" }],
|
|
}),
|
|
"utf-8",
|
|
);
|
|
const eventsSession = await store.finalizeEvents({ recordingId: 113, videoPath });
|
|
await store.writeSnapshot({
|
|
recordingId: 113,
|
|
eventId: eventsSession.events[0]?.id ?? "",
|
|
timeMs: 700,
|
|
offsetMs: 500,
|
|
width: 800,
|
|
height: 600,
|
|
pngBytes: new Uint8Array([1, 2, 3]).buffer,
|
|
});
|
|
|
|
const ocrSession = await store.runOcr({ recordingId: 113 });
|
|
const draftSession = await store.generateDraft({
|
|
recordingId: 113,
|
|
language: "en",
|
|
provider: "local",
|
|
});
|
|
const markdown = await store.exportMarkdown({ recordingId: 113 });
|
|
const html = await store.exportHtml({ recordingId: 113 });
|
|
|
|
expect(ocrSession.candidates[0]).toMatchObject({ targetText: "Save" });
|
|
expect(draftSession.generatedGuide?.steps[0]?.instruction).toBe('Click "Save".');
|
|
await expect(fs.readFile(markdown.path, "utf-8")).resolves.toContain("# User guide");
|
|
await expect(fs.readFile(html.path, "utf-8")).resolves.toContain("<!doctype html>");
|
|
});
|
|
|
|
it("discards a guide session and output directory", async () => {
|
|
const store = new GuideStore(recordingsDir);
|
|
const session = await store.startSession(111);
|
|
await fs.writeFile(path.join(session.outputDir, "step-001.png"), "");
|
|
|
|
await store.discardSession({ recordingId: 111 });
|
|
|
|
await expect(fs.stat(session.guidePath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(fs.stat(session.outputDir)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
});
|