Files
jhonny-editor/bm/premiere-pro-mcp-main/tests/project-context.test.ts
T
João Henrique b541f502ba feat: initial commit - Jhonny Editor
- Adicionado estrutura completa do projeto
- Configurado MCP server para Premiere Pro
- Adicionado documentação e skills
- Configurado Gitignore para o projeto
2026-09-08 09:59:31 -04:00

514 lines
24 KiB
TypeScript
Executable File

import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
defaultProjectContextDirectory,
MAX_CONTEXT_RECORDS,
normalizeContextKeywords,
normalizeContextText,
ProjectContextRepository,
searchProjectContext,
} from "../src/context/project-context-store.js";
import {
buildContextDocumentFromSnapshot,
enrichContextDocument,
getProjectContextTools,
MAX_CONCURRENT_SOURCE_FINGERPRINTS,
type PremiereContextSnapshot,
} from "../src/tools/project-context.js";
const temporaryDirectories: string[] = [];
const [nodeMajor, nodeMinor] = process.versions.node.split(".").map(Number);
const supportsNodeSqlite = nodeMajor > 22 || (nodeMajor === 22 && nodeMinor >= 5);
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
function snapshot(overrides: Partial<PremiereContextSnapshot["sequence"]["clips"][number]> = {}): PremiereContextSnapshot {
return {
projectName: "Documentary",
projectPath: "D:/Projects/documentary.prproj",
sequence: {
id: "sequence-1",
name: "Assembly",
durationSeconds: 120,
truncated: false,
clips: [
{
nodeId: "timeline-1",
name: "Interview A",
startSeconds: 10,
endSeconds: 20,
inPointSeconds: 30,
outPointSeconds: 40,
speed: 1,
trackType: "video",
trackIndex: 0,
sourceId: "source-1",
sourceName: "Interview A.mov",
mediaPath: "D:/Media/interview-a.mov",
offline: false,
...overrides,
},
],
},
};
}
describe("project context index", () => {
it("separates source invalidation from timeline placement revisions", async () => {
let mediaVersion = 1;
const fingerprint = async () => ({ mediaPathHash: "path-hash", size: 1_000, modifiedMs: mediaVersion });
const first = await buildContextDocumentFromSnapshot(snapshot(), undefined, fingerprint);
const source = first.document.records.find((record) => record.kind === "source")!;
const enriched = enrichContextDocument(first.document, [
{
kind: "transcript",
source_id: "source-1",
source_revision: source.sourceRevision,
start_seconds: 31,
end_seconds: 35,
text: "The budget needs approval by Friday.",
keywords: ["budget", "approval"],
},
]).document;
const moved = await buildContextDocumentFromSnapshot(
snapshot({ startSeconds: 45, endSeconds: 55 }),
enriched,
fingerprint,
);
expect(moved.document.sourceRevision).toBe(first.document.sourceRevision);
expect(moved.document.timelineRevision).not.toBe(first.document.timelineRevision);
expect(moved.document.records.some((record) => record.kind === "transcript")).toBe(true);
expect(moved.invalidatedRecords).toBe(0);
mediaVersion = 2;
const changedSource = await buildContextDocumentFromSnapshot(snapshot(), moved.document, fingerprint);
expect(changedSource.document.sourceRevision).not.toBe(first.document.sourceRevision);
expect(changedSource.document.records.some((record) => record.kind === "transcript")).toBe(false);
expect(changedSource.invalidatedRecords).toBe(1);
});
it("bounds concurrent source fingerprint work during a large capture", async () => {
const largeSnapshot = snapshot();
largeSnapshot.sequence.clips = Array.from({ length: MAX_CONCURRENT_SOURCE_FINGERPRINTS * 3 }, (_, index) => ({
...largeSnapshot.sequence.clips[0]!,
nodeId: `timeline-${index}`,
sourceId: `source-${index}`,
mediaPath: `D:/Media/source-${index}.mov`,
}));
let active = 0;
let peak = 0;
await buildContextDocumentFromSnapshot(largeSnapshot, undefined, async () => {
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setTimeout(resolve, 0));
active -= 1;
return {};
});
expect(peak).toBe(MAX_CONCURRENT_SOURCE_FINGERPRINTS);
});
it("ranks bounded transcript and audio evidence while preserving revision provenance", async () => {
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({ mediaPathHash: "hash" }));
const source = built.document.records.find((record) => record.kind === "source")!;
const enriched = enrichContextDocument(built.document, [
{
id: "budget-transcript",
kind: "transcript",
name: "Budget answer",
source_id: "source-1",
source_revision: source.sourceRevision,
start_seconds: 31,
end_seconds: 35,
text: "The budget needs approval by Friday.",
keywords: ["budget", "approval"],
},
{
id: "room-tone",
kind: "audio",
source_id: "source-1",
source_revision: source.sourceRevision,
text: "Quiet room tone suitable for dialogue padding.",
keywords: ["room tone"],
},
]).document;
const results = searchProjectContext(enriched, { query: "budget approval", limit: 1 });
expect(results).toHaveLength(1);
expect(results[0].record.id).toBe("budget-transcript");
expect(results[0].record.sourceRevision).toBe(source.sourceRevision);
expect(results[0].matchedTerms).toEqual(expect.arrayContaining(["budget", "approval"]));
});
it("persists locally without retaining a native media path", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "ppmcp-context-test-"));
temporaryDirectories.push(directory);
const repository = new ProjectContextRepository({ backend: "json", directory });
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({ mediaPathHash: "redacted-path-hash" }));
await repository.put(built.document);
const restored = await repository.get(built.document.projectId);
expect(restored).toEqual(built.document);
expect(await repository.backendName()).toBe("json");
const files = await readdir(directory);
const serialized = await readFile(path.join(directory, files.find((name) => name.endsWith(".json"))!), "utf8");
expect(serialized).not.toContain("D:/Media/interview-a.mov");
expect(serialized).toContain("redacted-path-hash");
});
it.runIf(supportsNodeSqlite)("uses SQLite when requested and supports complete repository lifecycle", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "ppmcp-context-sqlite-test-"));
temporaryDirectories.push(directory);
const repository = new ProjectContextRepository({ backend: "sqlite", directory });
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({ mediaPathHash: "hash" }));
expect(await repository.backendName()).toBe("sqlite");
expect(await repository.get("missing")).toBeUndefined();
await repository.put(built.document);
expect(await repository.get(built.document.projectId)).toEqual(built.document);
expect(await repository.list()).toEqual([
expect.objectContaining({ projectId: built.document.projectId, recordCount: built.document.records.length }),
]);
const updated = { ...built.document, projectName: "Updated documentary" };
await repository.put(updated);
expect((await repository.get(built.document.projectId))?.projectName).toBe("Updated documentary");
expect(await repository.delete(built.document.projectId)).toBe(true);
expect(await repository.delete(built.document.projectId)).toBe(false);
await repository.close();
});
it("handles missing and corrupt JSON entries without hiding valid project summaries", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "ppmcp-context-json-test-"));
temporaryDirectories.push(directory);
const repository = new ProjectContextRepository({ backend: "json", directory });
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({}));
expect(await repository.get("missing")).toBeUndefined();
expect(await repository.delete("missing")).toBe(false);
await repository.put(built.document);
await writeFile(path.join(directory, "corrupt.json"), "not json", "utf8");
expect(await repository.list()).toEqual([
expect.objectContaining({ projectId: built.document.projectId }),
]);
const validFile = (await readdir(directory)).find((name) => name !== "corrupt.json")!;
await writeFile(path.join(directory, validFile), JSON.stringify({ schemaVersion: 999 }), "utf8");
await expect(repository.get(built.document.projectId)).rejects.toThrow("Unsupported project context schema version");
});
it("validates repository backend, record bounds, and context directory overrides", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "ppmcp-context-env-test-"));
temporaryDirectories.push(directory);
vi.stubEnv("PREMIERE_CONTEXT_DIR", directory);
expect(defaultProjectContextDirectory()).toBe(path.resolve(directory));
await expect(new ProjectContextRepository({ backend: "invalid" as any }).backendName())
.rejects.toThrow("must be auto, sqlite, json, or memory");
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({}));
const repository = new ProjectContextRepository({ backend: "memory" });
await expect(repository.put({
...built.document,
records: Array.from({ length: MAX_CONTEXT_RECORDS + 1 }, () => built.document.records[0]),
})).rejects.toThrow(`limited to ${MAX_CONTEXT_RECORDS}`);
expect(await repository.get("missing")).toBeUndefined();
expect(await repository.list()).toEqual([]);
expect(await repository.delete("missing")).toBe(false);
});
it("normalizes bounded text and filters search by sequence, kind, and result limits", async () => {
expect(normalizeContextText(null)).toBe("");
expect(normalizeContextText(" multiple\n spaces ")).toBe("multiple spaces");
expect(normalizeContextKeywords("not-an-array")).toEqual([]);
expect(normalizeContextKeywords([" budget ", "budget", 4])).toEqual(["budget"]);
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({}));
expect(() => searchProjectContext(built.document, { query: " " })).toThrow("must not be empty");
expect(searchProjectContext(built.document, {
query: "interview",
sequenceId: "different-sequence",
})).toEqual([]);
const sourceOnly = searchProjectContext(built.document, {
query: "interview",
kinds: ["source"],
limit: 100,
});
expect(sourceOnly).toHaveLength(1);
expect(sourceOnly[0].record.kind).toBe("source");
expect(searchProjectContext(built.document, { query: "not-present", limit: -4 })).toEqual([]);
});
it("supports enrich, status, search, plan, and clear through the MCP tool contracts", async () => {
const repository = new ProjectContextRepository({ backend: "memory" });
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({ mediaPathHash: "hash" }));
await repository.put(built.document);
const tools = getProjectContextTools({}, { repository });
const source = built.document.records.find((record) => record.kind === "source")!;
const enrich = await (tools.manage_project_context.handler as any)({
action: "enrich",
project_id: built.document.projectId,
records: [
{
kind: "transcript",
source_id: "source-1",
source_revision: source.sourceRevision,
start_seconds: 31,
end_seconds: 35,
text: "The budget needs approval by Friday.",
keywords: ["budget"],
metadata: { confidence: 0.97, source_path: "D:/private.mov", api_token: "secret" },
},
],
});
expect(enrich).toMatchObject({ success: true, data: { upserted: 1 } });
const status = await (tools.manage_project_context.handler as any)({
action: "status",
project_id: built.document.projectId,
});
expect(status).toMatchObject({ success: true, data: { backend: "memory", counts: { transcript: 1 } } });
const search = await (tools.search_project_context.handler as any)({
project_id: built.document.projectId,
query: "budget",
});
expect(search.data.results[0]).toMatchObject({ kind: "transcript", sourceId: "source-1" });
expect(search.data.results[0].metadata).toEqual({ confidence: 0.97 });
await expect((tools.search_project_context.handler as any)({
project_id: built.document.projectId,
query: "budget",
max_results: 51,
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("1 through 50") });
const plan = await (tools.create_context_edit_plan.handler as any)({
project_id: built.document.projectId,
intent: "Use the budget answer",
});
expect(plan).toMatchObject({
success: true,
data: {
applied: false,
expectedTimelineRevision: built.document.timelineRevision,
candidates: [{ kind: "transcript", sourceId: "source-1" }],
},
});
expect(plan.data.nextSteps.join(" ")).toContain("preview_edit_plan");
const cleared = await (tools.manage_project_context.handler as any)({
action: "clear",
project_id: built.document.projectId,
});
expect(cleared).toEqual({ success: true, data: { projectId: built.document.projectId, cleared: true } });
});
it("imports explicit revision-bound editorial evidence without opening frames or calling a provider", async () => {
const repository = new ProjectContextRepository({ backend: "memory" });
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({ mediaPathHash: "hash" }));
await repository.put(built.document);
const source = built.document.records.find((record) => record.kind === "source")!;
const tools = getProjectContextTools({}, { repository });
const result = await (tools.manage_project_context.handler as any)({
action: "import_evidence",
project_id: built.document.projectId,
evidence: [
{
type: "transcript_passage",
text: "The budget needs approval by Friday.",
speaker_label: "Presenter",
source_id: "source-1",
source_revision: source.sourceRevision,
sequence_id: "sequence-1",
timeline_item_id: "timeline-1",
timeline_revision: built.document.timelineRevision,
start_seconds: 31,
end_seconds: 35,
metadata: { confidence: 0.97, source_path: "D:/private.mov", api_token: "secret" },
},
{
type: "shot_log",
text: "Medium interview framing with a visible product sample.",
source_id: "source-1",
source_revision: source.sourceRevision,
},
{
type: "audio_observation",
text: "Clean dialogue; brief room-tone gap before the answer.",
},
{
type: "operator_note",
text: "Prefer this explanation after the opening question.",
},
{
type: "frame_reference",
frame_reference_id: "contact-sheet-003",
sequence_id: "sequence-1",
timeline_revision: built.document.timelineRevision,
},
{
type: "speaker_label",
speaker_label: "Presenter",
source_id: "source-1",
source_revision: source.sourceRevision,
},
],
});
expect(result).toMatchObject({
success: true,
data: {
applied: false,
upserted: 6,
importedEvidenceTypes: expect.arrayContaining(["transcript_passage", "frame_reference"]),
},
});
const current = await repository.get(built.document.projectId);
const transcript = current!.records.find((record) => record.kind === "transcript")!;
const frame = current!.records.find((record) => record.metadata?.frameReferenceId === "contact-sheet-003")!;
expect(transcript).toMatchObject({
sourceRevision: source.sourceRevision,
timelineRevision: built.document.timelineRevision,
metadata: { evidenceType: "transcript_passage", speakerLabel: "Presenter", confidence: 0.97 },
});
expect(transcript.metadata).not.toHaveProperty("source_path");
expect(transcript.metadata).not.toHaveProperty("api_token");
expect(frame).toMatchObject({ kind: "shot", timelineRevision: built.document.timelineRevision });
const moved = await buildContextDocumentFromSnapshot(
snapshot({ startSeconds: 45, endSeconds: 55 }),
current,
async () => ({ mediaPathHash: "hash" }),
);
expect(moved.document.records.some((record) => record.metadata?.evidenceType === "transcript_passage")).toBe(false);
expect(moved.document.records.some((record) => record.metadata?.evidenceType === "frame_reference")).toBe(false);
expect(moved.document.records.some((record) => record.metadata?.evidenceType === "shot_log")).toBe(true);
expect(moved.invalidatedRecords).toBeGreaterThanOrEqual(2);
});
it("rejects unguarded, stale, unknown, and path-shaped editorial evidence references", async () => {
const repository = new ProjectContextRepository({ backend: "memory" });
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({ mediaPathHash: "hash" }));
await repository.put(built.document);
const tools = getProjectContextTools({}, { repository });
const importEvidence = (evidence: unknown[]) => (tools.manage_project_context.handler as any)({
action: "import_evidence",
project_id: built.document.projectId,
evidence,
});
await expect(importEvidence([{ type: "shot_log", text: "Shot", source_id: "source-1" }]))
.resolves.toMatchObject({ success: false, error: expect.stringContaining("source_revision") });
await expect(importEvidence([{ type: "operator_note", text: "Note", sequence_id: "sequence-1" }]))
.resolves.toMatchObject({ success: false, error: expect.stringContaining("timeline_revision") });
await expect(importEvidence([{ type: "frame_reference", frame_reference_id: "D:/private/frame.png" }]))
.resolves.toMatchObject({ success: false, error: expect.stringContaining("opaque identifier") });
await expect(importEvidence([{ type: "operator_note", text: "Note", timeline_revision: "stale" }]))
.resolves.toMatchObject({ success: false, error: expect.stringContaining("requires sequence_id") });
});
it("captures through the MCP contract and preserves bridge failures", async () => {
const repository = new ProjectContextRepository({ backend: "memory" });
const captureSnapshot = vi.fn().mockResolvedValue({ success: true, data: snapshot() });
const tools = getProjectContextTools({}, { repository, captureSnapshot });
const captured = await (tools.manage_project_context.handler as any)({ action: "capture" });
expect(captured).toMatchObject({
success: true,
data: { backend: "memory", projectName: "Documentary", recordCount: 4, invalidatedRecords: 0 },
});
expect(captureSnapshot).toHaveBeenCalledOnce();
const statusList = await (tools.manage_project_context.handler as any)({ action: "status" });
expect(statusList.data.projects).toHaveLength(1);
const failedTools = getProjectContextTools({}, {
repository,
captureSnapshot: async () => ({ success: false, error: "Premiere offline" }),
});
await expect((failedTools.manage_project_context.handler as any)({ action: "capture" }))
.resolves.toEqual({ success: false, error: "Premiere offline" });
});
it("returns bounded not-found and unsupported-action errors", async () => {
const repository = new ProjectContextRepository({ backend: "memory" });
const tools = getProjectContextTools({}, { repository });
await expect((tools.manage_project_context.handler as any)({ action: "status", project_id: "missing" }))
.resolves.toEqual({ success: false, error: "Project context not found" });
await expect((tools.manage_project_context.handler as any)({ action: "enrich", project_id: "missing", records: [] }))
.resolves.toEqual({ success: false, error: "Project context not found; capture it before enrichment" });
await expect((tools.search_project_context.handler as any)({ project_id: "missing", query: "x" }))
.resolves.toEqual({ success: false, error: "Project context not found" });
await expect((tools.create_context_edit_plan.handler as any)({ project_id: "missing", intent: "x" }))
.resolves.toEqual({ success: false, error: "Project context not found" });
await expect((tools.manage_project_context.handler as any)({ action: "unknown" }))
.resolves.toEqual({ success: false, error: "Unsupported project context action: unknown" });
await expect((tools.manage_project_context.handler as any)({ action: "clear", project_id: "" }))
.rejects.toThrow("project_id is required");
});
it("rejects stale enrichment revisions", async () => {
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({ mediaPathHash: "hash" }));
expect(() => enrichContextDocument(built.document, [
{
kind: "transcript",
source_id: "source-1",
source_revision: "stale",
text: "Old analysis",
},
])).toThrow("stale source_revision");
});
it("validates enrichment shapes, bounds, replacement, and timeline revisions", async () => {
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({}));
expect(() => enrichContextDocument(built.document, [])).toThrow("at least one enrichment");
expect(() => enrichContextDocument(built.document, Array.from({ length: 513 }, () => ({
kind: "note" as const,
text: "note",
})))).toThrow("limited to 512");
expect(() => enrichContextDocument(built.document, [{ kind: "source", text: "bad" }]))
.toThrow("kind must be transcript, shot, audio, or note");
expect(() => enrichContextDocument(built.document, [{ kind: "note", text: " " }]))
.toThrow("text must not be empty");
expect(() => enrichContextDocument(built.document, [{ kind: "audio", source_id: "missing", text: "quiet" }]))
.toThrow("unknown source_id");
expect(() => enrichContextDocument(built.document, [{
kind: "note",
timeline_revision: "stale",
text: "stale note",
}])).toThrow("stale timeline_revision");
expect(() => enrichContextDocument(built.document, [{ kind: "shot", start_seconds: -1, text: "bad" }]))
.toThrow("start_seconds must be non-negative");
expect(() => enrichContextDocument(built.document, [{ kind: "shot", start_seconds: 5, end_seconds: 4, text: "bad" }]))
.toThrow("end_seconds must be at or after");
const first = enrichContextDocument(built.document, [{ id: "replace-me", kind: "note", text: "first" }]).document;
const replaced = enrichContextDocument(first, [{
id: "replace-me",
kind: "note",
text: "second",
timeline_item_id: "timeline-1",
timeline_revision: first.timelineRevision,
track_type: "video",
track_index: 0,
metadata: { approved: true, score: 1, nullable: null, nested: { ignored: true } },
}], true).document;
expect(replaced.records.filter((record) => record.kind === "note")).toEqual([
expect.objectContaining({ text: "second", timelineRevision: first.timelineRevision }),
]);
});
it("validates incomplete and oversized Premiere snapshots", async () => {
await expect(buildContextDocumentFromSnapshot({ projectName: "bad" } as any))
.rejects.toThrow("incomplete context snapshot");
const oversized = snapshot();
oversized.sequence.clips = Array.from({ length: 2_001 }, (_, index) => ({
...snapshot().sequence.clips[0],
nodeId: `clip-${index}`,
}));
await expect(buildContextDocumentFromSnapshot(oversized))
.rejects.toThrow("limited to 2000 timeline items");
});
});