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
This commit is contained in:
+338
@@ -0,0 +1,338 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { createServer } from "../../src/server.js";
|
||||
import { getUxpTools } from "../../src/tools/uxp.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
type UxpTool = {
|
||||
parameters: {
|
||||
type?: string;
|
||||
properties?: Record<string, Record<string, unknown>>;
|
||||
required?: string[];
|
||||
};
|
||||
handler: (args: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const ADOBE_26_3_TOOLS = [
|
||||
"rename_track_uxp",
|
||||
"create_subclip_uxp",
|
||||
"list_markers_uxp",
|
||||
"set_source_monitor_position_uxp",
|
||||
"inspect_frame_alignment_uxp",
|
||||
"calculate_tick_time_uxp",
|
||||
"manage_sequence_preview_frame_uxp",
|
||||
"inspect_source_media_provenance_uxp",
|
||||
"inspect_source_proxy_uxp",
|
||||
"manage_source_media_overrides_uxp",
|
||||
"inspect_track_item_identity_uxp",
|
||||
"slide_track_item_uxp",
|
||||
"duplicate_track_item_uxp",
|
||||
"ripple_delete_track_item_uxp",
|
||||
"create_empty_sequence_uxp",
|
||||
"audit_object_masks_uxp",
|
||||
"inspect_unique_object_identity_uxp",
|
||||
"inspect_effect_parameter_catalog_uxp",
|
||||
"has_transcript_uxp",
|
||||
"export_aaf_uxp",
|
||||
] as const;
|
||||
|
||||
function catalog(request = vi.fn().mockResolvedValue({ outcome: "verified" })) {
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
return { request, tools: getUxpTools(bridge) as Record<string, UxpTool> };
|
||||
}
|
||||
|
||||
describe("Adobe Premiere 26.3 UXP public MCP catalog", () => {
|
||||
it("registers the sampled capability-gated tools only with a UXP bridge", async () => {
|
||||
const bridge = {
|
||||
request: vi.fn(),
|
||||
getState: vi.fn(() => ({ status: "listening", connected: false })),
|
||||
} as unknown as UxpWebSocketBridge;
|
||||
const server = createServer({}, { uxpBridge: bridge });
|
||||
const client = new Client({ name: "adobe-26-3-catalog-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
try {
|
||||
const listed = await client.listTools();
|
||||
expect(listed.tools.map((tool) => tool.name)).toEqual(
|
||||
expect.arrayContaining(ADOBE_26_3_TOOLS),
|
||||
);
|
||||
expect(listed.tools).toHaveLength(440);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes bounded schemas for Adobe's documented 26.3 APIs", () => {
|
||||
const { tools } = catalog();
|
||||
expect(Object.keys(tools)).toEqual(expect.arrayContaining(ADOBE_26_3_TOOLS));
|
||||
|
||||
expect(tools.rename_track_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
required: ["track_type", "track_index", "name"],
|
||||
properties: {
|
||||
track_type: { type: "string", enum: ["audio", "video", "caption"] },
|
||||
track_index: { type: "integer" },
|
||||
name: { type: "string" },
|
||||
operation_id: { type: "string" },
|
||||
},
|
||||
});
|
||||
expect(tools.create_subclip_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
required: ["name", "start_seconds", "end_seconds"],
|
||||
properties: {
|
||||
project_item_id: { type: "string" },
|
||||
project_item_name: { type: "string" },
|
||||
name: { type: "string" },
|
||||
start_seconds: { type: "number" },
|
||||
end_seconds: { type: "number" },
|
||||
hard_boundaries: { type: "boolean" },
|
||||
take_video: { type: "boolean" },
|
||||
take_audio: { type: "boolean" },
|
||||
operation_id: { type: "string" },
|
||||
},
|
||||
});
|
||||
expect(tools.list_markers_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["sequence", "project_item"] },
|
||||
project_item_id: { type: "string" },
|
||||
project_item_name: { type: "string" },
|
||||
filters: { type: "array", items: { type: "string" } },
|
||||
include_web_links: { type: "boolean" },
|
||||
include_color_values: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
expect(tools.set_source_monitor_position_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
required: ["seconds"],
|
||||
properties: { seconds: { type: "number" }, operation_id: { type: "string" } },
|
||||
});
|
||||
expect(tools.inspect_frame_alignment_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false, required: ["action", "frame_rate"],
|
||||
properties: {
|
||||
action: { type: "string", enum: ["align", "frame"] },
|
||||
frame_rate: { type: "number", minimum: 1, maximum: 240 },
|
||||
seconds: { type: "number", minimum: 0, maximum: 86400 },
|
||||
frame_count: { type: "integer", minimum: 0, maximum: 20736000 },
|
||||
},
|
||||
});
|
||||
expect(tools.calculate_tick_time_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false, required: ["operation", "base_ticks"],
|
||||
properties: {
|
||||
operation: { type: "string", enum: ["add", "subtract", "multiply", "divide"] },
|
||||
base_ticks: { type: "string", pattern: "^(?:0|-?[1-9][0-9]{0,17})$" },
|
||||
operand_ticks: { type: "string", pattern: "^(?:0|-?[1-9][0-9]{0,17})$" },
|
||||
factor: { type: "integer", minimum: -1_000_000, maximum: 1_000_000 },
|
||||
},
|
||||
});
|
||||
expect(tools.manage_sequence_preview_frame_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false, required: ["action", "sequence_id"],
|
||||
properties: {
|
||||
action: { type: "string", enum: ["inspect", "update"] },
|
||||
sequence_id: { type: "string", minLength: 1, maxLength: 128 },
|
||||
preview_width: { type: "integer", minimum: 16, maximum: 10240 },
|
||||
preview_height: { type: "integer", minimum: 16, maximum: 8192 },
|
||||
expected_snapshot: { type: "object", additionalProperties: false, required: ["project_guid", "sequence_id", "preview_width", "preview_height"] },
|
||||
confirm_set_preview_frame: { type: "boolean" }, operation_id: { type: "string" },
|
||||
},
|
||||
});
|
||||
expect(tools.inspect_source_media_provenance_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false, required: ["project_item_id"],
|
||||
properties: {
|
||||
project_item_id: { type: "string", minLength: 1, maxLength: 512 },
|
||||
include_media_file_path: { type: "boolean" },
|
||||
include_originating_project_path: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
expect(tools.inspect_source_proxy_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false, required: ["project_item_id"],
|
||||
properties: {
|
||||
project_item_id: { type: "string", minLength: 1, maxLength: 512 },
|
||||
include_proxy_path: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
expect(tools.create_empty_sequence_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["name", "confirm_non_undoable", "operation_id"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
confirm_non_undoable: { type: "boolean" },
|
||||
operation_id: { type: "string" },
|
||||
},
|
||||
});
|
||||
expect(tools.audit_object_masks_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
expected_project_guid: { type: "string", minLength: 1, maxLength: 512 },
|
||||
sequence_ids: {
|
||||
type: "array", minItems: 1, maxItems: 64, uniqueItems: true,
|
||||
items: { type: "string", minLength: 1, maxLength: 512 },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(tools.inspect_unique_object_identity_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false,
|
||||
properties: {
|
||||
project_item_id: { type: "string", minLength: 1, maxLength: 512 },
|
||||
sequence_guid: { type: "string", minLength: 1, maxLength: 512 },
|
||||
expected_project_guid: { type: "string", minLength: 1, maxLength: 512 },
|
||||
expected_unique_id: { type: "string", minLength: 1, maxLength: 512 },
|
||||
},
|
||||
oneOf: [{ required: ["project_item_id"] }, { required: ["sequence_guid"] }],
|
||||
});
|
||||
expect(tools.inspect_track_item_identity_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["media_type", "track_index", "clip_index"],
|
||||
properties: {
|
||||
media_type: { type: "string", enum: ["video", "audio"] },
|
||||
track_index: { type: "integer", minimum: 0 },
|
||||
clip_index: { type: "integer", minimum: 0 },
|
||||
expected_sequence_guid: { type: "string", maxLength: 512 },
|
||||
},
|
||||
});
|
||||
expect(tools.inspect_effect_parameter_catalog_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false,
|
||||
required: ["media_type", "track_index", "clip_index", "component_index"],
|
||||
properties: {
|
||||
media_type: { enum: ["video", "audio"] },
|
||||
track_index: { maximum: 511 }, clip_index: { maximum: 511 }, component_index: { maximum: 511 },
|
||||
expected_sequence_guid: { maxLength: 512 }, expected_component_id: { maxLength: 512 },
|
||||
},
|
||||
});
|
||||
expect(tools.has_transcript_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
properties: {
|
||||
project_item_id: { type: "string" },
|
||||
project_item_name: { type: "string" },
|
||||
},
|
||||
});
|
||||
expect(tools.export_aaf_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
required: ["output_file_path"],
|
||||
properties: {
|
||||
output_file_path: { type: "string" },
|
||||
operation_id: { type: "string" },
|
||||
options: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mixdown_video: { type: "boolean" },
|
||||
explode_to_mono: { type: "boolean" },
|
||||
embed_audio: { type: "boolean" },
|
||||
trim_sources: { type: "boolean" },
|
||||
render_audio_effects: { type: "boolean" },
|
||||
interleave_without_effects: { type: "boolean" },
|
||||
preserve_parent_folder: { type: "boolean" },
|
||||
sample_rate: { type: "integer", enum: [32000, 44100, 48000, 88200, 96000] },
|
||||
bits_per_sample: { type: "integer", enum: [16, 24, 32] },
|
||||
audio_file_format: { type: "string", enum: ["aiff", "wav"] },
|
||||
handle_frames: { type: "integer" },
|
||||
video_mixdown_preset_path: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("translates the public snake_case arguments to protocol arguments", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const { tools } = catalog(request);
|
||||
|
||||
await tools.rename_track_uxp.handler({
|
||||
track_type: "caption", track_index: 2, name: "Dialogue", operation_id: "track-1",
|
||||
});
|
||||
await tools.create_subclip_uxp.handler({
|
||||
project_item_id: "clip-17", name: "Sentence", start_seconds: 1.25, end_seconds: 4.75,
|
||||
hard_boundaries: true, take_video: false, take_audio: true, operation_id: "subclip-1",
|
||||
});
|
||||
await tools.list_markers_uxp.handler({
|
||||
scope: "project_item", project_item_id: "clip-17", filters: ["Comment", "Chapter"], include_web_links: true, include_color_values: true,
|
||||
});
|
||||
await tools.set_source_monitor_position_uxp.handler({ seconds: 12.5, operation_id: "source-1" });
|
||||
await tools.inspect_frame_alignment_uxp.handler({ action: "align", frame_rate: 24, seconds: 1.03 });
|
||||
await tools.calculate_tick_time_uxp.handler({ operation: "add", base_ticks: "1", operand_ticks: "2" });
|
||||
await tools.manage_sequence_preview_frame_uxp.handler({
|
||||
action: "update", sequence_id: "sequence-1", preview_width: 1920, preview_height: 1080,
|
||||
expected_snapshot: { project_guid: "project-1", sequence_id: "sequence-1", preview_width: 640, preview_height: 360 },
|
||||
confirm_set_preview_frame: true, operation_id: "preview-frame-1",
|
||||
});
|
||||
await tools.inspect_source_media_provenance_uxp.handler({
|
||||
project_item_id: "clip-17", include_media_file_path: true, include_originating_project_path: false,
|
||||
});
|
||||
await tools.inspect_source_proxy_uxp.handler({ project_item_id: "clip-17", include_proxy_path: true });
|
||||
await tools.create_empty_sequence_uxp.handler({
|
||||
name: "Empty Assembly", confirm_non_undoable: true, operation_id: "empty-1",
|
||||
});
|
||||
await tools.audit_object_masks_uxp.handler({
|
||||
expected_project_guid: "project-1", sequence_ids: ["sequence-2", "sequence-1"],
|
||||
});
|
||||
await tools.inspect_track_item_identity_uxp.handler({
|
||||
media_type: "video", track_index: 1, clip_index: 2, expected_sequence_guid: "sequence-1",
|
||||
});
|
||||
await tools.has_transcript_uxp.handler({ project_item_name: "Interview A" });
|
||||
await tools.export_aaf_uxp.handler({
|
||||
output_file_path: "/exports/turnover.aaf",
|
||||
options: {
|
||||
mixdown_video: true, explode_to_mono: true, embed_audio: false, trim_sources: true,
|
||||
render_audio_effects: true, interleave_without_effects: false, preserve_parent_folder: true,
|
||||
sample_rate: 48000, bits_per_sample: 24, audio_file_format: "wav", handle_frames: 12,
|
||||
video_mixdown_preset_path: "/presets/prores.epr",
|
||||
},
|
||||
operation_id: "aaf-1",
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "track.rename", {
|
||||
trackType: "caption", trackIndex: 2, name: "Dialogue", operationId: "track-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "subclip.create", {
|
||||
projectItemId: "clip-17", name: "Sentence", startSeconds: 1.25, endSeconds: 4.75,
|
||||
hasHardBoundaries: true, takeVideo: false, takeAudio: true, operationId: "subclip-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(3, "marker.list", {
|
||||
scope: "projectItem", projectItemId: "clip-17", filters: ["Comment", "Chapter"], includeWebLinks: true, includeColorValues: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(4, "sourceMonitor.position.set", {
|
||||
seconds: 12.5, operationId: "source-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(5, "time.frameAlignment.inspect", {
|
||||
action: "align", frameRate: 24, seconds: 1.03,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(6, "time.tickArithmetic.inspect", {
|
||||
operation: "add", baseTicks: "1", operandTicks: "2",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(7, "sequence.previewFrame.update", {
|
||||
sequenceId: "sequence-1", previewWidth: 1920, previewHeight: 1080, confirmSetPreviewFrame: true, operationId: "preview-frame-1",
|
||||
expectedSnapshot: { projectGuid: "project-1", sequenceId: "sequence-1", previewWidth: 640, previewHeight: 360 },
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(8, "source.provenance.inspect", {
|
||||
projectItemId: "clip-17", includeMediaFilePath: true, includeOriginatingProjectPath: false,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(9, "source.proxy.inspect", {
|
||||
projectItemId: "clip-17", includeProxyPath: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(10, "sequences.createEmpty", {
|
||||
name: "Empty Assembly", confirmNonUndoable: true, operationId: "empty-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(11, "objectMask.audit", {
|
||||
expectedProjectGuid: "project-1", sequenceIds: ["sequence-2", "sequence-1"],
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(12, "trackItem.identity.inspect", {
|
||||
mediaType: "video", trackIndex: 1, clipIndex: 2, expectedSequenceGuid: "sequence-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(13, "transcript.has", { projectItemName: "Interview A" });
|
||||
expect(request).toHaveBeenNthCalledWith(14, "interchange.aaf.export", {
|
||||
outputFilePath: "/exports/turnover.aaf",
|
||||
options: {
|
||||
mixdownVideo: true, explodeToMono: true, embedAudio: false, trimSources: true,
|
||||
renderAudioEffects: true, interleaveWithoutEffects: false, preserveParentFolder: true,
|
||||
sampleRate: 48000, bitsPerSample: 24, audioFileFormat: "wav", handleFrames: 12,
|
||||
videoMixdownPresetPath: "/presets/prores.epr",
|
||||
},
|
||||
operationId: "aaf-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
Executable
+196
@@ -0,0 +1,196 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockedExecFile, mockedExecFileAsync } = vi.hoisted(() => {
|
||||
const mockedExecFileAsync = vi.fn();
|
||||
const mockedExecFile = vi.fn();
|
||||
mockedExecFile[Symbol.for("nodejs.util.promisify.custom")] = mockedExecFileAsync;
|
||||
return { mockedExecFile, mockedExecFileAsync };
|
||||
});
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({ execFile: mockedExecFile }));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getAudioTools, parseEbur128Summary } from "../../src/tools/audio.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const tools = getAudioTools({ tempDir: "/tmp/test", timeoutMs: 1000 });
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function enqueueFfmpegResult(stderr = "") {
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr });
|
||||
}
|
||||
|
||||
function enqueueFfmpegFailure(error: Error) {
|
||||
mockedExecFileAsync.mockRejectedValueOnce(error);
|
||||
}
|
||||
|
||||
function createMediaFixture() {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-loudness-"));
|
||||
const mediaPath = join(directory, "dialogue.wav");
|
||||
writeFileSync(mediaPath, "audio-fixture");
|
||||
return mediaPath;
|
||||
}
|
||||
|
||||
const measurableSummary = `
|
||||
Summary:
|
||||
Integrated loudness:
|
||||
I: -16.2 LUFS
|
||||
Loudness range:
|
||||
LRA: 4.1 LU
|
||||
True peak:
|
||||
Peak: -1.3 dBFS
|
||||
`;
|
||||
|
||||
describe("parseEbur128Summary", () => {
|
||||
it("extracts the final integrated loudness, range, and true peak", () => {
|
||||
const output = `
|
||||
Summary:
|
||||
|
||||
Integrated loudness:
|
||||
I: -16.2 LUFS
|
||||
Threshold: -26.2 LUFS
|
||||
|
||||
Loudness range:
|
||||
LRA: 4.1 LU
|
||||
|
||||
True peak:
|
||||
Peak: -1.3 dBFS
|
||||
`;
|
||||
expect(parseEbur128Summary(output)).toEqual({
|
||||
integratedLufs: -16.2,
|
||||
loudnessRangeLu: 4.1,
|
||||
truePeakDbfs: -1.3,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports digital silence as unmeasurable instead of fabricating a number", () => {
|
||||
const output = `Integrated loudness:\n I: -inf LUFS\nLoudness range:\n LRA: 0.0 LU\nTrue peak:\n Peak: -inf dBFS`;
|
||||
expect(parseEbur128Summary(output)).toEqual({
|
||||
integratedLufs: null,
|
||||
loudnessRangeLu: 0,
|
||||
truePeakDbfs: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyze_loudness argument boundary", () => {
|
||||
it("rejects invalid targets before contacting Premiere or FFmpeg", async () => {
|
||||
await expect(tools.analyze_loudness.handler({ media_path: "missing.wav", target_lufs: 4 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("target_lufs") });
|
||||
await expect(tools.analyze_loudness.handler({ media_path: "missing.wav", tolerance_lu: -1 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("tolerance_lu") });
|
||||
await expect(tools.analyze_loudness.handler({ media_path: "missing.wav", max_true_peak_dbfs: 1 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("max_true_peak_dbfs") });
|
||||
await expect(tools.analyze_loudness.handler({}))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("media_path") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves project-item paths through one read-only bridge call", async () => {
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: false, error: "Project item not found" });
|
||||
const result = await tools.analyze_loudness.handler({ project_item_id: "missing" });
|
||||
expect(mockedSendCommand).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSendCommand.mock.calls[0][0]).toContain("getMediaPath");
|
||||
expect(result).toMatchObject({ success: false, error: "Project item not found" });
|
||||
});
|
||||
|
||||
it("returns a local EBU R128 measurement and evaluates an optional delivery target", async () => {
|
||||
const mediaPath = createMediaFixture();
|
||||
enqueueFfmpegResult();
|
||||
enqueueFfmpegResult(measurableSummary);
|
||||
|
||||
await expect(tools.analyze_loudness.handler({
|
||||
media_path: mediaPath,
|
||||
target_lufs: -16,
|
||||
tolerance_lu: 0.5,
|
||||
max_true_peak_dbfs: -1,
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
mediaPath,
|
||||
integratedLufs: -16.2,
|
||||
loudnessRangeLu: 4.1,
|
||||
truePeakDbfs: -1.3,
|
||||
target: {
|
||||
lufs: -16,
|
||||
toleranceLu: 0.5,
|
||||
deltaLu: -0.2,
|
||||
loudnessPass: true,
|
||||
truePeakPass: true,
|
||||
passes: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockedExecFileAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("resolves a project item to a local file before measuring it", async () => {
|
||||
const mediaPath = createMediaFixture();
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { mediaPath, name: "Dialogue" } });
|
||||
enqueueFfmpegResult();
|
||||
enqueueFfmpegResult(measurableSummary);
|
||||
|
||||
await expect(tools.analyze_loudness.handler({ project_item_id: "dialogue-item" }))
|
||||
.resolves.toMatchObject({ success: true, data: { mediaPath, projectItemName: "Dialogue", target: null } });
|
||||
expect(mockedSendCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports a failed target and true-peak check without changing the media", async () => {
|
||||
const mediaPath = createMediaFixture();
|
||||
enqueueFfmpegResult();
|
||||
enqueueFfmpegResult(measurableSummary);
|
||||
|
||||
await expect(tools.analyze_loudness.handler({
|
||||
media_path: mediaPath,
|
||||
target_lufs: -14,
|
||||
tolerance_lu: 0.1,
|
||||
max_true_peak_dbfs: -2,
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
target: {
|
||||
loudnessPass: false,
|
||||
truePeakPass: false,
|
||||
passes: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports unavailable analysis prerequisites, timeouts, and digital silence without making up a metric", async () => {
|
||||
const mediaPath = createMediaFixture();
|
||||
enqueueFfmpegFailure(new Error("not found"));
|
||||
await expect(tools.analyze_loudness.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("not found on PATH") });
|
||||
|
||||
enqueueFfmpegResult();
|
||||
enqueueFfmpegFailure(Object.assign(new Error("timed out"), { killed: true }));
|
||||
await expect(tools.analyze_loudness.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("timed out") });
|
||||
|
||||
enqueueFfmpegResult();
|
||||
enqueueFfmpegResult("Integrated loudness:\n I: -inf LUFS");
|
||||
await expect(tools.analyze_loudness.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("did not return measurable") });
|
||||
});
|
||||
|
||||
it("does not invoke FFmpeg for an unavailable file and preserves an analysis error detail", async () => {
|
||||
await expect(tools.analyze_loudness.handler({ media_path: "missing-dialogue.wav" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("Media file not found") });
|
||||
expect(mockedExecFileAsync).not.toHaveBeenCalled();
|
||||
|
||||
const mediaPath = createMediaFixture();
|
||||
enqueueFfmpegResult();
|
||||
enqueueFfmpegFailure(Object.assign(new Error("decode failed"), { stderr: "invalid stream" }));
|
||||
await expect(tools.analyze_loudness.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("invalid stream") });
|
||||
});
|
||||
});
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockedExecFile, mockedExecFileAsync } = vi.hoisted(() => {
|
||||
const mockedExecFileAsync = vi.fn();
|
||||
const mockedExecFile = vi.fn();
|
||||
mockedExecFile[Symbol.for("nodejs.util.promisify.custom")] = mockedExecFileAsync;
|
||||
return { mockedExecFile, mockedExecFileAsync };
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({ execFile: mockedExecFile }));
|
||||
|
||||
import { getExportTools, parseVideoQcOutput } from "../../src/tools/export.js";
|
||||
|
||||
describe("parseVideoQcOutput", () => {
|
||||
it("returns exact black and freeze intervals", () => {
|
||||
const stderr = [
|
||||
"[blackdetect] black_start:0 black_end:1.25 black_duration:1.25",
|
||||
"[freezedetect] lavfi.freezedetect.freeze_start: 2.5",
|
||||
"[freezedetect] lavfi.freezedetect.freeze_duration: 1.5",
|
||||
"[freezedetect] lavfi.freezedetect.freeze_end: 4",
|
||||
].join("\n");
|
||||
expect(parseVideoQcOutput(stderr)).toEqual({
|
||||
blackFrames: [{ start: 0, end: 1.25, duration: 1.25 }],
|
||||
freezes: [{ start: 2.5, end: 4, duration: 1.5 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyze_video_qc boundary", () => {
|
||||
const tool = getExportTools({ tempDir: "/tmp/test" }).analyze_video_qc;
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("rejects invalid thresholds and missing files before FFmpeg", async () => {
|
||||
await expect(tool.handler({ media_path: "missing.mp4", minimum_black_seconds: 0 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("minimum_black_seconds") });
|
||||
await expect(tool.handler({ media_path: "missing.mp4" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("not found") });
|
||||
await expect(tool.handler({ media_path: "missing.mp4", minimum_freeze_seconds: 0 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("minimum_freeze_seconds") });
|
||||
});
|
||||
|
||||
it("reports decoded findings without modifying the delivery file", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-video-qc-"));
|
||||
const mediaPath = join(directory, "delivery.mp4");
|
||||
writeFileSync(mediaPath, "video-fixture");
|
||||
mockedExecFileAsync.mockResolvedValueOnce({
|
||||
stdout: "",
|
||||
stderr: [
|
||||
"black_start:0 black_end:1.25 black_duration:1.25",
|
||||
"lavfi.freezedetect.freeze_start: 2.5",
|
||||
"lavfi.freezedetect.freeze_end: 4",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
await expect(tool.handler({ media_path: mediaPath, minimum_black_seconds: 1, minimum_freeze_seconds: 1 }))
|
||||
.resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
mediaPath,
|
||||
passes: false,
|
||||
blackFrames: [{ start: 0, end: 1.25, duration: 1.25 }],
|
||||
freezes: [{ start: 2.5, end: 4, duration: 1.5 }],
|
||||
},
|
||||
});
|
||||
expect(mockedExecFileAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves a non-timeout FFmpeg failure detail", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-video-qc-error-"));
|
||||
const mediaPath = join(directory, "delivery.mp4");
|
||||
writeFileSync(mediaPath, "video-fixture");
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("decode failed"), { stderr: "invalid stream" }));
|
||||
|
||||
await expect(tool.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("invalid stream") });
|
||||
});
|
||||
});
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const { mockExecFile } = vi.hoisted(() => ({
|
||||
mockExecFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({ execFile: mockExecFile }));
|
||||
vi.mock("node:util", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("node:util")>();
|
||||
return {
|
||||
...original,
|
||||
promisify: () => (...args: any[]) => new Promise((resolve, reject) => {
|
||||
mockExecFile(...args, (error: Error | null, stdout: string, stderr: string) => {
|
||||
if (error) reject(error);
|
||||
else resolve({ stdout, stderr });
|
||||
});
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getAudioTools } from "../../src/tools/audio.js";
|
||||
import { confirmationToken, getEditPlanTools, validateEditPlan } from "../../src/tools/edit-plans.js";
|
||||
import {
|
||||
getExportTools,
|
||||
inspectExportPresetFile,
|
||||
MAX_CAPTURE_FRAME_BYTES,
|
||||
verifyDeliveryFile,
|
||||
} from "../../src/tools/export.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const temporaryDirectories: string[] = [];
|
||||
const bridgeOptions = { tempDir: "/tmp/coverage-tools", timeoutMs: 500 };
|
||||
|
||||
function temporaryPath(name: string, contents = "test content"): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-tool-coverage-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const path = join(directory, name);
|
||||
writeFileSync(path, contents);
|
||||
return path;
|
||||
}
|
||||
|
||||
function temporaryDirectory(): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-tool-coverage-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function execSucceeds(stderr = ""): void {
|
||||
mockExecFile.mockImplementationOnce((...args: any[]) => {
|
||||
args.at(-1)(null, "", stderr);
|
||||
});
|
||||
}
|
||||
|
||||
function execFails(error: Error): void {
|
||||
mockExecFile.mockImplementationOnce((...args: any[]) => {
|
||||
args.at(-1)(error, "", "");
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedSendCommand.mockResolvedValue({ success: true, data: { host: "ok" } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("audio tool analysis coverage", () => {
|
||||
it("resolves a project item, runs ffmpeg, and returns silent and retained ranges", async () => {
|
||||
const mediaPath = temporaryPath("speech.mp4");
|
||||
mockedSendCommand.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: { mediaPath, name: "Interview" },
|
||||
});
|
||||
execSucceeds();
|
||||
execSucceeds([
|
||||
"Duration: 00:00:12.00, start: 0.000000",
|
||||
"[silencedetect] silence_start: 2",
|
||||
"[silencedetect] silence_end: 4.5 | silence_duration: 2.5",
|
||||
"[silencedetect] silence_start: 10",
|
||||
].join("\n"));
|
||||
|
||||
const result = await getAudioTools(bridgeOptions).detect_silence.handler({
|
||||
project_item_id: "interview-item",
|
||||
noise_threshold_db: -42,
|
||||
min_duration_seconds: 0.75,
|
||||
});
|
||||
|
||||
expect(mockedSendCommand).toHaveBeenCalledOnce();
|
||||
expect(mockedSendCommand.mock.calls[0][0]).toContain("getMediaPath");
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(2);
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
mediaPath,
|
||||
projectItemName: "Interview",
|
||||
noiseThresholdDb: -42,
|
||||
minDurationSeconds: 0.75,
|
||||
totalDurationSeconds: 12,
|
||||
silenceIntervals: [
|
||||
{ start: 2, end: 4.5, duration: 2.5 },
|
||||
{ start: 10, end: 12, duration: 2 },
|
||||
],
|
||||
segments: [
|
||||
{ start: 0, end: 2, duration: 2 },
|
||||
{ start: 4.5, end: 10, duration: 5.5 },
|
||||
],
|
||||
silentSeconds: 4.5,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports missing ffmpeg, timeouts, and useful analyser failure details", async () => {
|
||||
const mediaPath = temporaryPath("speech.mp4");
|
||||
const tools = getAudioTools(bridgeOptions);
|
||||
|
||||
execFails(new Error("ENOENT"));
|
||||
await expect(tools.detect_silence.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("ffmpeg was not found") });
|
||||
|
||||
execSucceeds();
|
||||
execFails(Object.assign(new Error("slow"), { killed: true }));
|
||||
await expect(tools.detect_silence.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("timed out after 300s") });
|
||||
|
||||
execSucceeds();
|
||||
execFails(Object.assign(new Error("failed"), {
|
||||
stderr: "first line\n\nsecond line\nthird line\nfourth line",
|
||||
}));
|
||||
await expect(tools.detect_silence.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("second line third line fourth line") });
|
||||
});
|
||||
|
||||
it("renders audio adjustment, keyframe, and unmute options into the bridge script", async () => {
|
||||
const tools = getAudioTools(bridgeOptions);
|
||||
|
||||
await tools.adjust_audio_levels.handler({ node_id: "clip\"id", level_db: -6 });
|
||||
// Premiere's Volume > Level uses +15 dB as its normalized maximum.
|
||||
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("0.08912509381337455");
|
||||
|
||||
await tools.add_audio_keyframes.handler({
|
||||
node_id: "clip",
|
||||
keyframes: [{ time_seconds: 1.25, level_db: -Infinity }],
|
||||
});
|
||||
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("1e-7");
|
||||
|
||||
await tools.mute_track.handler({ track_index: 3, muted: false });
|
||||
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("track.setMute(0)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit plan validation and apply coverage", () => {
|
||||
it.each([
|
||||
[null, "plan must be an object"],
|
||||
[{}, "plan.operations must contain at least one operation"],
|
||||
[{ operations: Array.from({ length: 101 }, () => ({ type: "remove_clip", node_id: "clip" })) }, "limited to 100"],
|
||||
[{ operations: [null] }, "operation 0 must be an object"],
|
||||
[{ operations: [{ type: "insert_clip", start_seconds: 0 }] }, "requires item_id"],
|
||||
[{ operations: [{ type: "insert_clip", item_id: "item", start_seconds: Number.NaN }] }, "non-negative number"],
|
||||
[{ operations: [{ type: "insert_clip", item_id: "item", start_seconds: 0, video_track_index: -1 }] }, "video_track_index"],
|
||||
[{ operations: [{ type: "insert_clip", item_id: "item", start_seconds: 0, audio_track_index: 1.5 }] }, "audio_track_index"],
|
||||
[{ operations: [{ type: "remove_clip", node_id: "" }] }, "requires node_id"],
|
||||
])("rejects %j", (value, message) => {
|
||||
expect(() => validateEditPlan(value)).toThrow(message);
|
||||
});
|
||||
|
||||
it("previews destructive and non-destructive work with stable confirmation", async () => {
|
||||
const plan = {
|
||||
operations: [
|
||||
{ type: "insert_clip" as const, item_id: "source", start_seconds: 2 },
|
||||
{ type: "remove_clip" as const, node_id: "old-clip", ripple: true },
|
||||
],
|
||||
};
|
||||
const tools = getEditPlanTools(bridgeOptions, {
|
||||
capabilities: { capabilities: new Set(["inspect"]), source: "explicit" },
|
||||
operationIdFactory: () => "preview-mixed",
|
||||
});
|
||||
|
||||
const result = await tools.preview_edit_plan.handler({ plan });
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
operationId: "preview-mixed",
|
||||
confirmationToken: confirmationToken(plan),
|
||||
changes: [
|
||||
{ type: "insert_clip", target: "source", destructive: false },
|
||||
{ type: "remove_clip", target: "old-clip", destructive: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(confirmationToken({ ...plan, sequence_id: "other" })).not.toBe(confirmationToken(plan));
|
||||
});
|
||||
|
||||
it("builds a remove plan against a named sequence and decorates bridge failures", async () => {
|
||||
const plan = {
|
||||
sequence_id: "seq\"one",
|
||||
operations: [
|
||||
{ type: "remove_clip" as const, node_id: "old-clip", ripple: true },
|
||||
{ type: "insert_clip" as const, item_id: "new-item", start_seconds: 3, video_track_index: 2, audio_track_index: 4 },
|
||||
],
|
||||
};
|
||||
const auditSink = vi.fn();
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: false, error: "Premiere rejected the edit" });
|
||||
const tools = getEditPlanTools(bridgeOptions, {
|
||||
capabilities: { capabilities: new Set(["inspect", "edit"]), source: "explicit" },
|
||||
auditSink,
|
||||
operationIdFactory: () => "apply-remove",
|
||||
});
|
||||
|
||||
const result = await tools.apply_edit_plan.handler({
|
||||
plan,
|
||||
confirmation_token: confirmationToken(plan),
|
||||
});
|
||||
const script = String(mockedSendCommand.mock.calls[0][0]);
|
||||
|
||||
expect(script).toContain('__findSequence("seq\\"one")');
|
||||
expect(script).toContain("function __planFindClip");
|
||||
expect(script).toContain("found0.remove(true, true)");
|
||||
expect(result).toEqual({ success: false, error: "Premiere rejected the edit (operation apply-remove)" });
|
||||
expect(auditSink.mock.calls.map(([event]) => event.outcome)).toEqual(["started", "failed"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("export verification and host-result coverage", () => {
|
||||
it("validates a SHA-512 delivery and rejects malformed size constraints", async () => {
|
||||
const delivery = temporaryPath("delivery.mov", "delivery data");
|
||||
const checksum = createHash("sha512").update("delivery data").digest("hex");
|
||||
|
||||
await expect(verifyDeliveryFile(delivery, { minimumSizeBytes: -1 })).rejects.toThrow("minimum_size_bytes");
|
||||
await expect(verifyDeliveryFile(delivery, { expectedSizeBytes: 1.5 })).rejects.toThrow("expected_size_bytes");
|
||||
await expect(verifyDeliveryFile(temporaryDirectory())).rejects.toThrow("not a regular file");
|
||||
|
||||
await expect(verifyDeliveryFile(delivery, {
|
||||
algorithm: "sha512",
|
||||
expectedChecksum: ` ${checksum.toUpperCase()} `,
|
||||
expectedSizeBytes: 13,
|
||||
minimumSizeBytes: 0,
|
||||
})).resolves.toMatchObject({
|
||||
checksum: { algorithm: "sha512", value: checksum },
|
||||
matchesExpectedChecksum: true,
|
||||
matchesExpectedSize: true,
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces local preset and delivery errors, then merges a host-validated preset response", async () => {
|
||||
const tools = getExportTools(bridgeOptions);
|
||||
const invalid = temporaryPath("preset.xml", "preset");
|
||||
await expect(tools.validate_export_preset.handler({ preset_path: invalid }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining(".epr") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
|
||||
const preset = temporaryPath("preset.epr", "preset");
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: false, error: "No active sequence" });
|
||||
await expect(tools.validate_export_preset.handler({ preset_path: preset }))
|
||||
.resolves.toEqual({ success: false, error: "No active sequence" });
|
||||
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { outputExtension: ".mov" } });
|
||||
await expect(tools.validate_export_preset.handler({ preset_path: preset }))
|
||||
.resolves.toMatchObject({ success: true, data: { path: preset, outputExtension: ".mov", hostValidated: true } });
|
||||
expect(inspectExportPresetFile(preset).sizeBytes).toBe(6);
|
||||
|
||||
await expect(tools.verify_delivery_file.handler({ output_path: join(tmpdir(), "missing-delivery.mov") }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("does not exist") });
|
||||
});
|
||||
|
||||
it("reads a host-exported frame, reports unreadable output, and covers proxy script choices", async () => {
|
||||
const tools = getExportTools(bridgeOptions);
|
||||
const frame = temporaryPath("frame.png", "png bytes");
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { outputPath: frame } });
|
||||
await expect(tools.capture_frame.handler({ time_seconds: 3 })).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { captured: true, mimeType: "image/png", base64: Buffer.from("png bytes").toString("base64") },
|
||||
});
|
||||
expect(existsSync(frame)).toBe(false);
|
||||
|
||||
const oversizedFrame = temporaryPath("oversized-frame.png", "x".repeat(MAX_CAPTURE_FRAME_BYTES + 1));
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { outputPath: oversizedFrame } });
|
||||
await expect(tools.capture_frame.handler({})).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("inline response limit"),
|
||||
});
|
||||
expect(existsSync(oversizedFrame)).toBe(false);
|
||||
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { outputPath: temporaryDirectory() } });
|
||||
await expect(tools.capture_frame.handler({})).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("Failed to read captured frame"),
|
||||
});
|
||||
|
||||
await tools.manage_proxies.handler({ item_id: "item", action: "create" });
|
||||
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("output_path is required");
|
||||
await tools.manage_proxies.handler({ item_id: "item", action: "create", output_path: "proxy.mov" });
|
||||
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("__findProxyPreset()");
|
||||
await tools.manage_proxies.handler({ item_id: "item", action: "create", output_path: "proxy.mov", preset_path: "proxy.epr" });
|
||||
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain('var presetPath = "proxy.epr"');
|
||||
await tools.manage_proxies.handler({ item_id: "item", action: "attach" });
|
||||
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("proxy_path is required for attach action");
|
||||
});
|
||||
});
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getAvSettingsTools } from "../../src/tools/av-settings.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const tools = getAvSettingsTools({ tempDir: "/tmp/test", timeoutMs: 1000 });
|
||||
|
||||
describe("AV feature support", () => {
|
||||
it("identifies documented support and UI-only boundaries without a host call", async () => {
|
||||
const result = await tools.get_av_feature_support.handler();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.supported.projectItemAudioChannelMapping.status).toBe("supported_with_limitations");
|
||||
expect(result.data.blocked.enhanceSpeech).toMatchObject({
|
||||
status: "ui_only",
|
||||
reason: expect.stringContaining("No documented"),
|
||||
});
|
||||
expect(result.data.blocked.audioTrackMixerRoutingAndSubmixes.status).toBe("unsupported");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AV inspection scripts", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("inspects documented sequence audio and color/render fields", async () => {
|
||||
await tools.inspect_sequence_av_settings.handler();
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("audioChannelCount");
|
||||
expect(script).toContain("autoToneMapEnabled");
|
||||
expect(script).toContain("compositeLinearColor");
|
||||
expect(script).toContain("sequenceWorkingColorSpace");
|
||||
});
|
||||
|
||||
it("inspects project item color spaces, LUTs, override options, and channel shape", async () => {
|
||||
await tools.inspect_project_item_av_metadata.handler({ item_id: 'clip "A"' });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('clip \\"A\\"');
|
||||
expect(script).toContain("getColorSpace");
|
||||
expect(script).toContain("getOriginalColorSpace");
|
||||
expect(script).toContain("getOverrideColorSpaceList");
|
||||
expect(script).toContain("getAudioChannelMapping");
|
||||
});
|
||||
});
|
||||
|
||||
describe("audio channel mapping", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("rejects non-integer and negative indexes before bridge execution", async () => {
|
||||
await expect(tools.set_project_item_audio_channel_mapping.handler({
|
||||
item_id: "clip", channel_index: -1, source_channel_index: 0,
|
||||
})).resolves.toEqual({ success: false, error: "channel_index must be a non-negative integer" });
|
||||
await expect(tools.set_project_item_audio_channel_mapping.handler({
|
||||
item_id: "clip", channel_index: 0, source_channel_index: 1.5,
|
||||
})).resolves.toEqual({ success: false, error: "source_channel_index must be a non-negative integer" });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires Premiere to accept the documented mapping call", async () => {
|
||||
await tools.set_project_item_audio_channel_mapping.handler({
|
||||
item_id: "clip", channel_index: 1, source_channel_index: 0,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("mapping.setMappingForChannel(1, 0)");
|
||||
expect(script).toContain("accepted !== true");
|
||||
expect(script).toContain('verification: "api-return"');
|
||||
});
|
||||
});
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({ sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }) }));
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getExportTools } from "../../src/tools/export.js";
|
||||
|
||||
const mocked = vi.mocked(sendCommand);
|
||||
const tool = getExportTools({ tempDir: "/tmp/test" }).export_sequence_clip_review_frames;
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("export_sequence_clip_review_frames", () => {
|
||||
it("samples up to twenty clip midpoints in one bridge round trip", async () => {
|
||||
await tool.handler({ output_dir: "/tmp/review" });
|
||||
expect(mocked).toHaveBeenCalledTimes(1);
|
||||
const script = mocked.mock.calls[0][0];
|
||||
expect(script).toContain("Math.min(track.clips.numItems, 20)");
|
||||
expect(script).toContain("clip.start.seconds + ((clip.end.seconds - clip.start.seconds) / 2)");
|
||||
expect(script).toContain("__exportStillFrame(");
|
||||
});
|
||||
|
||||
it("rejects unsafe bounds before contacting Premiere", async () => {
|
||||
await expect(tool.handler({ output_dir: "/tmp", track_index: -1 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tool.handler({ output_dir: "/tmp", limit: 51 })).resolves.toMatchObject({ success: false });
|
||||
expect(mocked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Executable
+235
@@ -0,0 +1,235 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BridgeOptions } from "../../src/bridge/file-bridge.js";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getCompetitorGapTools } from "../../src/tools/competitor-gaps.js";
|
||||
|
||||
const bridgeOptions: BridgeOptions = { tempDir: "/tmp/competitor-gap-tests", timeoutMs: 5000 };
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
|
||||
describe("competitor-gap default-profile tools", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("exposes the requested competitor-parity names", () => {
|
||||
const tools = getCompetitorGapTools(bridgeOptions);
|
||||
expect(Object.keys(tools)).toEqual(expect.arrayContaining([
|
||||
"import_edl",
|
||||
"add_to_timeline_batch",
|
||||
"crop_clip",
|
||||
"setup_ducking",
|
||||
"validate_project_for_export",
|
||||
"read_sequence_captions",
|
||||
"set_clip_properties_batch",
|
||||
"detect_scene_edits",
|
||||
]));
|
||||
});
|
||||
|
||||
it("does not attempt an unattended EDL import", async () => {
|
||||
const result = await getCompetitorGapTools(bridgeOptions).import_edl.handler({ file_path: "D:/cuts/spot.edl" });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("No mutation was attempted");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects ambiguous batch insert positions before writing", async () => {
|
||||
const result = await getCompetitorGapTools(bridgeOptions).add_to_timeline_batch.handler({
|
||||
clips: [
|
||||
{ item_id: "a", track_index: 0, start_seconds: 4 },
|
||||
{ item_id: "b", track_index: 0, start_seconds: 4 },
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("shares video track");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preflights and reads back batch insertions", async () => {
|
||||
await getCompetitorGapTools(bridgeOptions).add_to_timeline_batch.handler({
|
||||
clips: [
|
||||
{ item_id: "later", track_index: 1, start_seconds: 10, audio_track_index: 0 },
|
||||
{ item_id: "first", track_index: 0, start_seconds: 2, audio_track_index: 0 },
|
||||
],
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("Preflight every mutable dependency");
|
||||
expect(script).toContain("seq.insertClip");
|
||||
expect(script).toContain("actualStart");
|
||||
expect(script.indexOf('itemId: "first"')).toBeLessThan(script.indexOf('itemId: "later"'));
|
||||
});
|
||||
|
||||
it("requires a meaningful crop change before adding an effect", async () => {
|
||||
const result = await getCompetitorGapTools(bridgeOptions).crop_clip.handler({ node_id: "video-1" });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("requires at least one");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the QE crop catalog only when needed and verifies requested values", async () => {
|
||||
await getCompetitorGapTools(bridgeOptions).crop_clip.handler({ node_id: "video-1", left: 12, zoom: true });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('__getQeEffectCatalog("video")');
|
||||
expect(script).toContain("addVideoEffect");
|
||||
expect(script).toContain('name: "Left"');
|
||||
expect(script).toContain("getValue");
|
||||
});
|
||||
|
||||
it("rejects overlapping ducking windows before writing automation", async () => {
|
||||
const result = await getCompetitorGapTools(bridgeOptions).setup_ducking.handler({
|
||||
node_id: "music-1",
|
||||
ducking_windows: [
|
||||
{ start_seconds: 1, end_seconds: 3, ducked_db: -20 },
|
||||
{ start_seconds: 2, end_seconds: 4, ducked_db: -24 },
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("must not overlap");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("generates verified relative audio keyframes for non-overlapping windows", async () => {
|
||||
await getCompetitorGapTools(bridgeOptions).setup_ducking.handler({
|
||||
node_id: "music-1",
|
||||
base_db: -4,
|
||||
fade_seconds: 0.25,
|
||||
ducking_windows: [{ start_seconds: 2, end_seconds: 4, ducked_db: -28 }],
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("setup_ducking only supports audio");
|
||||
expect(script).toContain("setTimeVarying");
|
||||
expect(script).toContain("getValueAtTime");
|
||||
expect(script).toContain("duckingWindowCount");
|
||||
});
|
||||
|
||||
it("generates a non-mutating export readiness audit", async () => {
|
||||
await getCompetitorGapTools(bridgeOptions).validate_project_for_export.handler({
|
||||
sequence_id: "edit-1",
|
||||
output_path: "D:/exports/final.mp4",
|
||||
preset_path: "D:/presets/h264.epr",
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("OFFLINE_MEDIA");
|
||||
expect(script).toContain("TIMELINE_GAPS");
|
||||
expect(script).toContain("readyForExport");
|
||||
expect(script).not.toContain("encodeSequence");
|
||||
});
|
||||
|
||||
it("makes caption-read limitations explicit instead of treating no tracks as no captions", async () => {
|
||||
await getCompetitorGapTools(bridgeOptions).read_sequence_captions.handler({});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("captionReadSupported");
|
||||
expect(script).toContain("do not prove this sequence has no captions");
|
||||
});
|
||||
|
||||
it("fails property batch speed requests before touching any clip", async () => {
|
||||
const result = await getCompetitorGapTools(bridgeOptions).set_clip_properties_batch.handler({
|
||||
items: [{ node_id: "clip-1", speed: 150 }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("speed");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preflights, verifies, and restores batch clip properties on mismatch", async () => {
|
||||
await getCompetitorGapTools(bridgeOptions).set_clip_properties_batch.handler({
|
||||
items: [{ node_id: "clip-1", opacity: 80, scale: 110, position_x: 400, rotation: 12 }],
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("No batch mutation was attempted");
|
||||
expect(script).toContain("restoreAll");
|
||||
expect(script).toContain("No partial batch is reported as verified");
|
||||
});
|
||||
|
||||
it("withholds unsafe CEP scene detection without an authenticated UXP bridge", async () => {
|
||||
const result = await getCompetitorGapTools(bridgeOptions).detect_scene_edits.handler({
|
||||
mode: "create_markers",
|
||||
confirm_non_undoable: true,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("authenticated Premiere UXP bridge");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes confirmed scene detection through UXP with a stable mode mapping", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ detected: true });
|
||||
const bridge = { request } as any;
|
||||
const result = await getCompetitorGapTools(bridgeOptions, bridge).detect_scene_edits.handler({
|
||||
mode: "create_subclips",
|
||||
confirm_non_undoable: true,
|
||||
operation_id: "scene-42",
|
||||
});
|
||||
expect(result).toMatchObject({ success: true, data: { backend: "uxp" } });
|
||||
expect(request).toHaveBeenCalledWith("sceneEdit.detect", { mode: "createSubclips", operationId: "scene-42" });
|
||||
});
|
||||
|
||||
it("rejects malformed or unsafe inputs before generating a CEP command", async () => {
|
||||
const tools = getCompetitorGapTools(bridgeOptions);
|
||||
|
||||
await expect(tools.add_to_timeline_batch.handler({ clips: [] })).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("clips must contain"),
|
||||
});
|
||||
await expect(tools.add_to_timeline_batch.handler({
|
||||
clips: Array.from({ length: 33 }, (_, index) => ({ item_id: `clip-${index}` })),
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("between 1 and 32") });
|
||||
await expect(tools.add_to_timeline_batch.handler({ clips: [{ item_id: "" }] })).resolves
|
||||
.toMatchObject({ success: false, error: expect.stringContaining("item_id") });
|
||||
await expect(tools.add_to_timeline_batch.handler({
|
||||
clips: [{ item_id: "clip", track_index: -1 }],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("non-negative integer") });
|
||||
await expect(tools.crop_clip.handler({ node_id: "clip", right: 101 })).resolves.toMatchObject({ success: false, error: expect.stringContaining("percentages") });
|
||||
await expect(tools.setup_ducking.handler({
|
||||
node_id: "music", fade_seconds: 0, ducking_windows: [],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("positive fade_seconds") });
|
||||
await expect(tools.setup_ducking.handler({
|
||||
node_id: "music", ducking_windows: Array.from({ length: 33 }, () => ({ start_seconds: 0, end_seconds: 1, ducked_db: -12 })),
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("0–32 ducking windows") });
|
||||
await expect(tools.setup_ducking.handler({
|
||||
node_id: "music", ducking_windows: [{ start_seconds: 3, end_seconds: 3, ducked_db: -12 }],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("greater than start_seconds") });
|
||||
await expect(tools.set_clip_properties_batch.handler({ items: [] })).resolves.toMatchObject({ success: false, error: expect.stringContaining("between 1 and 16") });
|
||||
await expect(tools.set_clip_properties_batch.handler({ items: [{ node_id: "" }] })).resolves
|
||||
.toMatchObject({ success: false, error: expect.stringContaining("node_id") });
|
||||
await expect(tools.set_clip_properties_batch.handler({
|
||||
items: [{ node_id: "clip" }, { node_id: "clip", opacity: 80 }],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("must specify at least one") });
|
||||
await expect(tools.set_clip_properties_batch.handler({
|
||||
items: [{ node_id: "clip", opacity: -1 }],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("invalid property") });
|
||||
await expect(tools.set_clip_properties_batch.handler({
|
||||
items: [{ node_id: "clip", opacity: 80 }, { node_id: "clip", opacity: 60 }],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("duplicate node_id") });
|
||||
await expect(tools.detect_scene_edits.handler({ mode: "create_markers", confirm_non_undoable: false })).resolves
|
||||
.toMatchObject({ success: false, error: expect.stringContaining("confirm_non_undoable") });
|
||||
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("covers optional export, caption, and UXP-scene request variants", async () => {
|
||||
const tools = getCompetitorGapTools(bridgeOptions);
|
||||
await tools.validate_project_for_export.handler({ require_non_empty_timeline: false, check_gaps: false });
|
||||
expect(String(mockedSendCommand.mock.calls[0][0])).toContain("requireNonEmptyTimeline: false");
|
||||
expect(String(mockedSendCommand.mock.calls[0][0])).toContain("PRESET_NOT_PROVIDED");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await tools.read_sequence_captions.handler({ sequence_id: "sequence-1" });
|
||||
expect(String(mockedSendCommand.mock.calls[0][0])).toContain('__findSequence("sequence-1")');
|
||||
|
||||
const rejectedBridge = { request: vi.fn().mockRejectedValue(new Error("UXP disconnected")) } as any;
|
||||
await expect(getCompetitorGapTools(bridgeOptions, rejectedBridge).detect_scene_edits.handler({
|
||||
mode: "apply_cuts", confirm_non_undoable: true,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("UXP disconnected") });
|
||||
await expect(getCompetitorGapTools(bridgeOptions, { request: vi.fn() } as any).detect_scene_edits.handler({
|
||||
mode: "not-supported" as any, confirm_non_undoable: true,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("Unsupported scene-edit mode") });
|
||||
|
||||
const bridge = { request: vi.fn().mockResolvedValue({ applied: true }) } as any;
|
||||
await getCompetitorGapTools(bridgeOptions, bridge).detect_scene_edits.handler({
|
||||
mode: "create_markers", confirm_non_undoable: true,
|
||||
});
|
||||
expect(bridge.request).toHaveBeenCalledWith("sceneEdit.detect", { mode: "createMarkers" });
|
||||
});
|
||||
});
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
import { appendFileSync, mkdtempSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockedExecFile, mockedExecFileAsync } = vi.hoisted(() => {
|
||||
const mockedExecFileAsync = vi.fn();
|
||||
const mockedExecFile = vi.fn();
|
||||
mockedExecFile[Symbol.for("nodejs.util.promisify.custom")] = mockedExecFileAsync;
|
||||
return { mockedExecFile, mockedExecFileAsync };
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({ execFile: mockedExecFile }));
|
||||
|
||||
import {
|
||||
evaluateDeliveryConformance,
|
||||
getExportTools,
|
||||
parseRationalRate,
|
||||
validateDeliveryConformanceContract,
|
||||
} from "../../src/tools/export.js";
|
||||
|
||||
const probe = {
|
||||
format: { format_name: "mov,mp4,m4a", duration: "10.020", bit_rate: "12000000" },
|
||||
streams: [
|
||||
{ codec_type: "video", codec_name: "h264", width: 1920, height: 1080, avg_frame_rate: "30000/1001", bit_rate: "10000000" },
|
||||
{ codec_type: "audio", codec_name: "aac", sample_rate: "48000", channels: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
describe("delivery conformance comparisons", () => {
|
||||
it("parses rational frame rates and rejects malformed contracts", () => {
|
||||
expect(parseRationalRate("30000/1001")).toBeCloseTo(29.97003, 5);
|
||||
expect(parseRationalRate("0/0")).toBeNull();
|
||||
expect(parseRationalRate("0/1")).toBeNull();
|
||||
expect(parseRationalRate(0)).toBeNull();
|
||||
expect(validateDeliveryConformanceContract({})).toContain("At least one");
|
||||
expect(validateDeliveryConformanceContract({ width: -1 })).toContain("positive integer");
|
||||
expect(validateDeliveryConformanceContract({ minimumVideoBitrateKbps: 20, maximumVideoBitrateKbps: 10 })).toContain("cannot exceed");
|
||||
expect(parseRationalRate(24)).toBe(24);
|
||||
expect(parseRationalRate("bad")).toBeNull();
|
||||
expect(validateDeliveryConformanceContract({ allowedContainerNames: [] })).toContain("container name");
|
||||
expect(validateDeliveryConformanceContract({ audioChannels: 1.5 })).toContain("positive integer");
|
||||
expect(validateDeliveryConformanceContract({ durationSeconds: 0 })).toContain("greater than 0");
|
||||
expect(validateDeliveryConformanceContract({ durationToleranceSeconds: -1 })).toContain("non-negative");
|
||||
expect(validateDeliveryConformanceContract({ targetLufs: 1 })).toContain("target_lufs");
|
||||
expect(validateDeliveryConformanceContract({ maximumTruePeakDbfs: 1 })).toContain("maximum_true_peak");
|
||||
expect(validateDeliveryConformanceContract({ frameRateTolerance: 0.01 })).toContain("frame_rate_tolerance requires frame_rate");
|
||||
expect(validateDeliveryConformanceContract({ durationToleranceSeconds: 0.01 })).toContain("duration_tolerance_seconds requires duration_seconds");
|
||||
expect(validateDeliveryConformanceContract({ loudnessToleranceLu: 1 })).toContain("loudness_tolerance_lu requires target_lufs");
|
||||
expect(validateDeliveryConformanceContract({ videoCodec: "h264", frameRateTolerance: 0.01 })).toContain("frame_rate_tolerance requires frame_rate");
|
||||
expect(validateDeliveryConformanceContract({ videoCodec: " " })).toContain("non-empty");
|
||||
expect(validateDeliveryConformanceContract({ audioCodec: "" })).toContain("non-empty");
|
||||
});
|
||||
|
||||
it("falls back from unusable frame-rate metadata and does not fail missing measurements", () => {
|
||||
const checks = evaluateDeliveryConformance({
|
||||
format: { bit_rate: "12000000" },
|
||||
streams: [{ codec_type: "video", avg_frame_rate: "0/1", r_frame_rate: "24/1", bit_rate: "N/A" }],
|
||||
}, {
|
||||
frameRate: 24, minimumVideoBitrateKbps: 11000, targetLufs: -23, maximumTruePeakDbfs: -1,
|
||||
}, { integratedLufs: -23, truePeakDbfs: null });
|
||||
expect(checks.find(check => check.id === "frame_rate")?.status).toBe("pass");
|
||||
expect(checks.find(check => check.id === "minimum_video_bitrate")).toMatchObject({
|
||||
status: "not_evaluated", detail: "Video bitrate metadata was unavailable",
|
||||
});
|
||||
expect(checks.find(check => check.id === "integrated_loudness")?.status).toBe("pass");
|
||||
expect(checks.find(check => check.id === "true_peak")?.status).toBe("not_evaluated");
|
||||
});
|
||||
|
||||
it("reports matching and mismatching fields independently", () => {
|
||||
const checks = evaluateDeliveryConformance(probe, {
|
||||
allowedContainerNames: ["mp4"], videoCodec: "h264", audioCodec: "pcm_s24le",
|
||||
width: 1920, height: 720, frameRate: 29.97, frameRateTolerance: 0.01,
|
||||
durationSeconds: 10, durationToleranceSeconds: 0.05,
|
||||
minimumVideoBitrateKbps: 9000, maximumVideoBitrateKbps: 11000,
|
||||
audioSampleRateHz: 48000, audioChannels: 2,
|
||||
});
|
||||
expect(checks.find(check => check.id === "container_demuxer_family")).toMatchObject({
|
||||
status: "pass", detail: expect.stringContaining("do not identify an exact container subtype"),
|
||||
});
|
||||
expect(checks.find(check => check.id === "audio_codec")?.status).toBe("fail");
|
||||
expect(checks.find(check => check.id === "height")?.status).toBe("fail");
|
||||
expect(checks.find(check => check.id === "frame_rate")?.status).toBe("pass");
|
||||
expect(checks.find(check => check.id === "maximum_video_bitrate")?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("fails expected streams that are missing and preserves unavailable loudness", () => {
|
||||
const checks = evaluateDeliveryConformance({ format: {}, streams: [] }, {
|
||||
videoCodec: "prores", audioChannels: 2, targetLufs: -23, maximumTruePeakDbfs: -1,
|
||||
}, null, "No audio stream was available for EBU R128 analysis");
|
||||
expect(checks.find(check => check.id === "video_codec")?.status).toBe("fail");
|
||||
expect(checks.find(check => check.id === "audio_channels")?.status).toBe("fail");
|
||||
expect(checks.find(check => check.id === "integrated_loudness")?.status).toBe("not_evaluated");
|
||||
expect(checks.find(check => check.id === "true_peak")?.status).toBe("not_evaluated");
|
||||
});
|
||||
|
||||
it("evaluates loudness failures and does not evaluate video bitrate without video", () => {
|
||||
const checks = evaluateDeliveryConformance({ format: { format_name: "matroska", bit_rate: "5000000" }, streams: [{ codec_type: "audio", codec_name: "aac" }] }, {
|
||||
allowedContainerNames: ["mov"], minimumVideoBitrateKbps: 1000, maximumVideoBitrateKbps: 2000,
|
||||
targetLufs: -23, loudnessToleranceLu: 0.5, maximumTruePeakDbfs: -1,
|
||||
}, { integratedLufs: -20, truePeakDbfs: -0.5 });
|
||||
expect(checks.find(check => check.id === "minimum_video_bitrate")).toMatchObject({
|
||||
status: "not_evaluated", detail: expect.stringContaining("No video stream"),
|
||||
});
|
||||
expect(checks.find(check => check.id === "maximum_video_bitrate")?.status).toBe("not_evaluated");
|
||||
expect(checks.filter(check => !check.id.includes("video_bitrate")).every(check => check.status === "fail")).toBe(true);
|
||||
|
||||
const missingBitrate = evaluateDeliveryConformance({ format: {}, streams: [{ codec_type: "video", bit_rate: "N/A" }] }, {
|
||||
minimumVideoBitrateKbps: 1000,
|
||||
});
|
||||
expect(missingBitrate.find(check => check.id === "minimum_video_bitrate")).toMatchObject({
|
||||
status: "not_evaluated", detail: "Video bitrate metadata was unavailable",
|
||||
});
|
||||
|
||||
const coverArtOnly = evaluateDeliveryConformance({
|
||||
format: { bit_rate: "5000000" },
|
||||
streams: [{ codec_type: "video", codec_name: "mjpeg", width: 1000, disposition: { attached_pic: 1 } }],
|
||||
}, { videoCodec: "mjpeg", width: 1000, minimumVideoBitrateKbps: 1000 });
|
||||
expect(coverArtOnly.find(check => check.id === "video_codec")?.status).toBe("fail");
|
||||
expect(coverArtOnly.find(check => check.id === "width")?.status).toBe("fail");
|
||||
expect(coverArtOnly.find(check => check.id === "minimum_video_bitrate")?.status).toBe("not_evaluated");
|
||||
});
|
||||
|
||||
it("does not treat unavailable numeric metadata as a mismatch", () => {
|
||||
const checks = evaluateDeliveryConformance({
|
||||
format: { duration: "N/A", bit_rate: "5000000" },
|
||||
streams: [{ codec_type: "video", width: "N/A", height: "N/A", avg_frame_rate: "0/0", r_frame_rate: "0/0" }],
|
||||
}, {
|
||||
width: 1920, height: 1080, frameRate: 24, durationSeconds: 10,
|
||||
minimumVideoBitrateKbps: 1000,
|
||||
});
|
||||
for (const id of ["width", "height", "frame_rate", "duration", "minimum_video_bitrate"]) {
|
||||
expect(checks.find(check => check.id === id)?.status).toBe("not_evaluated");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not evaluate video bitrate from aggregate format bitrate", () => {
|
||||
const checks = evaluateDeliveryConformance({
|
||||
format: { bit_rate: "5000000" },
|
||||
streams: [{ codec_type: "video", bit_rate: "1000000" }],
|
||||
}, { minimumVideoBitrateKbps: 3000, maximumVideoBitrateKbps: 3000 });
|
||||
expect(checks.find(check => check.id === "minimum_video_bitrate")?.status).toBe("fail");
|
||||
expect(checks.find(check => check.id === "maximum_video_bitrate")?.status).toBe("pass");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verify_delivery_conformance boundary", () => {
|
||||
const tool = getExportTools({ tempDir: "/tmp/test" }).verify_delivery_conformance;
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("reports its local filesystem-only operational boundary", () => {
|
||||
expect(tool.operationalCapability).toMatchObject({
|
||||
backend: "local", backends: ["local"], authority: "filesystem",
|
||||
verificationBoundary: "local_filesystem", hostVerificationRequired: false,
|
||||
minimumPremiereVersion: null,
|
||||
});
|
||||
});
|
||||
|
||||
function mediaFile() {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-delivery-conformance-"));
|
||||
const mediaPath = join(directory, "delivery.mp4");
|
||||
writeFileSync(mediaPath, "delivery-fixture");
|
||||
return mediaPath;
|
||||
}
|
||||
|
||||
it("returns an explicit failure for a missing baseline file", async () => {
|
||||
const missingPath = join(tmpdir(), `missing-delivery-${Date.now()}.mp4`);
|
||||
await expect(tool.handler({ output_path: missingPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("not found on disk") });
|
||||
expect(mockedExecFileAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a complete local conformance report", async () => {
|
||||
const mediaPath = mediaFile();
|
||||
mockedExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify(probe), stderr: "" })
|
||||
.mockResolvedValueOnce({ stdout: "", stderr: "Summary:\n Integrated loudness:\n I: -23.0 LUFS\n True peak:\n Peak: -2.0 dBFS" });
|
||||
await expect(tool.handler({
|
||||
output_path: mediaPath, allowed_container_names: ["mp4"], video_codec: "h264",
|
||||
frame_rate: 29.97, frame_rate_tolerance: 0.01, target_lufs: -23,
|
||||
maximum_true_peak_dbfs: -1,
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { mediaPath, conforms: true, evaluated: 5, notEvaluated: 0 },
|
||||
});
|
||||
expect(mockedExecFileAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockedExecFileAsync).toHaveBeenNthCalledWith(1, "ffprobe", expect.arrayContaining(["-protocol_whitelist", "file,crypto,data"]), expect.any(Object));
|
||||
expect(mockedExecFileAsync).toHaveBeenNthCalledWith(2, "ffmpeg", expect.arrayContaining(["-protocol_whitelist", "file,crypto,data", "-map", "0:a:0"]), expect.any(Object));
|
||||
});
|
||||
|
||||
it("keeps ffprobe process failures explicit", async () => {
|
||||
const mediaPath = mediaFile();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("probe failed"), { stderr: "invalid media" }));
|
||||
await expect(tool.handler({ output_path: mediaPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("invalid media") });
|
||||
|
||||
const emptyStderrPath = mediaFile();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("probe failed"), { stderr: "" }));
|
||||
await expect(tool.handler({ output_path: emptyStderrPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("probe failed") });
|
||||
|
||||
const noisyStderrPath = mediaFile();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("probe failed"), { stderr: "x".repeat(5_000_000) }));
|
||||
const result = await tool.handler({ output_path: noisyStderrPath, video_codec: "h264" });
|
||||
expect(result).toMatchObject({ success: false, error: expect.stringContaining("ffprobe delivery conformance inspection failed:") });
|
||||
expect(result.error).toHaveLength(4_096 + "ffprobe delivery conformance inspection failed: ".length);
|
||||
expect(result.error.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports ffprobe timeouts and refuses a file that changes during inspection", async () => {
|
||||
const timedOutPath = mediaFile();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("timeout"), { killed: true }));
|
||||
await expect(tool.handler({ output_path: timedOutPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("timed out") });
|
||||
|
||||
const changingPath = mediaFile();
|
||||
mockedExecFileAsync.mockImplementationOnce(async () => {
|
||||
appendFileSync(changingPath, "changed");
|
||||
return { stdout: JSON.stringify(probe), stderr: "" };
|
||||
});
|
||||
await expect(tool.handler({ output_path: changingPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("changed during") });
|
||||
|
||||
const disappearingPath = mediaFile();
|
||||
mockedExecFileAsync.mockImplementationOnce(async () => {
|
||||
unlinkSync(disappearingPath);
|
||||
return { stdout: JSON.stringify(probe), stderr: "" };
|
||||
});
|
||||
await expect(tool.handler({ output_path: disappearingPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("changed during") });
|
||||
|
||||
const rejectedAfterDeletionPath = mediaFile();
|
||||
mockedExecFileAsync.mockImplementationOnce(async () => {
|
||||
unlinkSync(rejectedAfterDeletionPath);
|
||||
throw Object.assign(new Error("probe failed"), { stderr: "invalid media" });
|
||||
});
|
||||
await expect(tool.handler({ output_path: rejectedAfterDeletionPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("changed during") });
|
||||
|
||||
const invalidAfterChangePath = mediaFile();
|
||||
mockedExecFileAsync.mockImplementationOnce(async () => {
|
||||
appendFileSync(invalidAfterChangePath, "changed");
|
||||
return { stdout: "null", stderr: "" };
|
||||
});
|
||||
await expect(tool.handler({ output_path: invalidAfterChangePath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("changed during") });
|
||||
});
|
||||
|
||||
it("handles missing tools, invalid probe JSON, and unavailable loudness without overclaiming", async () => {
|
||||
const missingToolPath = mediaFile();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" }));
|
||||
await expect(tool.handler({ output_path: missingToolPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("ffprobe was not found") });
|
||||
|
||||
const invalidJsonPath = mediaFile();
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "null", stderr: "" });
|
||||
await expect(tool.handler({ output_path: invalidJsonPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("invalid delivery report") });
|
||||
|
||||
const incompleteProbePath = mediaFile();
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: JSON.stringify({ format: {} }), stderr: "" });
|
||||
await expect(tool.handler({ output_path: incompleteProbePath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("invalid delivery report") });
|
||||
|
||||
const silentPath = mediaFile();
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: JSON.stringify({ format: {}, streams: [] }), stderr: "" });
|
||||
await expect(tool.handler({ output_path: silentPath, target_lufs: -23 }))
|
||||
.resolves.toMatchObject({ success: true, data: { conforms: false, evaluated: 0, notEvaluated: 1 } });
|
||||
|
||||
const unreadableAudioPath = mediaFile();
|
||||
mockedExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify(probe), stderr: "" })
|
||||
.mockRejectedValueOnce(Object.assign(new Error("decode"), { stderr: "no measurement" }));
|
||||
await expect(tool.handler({ output_path: unreadableAudioPath, target_lufs: -23 }))
|
||||
.resolves.toMatchObject({ success: true, data: { conforms: false, notEvaluated: 1 } });
|
||||
|
||||
const partialAudioPath = mediaFile();
|
||||
mockedExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify(probe), stderr: "" })
|
||||
.mockRejectedValueOnce(Object.assign(new Error("decode"), { stderr: "Summary:\n Integrated loudness:\n I: -23.0 LUFS\n True peak:\n Peak: -2.0 dBFS" }));
|
||||
await expect(tool.handler({ output_path: partialAudioPath, target_lufs: -23, maximum_true_peak_dbfs: -1 }))
|
||||
.resolves.toMatchObject({ success: true, data: { conforms: false, evaluated: 0, notEvaluated: 2 } });
|
||||
});
|
||||
});
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
deliveryFileChangedDuringHash,
|
||||
getExportTools,
|
||||
inspectExportPresetFile,
|
||||
verifyDeliveryFile,
|
||||
} from "../../src/tools/export.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
function temporaryFile(name: string, contents: string | Buffer): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-delivery-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const path = join(directory, name);
|
||||
writeFileSync(path, contents);
|
||||
return path;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("delivery file verification", () => {
|
||||
it("returns deterministic SHA-256 metadata", async () => {
|
||||
const path = temporaryFile("delivery.mov", "premiere delivery\n");
|
||||
const result = await verifyDeliveryFile(path);
|
||||
expect(result).toMatchObject({
|
||||
path,
|
||||
exists: true,
|
||||
regularFile: true,
|
||||
sizeBytes: 18,
|
||||
checksum: {
|
||||
algorithm: "sha256",
|
||||
value: "beabfc0259a7213ae09fffc062f2bdfc69fc0e702ab19ff44b50259b1f02d843",
|
||||
},
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports expected checksum and size mismatches without claiming validity", async () => {
|
||||
const path = temporaryFile("delivery.mp4", "video");
|
||||
const result = await verifyDeliveryFile(path, {
|
||||
expectedChecksum: "0".repeat(64),
|
||||
expectedSizeBytes: 99,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
matchesExpectedChecksum: false,
|
||||
matchesExpectedSize: false,
|
||||
valid: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects a file replacement or write during hashing", () => {
|
||||
const snapshot = { dev: 1, ino: 2, size: 100, mtimeMs: 500 };
|
||||
expect(deliveryFileChangedDuringHash(snapshot, snapshot)).toBe(false);
|
||||
expect(deliveryFileChangedDuringHash(snapshot, { ...snapshot, size: 101 })).toBe(true);
|
||||
expect(deliveryFileChangedDuringHash(snapshot, { ...snapshot, mtimeMs: 501 })).toBe(true);
|
||||
expect(deliveryFileChangedDuringHash(snapshot, { ...snapshot, ino: 3 })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns mismatch details through the MCP tool as a completed verification", async () => {
|
||||
const path = temporaryFile("delivery.mp4", "video");
|
||||
const result = await getExportTools({}).verify_delivery_file.handler({
|
||||
output_path: path,
|
||||
expected_checksum: "0".repeat(64),
|
||||
expected_size_bytes: 99,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
path,
|
||||
valid: false,
|
||||
matchesExpectedChecksum: false,
|
||||
matchesExpectedSize: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing, empty, and invalid delivery targets", async () => {
|
||||
await expect(verifyDeliveryFile("/definitely/missing/delivery.mov")).rejects.toThrow("does not exist");
|
||||
const empty = temporaryFile("empty.mov", "");
|
||||
await expect(verifyDeliveryFile(empty)).rejects.toThrow("expected at least 1");
|
||||
const path = temporaryFile("delivery.mov", "content");
|
||||
await expect(verifyDeliveryFile(path, { algorithm: "md5" as never })).rejects.toThrow("sha256");
|
||||
await expect(verifyDeliveryFile(path, { expectedChecksum: "not-hex" })).rejects.toThrow("64-character");
|
||||
});
|
||||
});
|
||||
|
||||
describe("export preset inspection", () => {
|
||||
it("accepts a non-empty regular .epr file", () => {
|
||||
const path = temporaryFile("YouTube.epr", "<preset />");
|
||||
expect(inspectExportPresetFile(path)).toMatchObject({
|
||||
path,
|
||||
exists: true,
|
||||
regularFile: true,
|
||||
sizeBytes: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects wrong extensions and empty presets", () => {
|
||||
const wrongExtension = temporaryFile("preset.xml", "<preset />");
|
||||
expect(() => inspectExportPresetFile(wrongExtension)).toThrow(".epr");
|
||||
const empty = temporaryFile("empty.epr", "");
|
||||
expect(() => inspectExportPresetFile(empty)).toThrow("empty");
|
||||
});
|
||||
});
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockedExecFile, mockedExecFileAsync } = vi.hoisted(() => {
|
||||
const mockedExecFileAsync = vi.fn();
|
||||
const mockedExecFile = vi.fn();
|
||||
mockedExecFile[Symbol.for("nodejs.util.promisify.custom")] = mockedExecFileAsync;
|
||||
return { mockedExecFile, mockedExecFileAsync };
|
||||
});
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({ sendCommand: vi.fn() }));
|
||||
vi.mock("node:child_process", () => ({ execFile: mockedExecFile }));
|
||||
|
||||
import { analyzeBeatPcm, getAudioTools } from "../../src/tools/audio.js";
|
||||
|
||||
const tools = getAudioTools({ tempDir: "/tmp/beat-tests" });
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function pulsePcm(seconds = 12) {
|
||||
const sampleRate = 200;
|
||||
const samples = new Int16Array(sampleRate * seconds);
|
||||
for (let at = 0; at < samples.length; at += sampleRate / 2) {
|
||||
for (let width = 0; width < 4; width++) samples[at + width] = 30_000;
|
||||
}
|
||||
return { sampleRate, samples };
|
||||
}
|
||||
|
||||
function sampleBuffer(samples: Int16Array) {
|
||||
const buffer = Buffer.alloc(samples.length * 2);
|
||||
for (let index = 0; index < samples.length; index++) buffer.writeInt16LE(samples[index], index * 2);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function createMediaFixture() {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-beats-"));
|
||||
const mediaPath = join(directory, "music.wav");
|
||||
writeFileSync(mediaPath, "audio-fixture");
|
||||
return mediaPath;
|
||||
}
|
||||
|
||||
describe("detect_beats analysis", () => {
|
||||
it("recovers a phase-aligned 120 BPM pulse train", () => {
|
||||
const { sampleRate, samples } = pulsePcm();
|
||||
const result = analyzeBeatPcm(samples, sampleRate);
|
||||
expect(result.bpm).toBeCloseTo(120, 0);
|
||||
expect(result.confidence).toBeGreaterThan(0.5);
|
||||
expect(result.beatTimesSeconds.slice(0, 4)).toEqual([0, 0.5, 1, 1.5]);
|
||||
});
|
||||
|
||||
it("rejects short or silent audio instead of fabricating a grid", () => {
|
||||
expect(() => analyzeBeatPcm(new Int16Array(100), 200)).toThrow("at least two seconds");
|
||||
expect(() => analyzeBeatPcm(new Int16Array(800), 200)).toThrow("No repeating beat evidence");
|
||||
});
|
||||
|
||||
it("validates each input before invoking FFmpeg", async () => {
|
||||
await expect(tools.detect_beats.handler({ media_path: "" }))
|
||||
.resolves.toMatchObject({ success: false, error: "media_path is required." });
|
||||
await expect(tools.detect_beats.handler({ media_path: "missing.wav", max_beats: 0 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("max_beats") });
|
||||
await expect(tools.detect_beats.handler({ media_path: "definitely-missing.wav" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("not found") });
|
||||
expect(mockedExecFileAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("decodes a bounded stream and reports truncation without mutating Premiere", async () => {
|
||||
const mediaPath = createMediaFixture();
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: sampleBuffer(pulsePcm().samples), stderr: Buffer.alloc(0) });
|
||||
await expect(tools.detect_beats.handler({ media_path: mediaPath, max_beats: 3 })).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { bpm: 120, beatTimesSeconds: [0, 0.5, 1], beatTimesTruncated: true, reliable: true },
|
||||
});
|
||||
expect(mockedExecFileAsync).toHaveBeenCalledTimes(1);
|
||||
const args = mockedExecFileAsync.mock.calls[0][1] as string[];
|
||||
expect(args).toEqual(expect.arrayContaining(["-t", "1800", "-ar", "200", "pipe:1"]));
|
||||
});
|
||||
|
||||
it("distinguishes missing FFmpeg, timeout, and decode failures", async () => {
|
||||
const mediaPath = createMediaFixture();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" }));
|
||||
await expect(tools.detect_beats.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("not found on PATH") });
|
||||
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("timeout"), { killed: true }));
|
||||
await expect(tools.detect_beats.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("timed out") });
|
||||
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("decode"), { stderr: "invalid stream" }));
|
||||
await expect(tools.detect_beats.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("invalid stream") });
|
||||
});
|
||||
});
|
||||
Executable
+259
@@ -0,0 +1,259 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import {
|
||||
getAudioTools,
|
||||
invertToSegments,
|
||||
mapSilenceIntervalsToTimeline,
|
||||
parseDurationSeconds,
|
||||
parseSilenceDetectOutput,
|
||||
} from "../../src/tools/audio.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const tools = getAudioTools({ tempDir: "/tmp/test", timeoutMs: 1000 });
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("parseSilenceDetectOutput", () => {
|
||||
it("pairs each silence_start with its silence_end", () => {
|
||||
const stderr = [
|
||||
"[silencedetect @ 0x1] silence_start: 2.5",
|
||||
"[silencedetect @ 0x1] silence_end: 6.25 | silence_duration: 3.75",
|
||||
"[silencedetect @ 0x1] silence_start: 10",
|
||||
"[silencedetect @ 0x1] silence_end: 12.5 | silence_duration: 2.5",
|
||||
].join("\n");
|
||||
|
||||
expect(parseSilenceDetectOutput(stderr, 20)).toEqual([
|
||||
{ start: 2.5, end: 6.25, duration: 3.75 },
|
||||
{ start: 10, end: 12.5, duration: 2.5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("closes a silence that runs to the end of the file at the media duration", () => {
|
||||
// ffmpeg emits no silence_end when the file ends mid-silence.
|
||||
const stderr = [
|
||||
"[silencedetect @ 0x1] silence_start: 2",
|
||||
"[silencedetect @ 0x1] silence_end: 5 | silence_duration: 3",
|
||||
"[silencedetect @ 0x1] silence_start: 17.5",
|
||||
].join("\n");
|
||||
|
||||
expect(parseSilenceDetectOutput(stderr, 20)).toEqual([
|
||||
{ start: 2, end: 5, duration: 3 },
|
||||
{ start: 17.5, end: 20, duration: 2.5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops an unterminated silence when the duration is unknown", () => {
|
||||
const stderr = "[silencedetect @ 0x1] silence_start: 17.5";
|
||||
expect(parseSilenceDetectOutput(stderr, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("clamps a negative silence_start to zero", () => {
|
||||
const stderr = [
|
||||
"[silencedetect @ 0x1] silence_start: -0.001",
|
||||
"[silencedetect @ 0x1] silence_end: 4 | silence_duration: 4",
|
||||
].join("\n");
|
||||
expect(parseSilenceDetectOutput(stderr, 20)[0].start).toBe(0);
|
||||
});
|
||||
|
||||
it("returns nothing for output containing no silence markers", () => {
|
||||
expect(parseSilenceDetectOutput("frame= 480 fps=0.0 q=-0.0", 20)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDurationSeconds", () => {
|
||||
it("reads the duration banner", () => {
|
||||
expect(parseDurationSeconds(" Duration: 00:00:20.00, start: 0.000000")).toBe(20);
|
||||
});
|
||||
|
||||
it("handles hours and fractional seconds", () => {
|
||||
expect(parseDurationSeconds(" Duration: 01:02:03.50, bitrate: 1000 kb/s")).toBeCloseTo(3723.5, 3);
|
||||
});
|
||||
|
||||
it("returns null when no duration is present", () => {
|
||||
expect(parseDurationSeconds("no banner here")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invertToSegments", () => {
|
||||
it("returns the ranges between silences", () => {
|
||||
const silences = [
|
||||
{ start: 2, end: 5, duration: 3 },
|
||||
{ start: 10, end: 12, duration: 2 },
|
||||
];
|
||||
expect(invertToSegments(silences, 20)).toEqual([
|
||||
{ start: 0, end: 2, duration: 2 },
|
||||
{ start: 5, end: 10, duration: 5 },
|
||||
{ start: 12, end: 20, duration: 8 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits a leading segment when the media opens on silence", () => {
|
||||
const silences = [{ start: 0, end: 4, duration: 4 }];
|
||||
expect(invertToSegments(silences, 20)).toEqual([
|
||||
{ start: 4, end: 20, duration: 16 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits a trailing segment when the media ends on silence", () => {
|
||||
const silences = [{ start: 16, end: 20, duration: 4 }];
|
||||
expect(invertToSegments(silences, 20)).toEqual([
|
||||
{ start: 0, end: 16, duration: 16 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the whole media when there is no silence", () => {
|
||||
expect(invertToSegments([], 20)).toEqual([
|
||||
{ start: 0, end: 20, duration: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns nothing usable when the duration is unknown", () => {
|
||||
expect(invertToSegments([], null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("collapses fully silent media to no segments", () => {
|
||||
const silences = [{ start: 0, end: 20, duration: 20 }];
|
||||
expect(invertToSegments(silences, 20)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapSilenceIntervalsToTimeline", () => {
|
||||
it("clips source ranges and maps the retained intervals onto a 1x placement", () => {
|
||||
expect(mapSilenceIntervalsToTimeline([
|
||||
{ start: 0, end: 4, duration: 4 },
|
||||
{ start: 7, end: 12, duration: 5 },
|
||||
], {
|
||||
sourceInSeconds: 2,
|
||||
sourceOutSeconds: 10,
|
||||
timelineStartSeconds: 100,
|
||||
maxCandidates: 50,
|
||||
})).toEqual({
|
||||
totalCandidateCount: 2,
|
||||
truncated: false,
|
||||
candidates: [
|
||||
{
|
||||
sourceStartSeconds: 2,
|
||||
sourceEndSeconds: 4,
|
||||
durationSeconds: 2,
|
||||
timelineStartSeconds: 100,
|
||||
timelineEndSeconds: 102,
|
||||
},
|
||||
{
|
||||
sourceStartSeconds: 7,
|
||||
sourceEndSeconds: 10,
|
||||
durationSeconds: 3,
|
||||
timelineStartSeconds: 105,
|
||||
timelineEndSeconds: 108,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps counting bounded candidates after its response limit", () => {
|
||||
const result = mapSilenceIntervalsToTimeline([
|
||||
{ start: 1, end: 2, duration: 1 },
|
||||
{ start: 3, end: 4, duration: 1 },
|
||||
], {
|
||||
sourceInSeconds: 0,
|
||||
sourceOutSeconds: 10,
|
||||
timelineStartSeconds: 0,
|
||||
maxCandidates: 1,
|
||||
});
|
||||
expect(result.totalCandidateCount).toBe(2);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.candidates).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detect_silence argument handling", () => {
|
||||
it("requires either a media path or a project item", async () => {
|
||||
const result = await tools.detect_silence.handler({});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("media_path or project_item_id");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a positive noise threshold", async () => {
|
||||
const result = await tools.detect_silence.handler({
|
||||
media_path: "/tmp/whatever.mp4",
|
||||
noise_threshold_db: 12,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("dBFS");
|
||||
});
|
||||
|
||||
it("rejects a non-positive minimum duration", async () => {
|
||||
const result = await tools.detect_silence.handler({
|
||||
media_path: "/tmp/whatever.mp4",
|
||||
min_duration_seconds: 0,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("greater than 0");
|
||||
});
|
||||
|
||||
it("reports a missing file before shelling out to ffmpeg", async () => {
|
||||
const result = await tools.detect_silence.handler({
|
||||
media_path: "/tmp/definitely-not-here-9f3a2b.mp4",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("not found on disk");
|
||||
});
|
||||
|
||||
it("resolves a project item's media path through the bridge", async () => {
|
||||
mockedSendCommand.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: "Project item not found: missing-clip",
|
||||
});
|
||||
const result = await tools.detect_silence.handler({
|
||||
project_item_id: "missing-clip",
|
||||
});
|
||||
expect(mockedSendCommand).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSendCommand.mock.calls[0][0]).toContain("getMediaPath");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("missing-clip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan_silence_review_markers argument handling", () => {
|
||||
const plan = tools.plan_silence_review_markers;
|
||||
|
||||
it("rejects invalid placement or response bounds before decoding media", async () => {
|
||||
await expect(plan.handler({
|
||||
media_path: "/tmp/does-not-matter.mov",
|
||||
timeline_start_seconds: -1,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("timeline_start_seconds") });
|
||||
await expect(plan.handler({
|
||||
media_path: "/tmp/does-not-matter.mov",
|
||||
timeline_start_seconds: 0,
|
||||
source_in_seconds: 5,
|
||||
source_out_seconds: 5,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("source_out_seconds") });
|
||||
await expect(plan.handler({
|
||||
media_path: "/tmp/does-not-matter.mov",
|
||||
timeline_start_seconds: 0,
|
||||
max_candidates: 201,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("max_candidates") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("detect_silence distribution prerequisites", () => {
|
||||
it("installs ffmpeg in the production container", () => {
|
||||
const dockerfile = readFileSync(join(process.cwd(), "Dockerfile"), "utf8");
|
||||
expect(dockerfile).toMatch(/apk add --no-cache ffmpeg/);
|
||||
});
|
||||
|
||||
it("documents the local ffmpeg prerequisite", () => {
|
||||
const readme = readFileSync(join(process.cwd(), "README.md"), "utf8");
|
||||
expect(readme).toContain("`detect_silence`");
|
||||
expect(readme).toContain("winget install Gyan.FFmpeg");
|
||||
expect(readme).toContain("brew install ffmpeg");
|
||||
});
|
||||
});
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { analyzeDialogueEditCandidates, getDialogueAnalysisTools } from "../../src/tools/dialogue-analysis.js";
|
||||
|
||||
const revision = `sha256:${"a".repeat(64)}`;
|
||||
describe("dialogue candidate analysis", () => {
|
||||
it("returns stable review candidates without applying edits", () => {
|
||||
const input = { segments: [
|
||||
{ id: "a", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 0, end_seconds: 1, text: "Um hello world" },
|
||||
{ id: "b", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 1, end_seconds: 2, text: "hello world again" },
|
||||
{ id: "c", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 2, end_seconds: 3, text: "hello world again" },
|
||||
], fillerWords: ["um"], silenceRanges: [{ source_project_item_id: "clip", start_seconds: 3, end_seconds: 4 }], minimumSilenceSeconds: .7 };
|
||||
const first = analyzeDialogueEditCandidates(input), second = analyzeDialogueEditCandidates(input);
|
||||
expect(first.analysis_revision).toBe(second.analysis_revision);
|
||||
expect(first.candidates.map((x) => x.reason)).toEqual(["filler_word", "repeated_phrase", "long_silence"]);
|
||||
expect(first.applied).toBe(false);
|
||||
});
|
||||
it("rejects unbound transcript input", () => expect(() => analyzeDialogueEditCandidates({ segments: [{ id: "a", source_project_item_id: "clip", transcript_revision: "bad", start_seconds: 0, end_seconds: 1, text: "hello" }] })).toThrow(/sha256/));
|
||||
it.each([
|
||||
[[], /between 1/], [[null], /must be an object/],
|
||||
[[{ id: "a", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 0, end_seconds: 1, text: "x", extra: 1 }], /unknown field/],
|
||||
[[{ id: "a", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 1, end_seconds: 1, text: "x" }], /greater/],
|
||||
[[{ id: "a", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 0, end_seconds: 1, text: 1 }], /text must/],
|
||||
])("rejects malformed segments", (segments, message) => expect(() => analyzeDialogueEditCandidates({ segments })).toThrow(message));
|
||||
it("validates optional analysis controls", () => {
|
||||
const segments = [{ id: "a", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 0, end_seconds: 1, text: "okay" }];
|
||||
expect(() => analyzeDialogueEditCandidates({ segments, fillerWords: "um" })).toThrow(/filler_words/);
|
||||
expect(() => analyzeDialogueEditCandidates({ segments, fillerWords: ["um", "um"] })).toThrow(/duplicates/);
|
||||
expect(() => analyzeDialogueEditCandidates({ segments, minimumSilenceSeconds: 40 })).toThrow(/between/);
|
||||
expect(() => analyzeDialogueEditCandidates({ segments, silenceRanges: "bad" })).toThrow(/silence_ranges/);
|
||||
expect(() => analyzeDialogueEditCandidates({ segments, silenceRanges: [null] })).toThrow(/must be an object/);
|
||||
expect(() => analyzeDialogueEditCandidates({ segments, silenceRanges: [{ source_project_item_id: "clip", start_seconds: 0, end_seconds: 1, extra: true }] })).toThrow(/unknown field/);
|
||||
});
|
||||
it("deduplicates time removal and ignores short silence", () => {
|
||||
const segments = [
|
||||
{ id: "a", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 0, end_seconds: 2, text: "um okay" },
|
||||
{ id: "b", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 3, end_seconds: 4, text: "different" },
|
||||
];
|
||||
const result = analyzeDialogueEditCandidates({ segments, fillerWords: ["um"], silenceRanges: [{ source_project_item_id: "clip", start_seconds: 1, end_seconds: 3 }, { source_project_item_id: "clip", start_seconds: 5, end_seconds: 5.2 }], minimumSilenceSeconds: .5 });
|
||||
expect(result.projected_removed_seconds).toBe(3);
|
||||
expect(result.candidate_count).toBe(2);
|
||||
});
|
||||
it("rejects duplicate ids and invalid labels", () => {
|
||||
const base = { id: "a", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 0, end_seconds: 1, text: "x" };
|
||||
expect(() => analyzeDialogueEditCandidates({ segments: [base, { ...base, start_seconds: 1, end_seconds: 2 }] })).toThrow(/duplicate id/);
|
||||
expect(() => analyzeDialogueEditCandidates({ segments: [{ ...base, speaker_label: "" }] })).toThrow(/speaker_label/);
|
||||
});
|
||||
it("bounds very large candidate sets", () => {
|
||||
const segments = Array.from({ length: 520 }, (_, index) => ({ id: String(index), source_project_item_id: "clip", transcript_revision: revision, start_seconds: index, end_seconds: index + .5, text: "um repeated phrase" }));
|
||||
const result = analyzeDialogueEditCandidates({ segments, fillerWords: ["um"] });
|
||||
expect(result.candidate_count).toBe(512);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
it("normalizes handler failures", async () => {
|
||||
expect((await getDialogueAnalysisTools().analyze_dialogue_edit_candidates.handler({ segments: [] })).success).toBe(false);
|
||||
});
|
||||
});
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({ sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }) }));
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getInspectionTools } from "../../src/tools/inspection.js";
|
||||
|
||||
const mocked = vi.mocked(sendCommand);
|
||||
const tool = getInspectionTools({ tempDir: "/tmp/test" }).inspect_edit_readiness;
|
||||
const reviewReport = getInspectionTools({ tempDir: "/tmp/test" }).inspect_sequence_review_report;
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("inspect_edit_readiness", () => {
|
||||
it("checks five structural defect classes in one read-only bridge call", async () => {
|
||||
await tool.handler({ primary_video_track: 0, maximum_scale_percent: 125 });
|
||||
expect(mocked).toHaveBeenCalledTimes(1);
|
||||
const script = mocked.mock.calls[0][0];
|
||||
for (const marker of ["PRIMARY_TRACK_GAP", "EMPTY_VIDEO_TIMELINE", "DISABLED_CLIP", "MUTED_TRACK", "EXCESSIVE_SCALE"]) {
|
||||
expect(script).toContain(marker);
|
||||
}
|
||||
expect(script).not.toMatch(/setMute\(|setValue\(|remove\(|insertClip\(/);
|
||||
});
|
||||
|
||||
it("rejects invalid thresholds locally", async () => {
|
||||
await expect(tool.handler({ primary_video_track: -1 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tool.handler({ maximum_scale_percent: 0 })).resolves.toMatchObject({ success: false });
|
||||
expect(mocked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("inspect_sequence_review_report", () => {
|
||||
it("builds one read-only, privacy-bounded handoff report", async () => {
|
||||
await reviewReport.handler({ sequence_id: "Final Cut", primary_video_track: 1, max_markers: 25 });
|
||||
expect(mocked).toHaveBeenCalledTimes(1);
|
||||
const script = mocked.mock.calls[0][0];
|
||||
for (const marker of [
|
||||
"sequence-review-handoff",
|
||||
"PRIMARY_VIDEO_TRACK_UNAVAILABLE",
|
||||
"DISABLED_CLIP",
|
||||
"MUTED_TRACK",
|
||||
"isOffline",
|
||||
"markersTruncated",
|
||||
]) {
|
||||
expect(script).toContain(marker);
|
||||
}
|
||||
expect(script).not.toContain("getMediaPath");
|
||||
expect(script).not.toContain("entry.comments");
|
||||
expect(script).not.toMatch(/setValue\(|remove\(|insertClip\(|save\(/);
|
||||
});
|
||||
|
||||
it("includes private marker comments only after explicit opt-in", async () => {
|
||||
await reviewReport.handler({ include_marker_comments: true });
|
||||
expect(mocked.mock.calls[0][0]).toContain("entry.comments");
|
||||
});
|
||||
|
||||
it("rejects invalid bounded input before using the bridge", async () => {
|
||||
await expect(reviewReport.handler({ primary_video_track: -1 })).resolves.toMatchObject({ success: false });
|
||||
await expect(reviewReport.handler({ max_markers: 201 })).resolves.toMatchObject({ success: false });
|
||||
expect(mocked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../src/bridge/file-bridge.js")>();
|
||||
return { ...actual, sendCommand: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("../../src/telemetry.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../src/telemetry.js")>();
|
||||
return { ...actual, captureActivationEvent: vi.fn() };
|
||||
});
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getHealthTools } from "../../src/tools/health.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("health connection diagnostics", () => {
|
||||
it("returns an explicit local feature-support report and safely surfaces invalid version input", async () => {
|
||||
const tools = getHealthTools(
|
||||
{ tempDir: "/tmp/health" },
|
||||
undefined,
|
||||
() => ({}),
|
||||
);
|
||||
await expect(tools.get_advanced_feature_support.handler({
|
||||
backend: "uxp",
|
||||
premiere_version: "26.3.0",
|
||||
frameio_entitled: true,
|
||||
generative_ai_entitled: true,
|
||||
network_available: true,
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
context: { backend: "uxp", premiereVersion: "26.3.0" },
|
||||
features: { productions: { staticEligibility: { eligible: true } } },
|
||||
},
|
||||
});
|
||||
await expect(tools.get_advanced_feature_support.handler({
|
||||
premiere_version: "26.beta",
|
||||
})).resolves.toEqual({
|
||||
success: false,
|
||||
error: "premiere_version must contain only numeric version components",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an unavailable UXP bridge without falling back to CEP", async () => {
|
||||
const telemetry = { enabled: true, capture: vi.fn(), shutdown: vi.fn() };
|
||||
const result = await getHealthTools({ tempDir: "/tmp/health" }, undefined, undefined, { telemetry })
|
||||
.verify_premiere_connection.handler({ backend: "uxp" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
backend: "uxp",
|
||||
overall: "needs_attention",
|
||||
components: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "premiere_connector", state: "needs_attention" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
expect(telemetry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a malformed UXP state response as a reachable host with no open project", async () => {
|
||||
const request = vi.fn().mockResolvedValue(null);
|
||||
const result = await getHealthTools(
|
||||
{ tempDir: "/tmp/health" },
|
||||
undefined,
|
||||
undefined,
|
||||
{ uxpBridge: { request } as any },
|
||||
).verify_premiere_connection.handler({ backend: "uxp" });
|
||||
|
||||
expect(request).toHaveBeenCalledWith("state.get");
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
backend: "uxp",
|
||||
overall: "needs_attention",
|
||||
components: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "premiere_connector", state: "ready" }),
|
||||
expect.objectContaining({ id: "active_project", state: "needs_attention" }),
|
||||
expect.objectContaining({ id: "active_sequence", state: "needs_attention" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an unreachable UXP bridge when its read-only request rejects", async () => {
|
||||
const request = vi.fn().mockRejectedValue(new Error("panel closed"));
|
||||
const result = await getHealthTools(
|
||||
{ tempDir: "/tmp/health" },
|
||||
undefined,
|
||||
undefined,
|
||||
{ uxpBridge: { request } as any },
|
||||
).verify_premiere_connection.handler({ backend: "uxp" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: { backend: "uxp", overall: "needs_attention" },
|
||||
});
|
||||
});
|
||||
|
||||
it("handles failed and thrown CEP probes as unavailable diagnostics", async () => {
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: false, error: "CEP unavailable" });
|
||||
const tools = getHealthTools({ tempDir: "/tmp/health" });
|
||||
await expect(tools.verify_premiere_connection.handler({ backend: "cep" }))
|
||||
.resolves.toMatchObject({ success: true, data: { backend: "cep", overall: "needs_attention" } });
|
||||
|
||||
mockedSendCommand.mockRejectedValueOnce(new Error("bridge directory unreadable"));
|
||||
await expect(tools.verify_premiere_connection.handler({ backend: "cep" }))
|
||||
.resolves.toMatchObject({ success: true, data: { backend: "cep", overall: "needs_attention" } });
|
||||
});
|
||||
});
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({ sendCommand: vi.fn().mockResolvedValue({ success: true }) }));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getEffectsTools } from "../../src/tools/effects.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const tool = getEffectsTools({ tempDir: "/tmp/test" }).inspect_stabilizer_status;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("inspect_stabilizer_status", () => {
|
||||
it("builds a read-only all-clips inspection with an explicit unknown-state boundary", async () => {
|
||||
await tool.handler({});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('requestedNodeId = ""');
|
||||
expect(script).toContain("analysisStatus = \"unknown\"");
|
||||
expect(script).toContain("unknown is not proof of a completed solve");
|
||||
expect(script).not.toContain("setValue(");
|
||||
expect(script).not.toContain("addVideoEffect(");
|
||||
});
|
||||
|
||||
it("escapes and limits inspection to a requested video clip", async () => {
|
||||
await tool.handler({ node_id: 'clip"1' });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('requestedNodeId = "clip\\\"1"');
|
||||
expect(script).toContain("__findClip(requestedNodeId)");
|
||||
expect(script).toContain('found.trackType !== "video"');
|
||||
});
|
||||
});
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareCmx3600Edls,
|
||||
getInterchangeAnalysisTools,
|
||||
inspectFcpxml,
|
||||
parseCmx3600Edl,
|
||||
timecodeToFrames,
|
||||
validateCmx3600Edl,
|
||||
} from "../../src/tools/interchange-analysis.js";
|
||||
|
||||
const tools = getInterchangeAnalysisTools({ tempDir: "/tmp/interchange-analysis" });
|
||||
|
||||
const BASE_EDL = [
|
||||
"TITLE: Round trip",
|
||||
"FCM: NON-DROP FRAME",
|
||||
"001 CAM_A V C 00:00:00:00 00:00:01:00 01:00:00:00 01:00:01:00",
|
||||
"002 CAM_B V D 024 00:00:01:00 00:00:02:00 01:00:01:00 01:00:02:00",
|
||||
].join("\n");
|
||||
|
||||
function writeFixture(name: string, contents: string): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-interchange-"));
|
||||
const path = join(directory, name);
|
||||
writeFileSync(path, contents);
|
||||
return path;
|
||||
}
|
||||
|
||||
describe("CMX 3600 parsing and validation", () => {
|
||||
it("parses CMX headers, cut and dissolve events, and bounded malformed numeric lines", () => {
|
||||
const parsed = parseCmx3600Edl(`${BASE_EDL}\n003 incomplete event`);
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
title: "Round trip",
|
||||
frameCodeMode: "NON-DROP FRAME",
|
||||
events: [
|
||||
{ eventNumber: 1, reel: "CAM_A", transition: "C", transitionDurationFrames: null },
|
||||
{ eventNumber: 2, reel: "CAM_B", transition: "D", transitionDurationFrames: 24 },
|
||||
],
|
||||
unrecognizedEventLines: [{ line: 5, text: "003 incomplete event" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("converts non-drop and valid drop-frame timecodes while rejecting invalid forms", () => {
|
||||
expect(timecodeToFrames("00:00:01:00", 24)).toBe(24);
|
||||
expect(timecodeToFrames("00:01:00;02", 29.97)).toBe(1_800);
|
||||
expect(timecodeToFrames("00:01:00;04", 59.94)).toBe(3_600);
|
||||
expect(() => timecodeToFrames("00:00:00;00", 24)).toThrow("Drop-frame separator");
|
||||
expect(() => timecodeToFrames("00:01:00;00", 29.97)).toThrow("Invalid dropped frame");
|
||||
expect(() => timecodeToFrames("00:00:60:00", 24)).toThrow("Invalid timecode fields");
|
||||
expect(() => timecodeToFrames("not timecode", 24)).toThrow("Invalid timecode");
|
||||
});
|
||||
|
||||
it("detects duplicate IDs, invalid durations, overlaps, gaps, and invalid event timecodes", () => {
|
||||
const parsed = parseCmx3600Edl([
|
||||
"001 CAM_A V C 00:00:01:00 00:00:00:00 01:00:00:00 01:00:01:00",
|
||||
"001 CAM_B V C 00:00:00:00 00:00:01:00 01:00:00:12 01:00:01:12",
|
||||
"003 CAM_C V C 00:00:00:00 00:00:01:00 01:00:02:00 01:00:03:00",
|
||||
"004 CAM_D V C 00:00:60:00 00:00:01:00 01:00:03:00 01:00:04:00",
|
||||
].join("\n"));
|
||||
const validation = validateCmx3600Edl(parsed, 24);
|
||||
|
||||
expect(validation.valid).toBe(false);
|
||||
expect(validation.errors.join(" ")).toContain("duplicated");
|
||||
expect(validation.errors.join(" ")).toContain("non-positive source");
|
||||
expect(validation.errors.join(" ")).toContain("overlaps");
|
||||
expect(validation.errors.join(" ")).toContain("Invalid timecode fields");
|
||||
expect(validation.warnings.join(" ")).toContain("record gap");
|
||||
|
||||
expect(validateCmx3600Edl(parseCmx3600Edl(BASE_EDL), 24)).toMatchObject({
|
||||
valid: true,
|
||||
totalRecordFrames: 48,
|
||||
});
|
||||
expect(validateCmx3600Edl(parseCmx3600Edl("001 malformed"), 24)).toMatchObject({
|
||||
valid: false,
|
||||
errors: ["No CMX 3600 event records were found"],
|
||||
warnings: ["1 numeric line(s) did not match the supported CMX 3600 event grammar"],
|
||||
totalRecordFrames: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("compares event additions, removals, and edits without mutating either EDL", () => {
|
||||
const before = parseCmx3600Edl(BASE_EDL);
|
||||
const after = parseCmx3600Edl([
|
||||
"001 CAM_A V C 00:00:00:00 00:00:01:00 01:00:00:00 01:00:01:00",
|
||||
"002 CAM_B V D 012 00:00:01:00 00:00:02:00 01:00:01:00 01:00:02:00",
|
||||
"004 CAM_D A C 00:00:02:00 00:00:03:00 01:00:02:00 01:00:03:00",
|
||||
].join("\n"));
|
||||
|
||||
expect(compareCmx3600Edls(before, after)).toEqual({
|
||||
beforeEventCount: 2,
|
||||
afterEventCount: 3,
|
||||
changes: [
|
||||
{ eventNumber: 2, type: "changed" },
|
||||
{ eventNumber: 4, type: "added" },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("FCPXML inspection and approved-root reference verification", () => {
|
||||
it("inspects text only, reports entities defensively, and keeps FCPXML facts bounded", () => {
|
||||
const report = inspectFcpxml([
|
||||
"<!DOCTYPE fcpxml [<!ENTITY media SYSTEM 'untrusted'>]>",
|
||||
"<fcpxml version=\"1.10\"><resources>",
|
||||
"<asset id=\"r1\" name=\"Camera\" src=\"file:///safe.mov\" />",
|
||||
"<asset id=\"r2\" name=\"Generator\" />",
|
||||
"</resources><library><event><project><sequence><spine>",
|
||||
"<asset-clip ref=\"r1\" /><video /><audio />",
|
||||
"</spine></sequence></project></event></library></fcpxml>",
|
||||
].join(""));
|
||||
|
||||
expect(report).toMatchObject({
|
||||
format: "FCPXML",
|
||||
version: "1.10",
|
||||
sequenceCount: 1,
|
||||
clipElementCount: 3,
|
||||
assetCount: 2,
|
||||
assets: [
|
||||
{ id: "r1", name: "Camera", source: "file:///safe.mov" },
|
||||
{ id: "r2", name: "Generator", source: null },
|
||||
],
|
||||
warnings: ["DOCTYPE is present; this tool inspects text only and never resolves entities"],
|
||||
});
|
||||
expect(inspectFcpxml("<sequence />").warnings).toContain("No <fcpxml> root element was found");
|
||||
});
|
||||
|
||||
it("verifies only file URLs inside caller-approved roots and hides denied paths", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "premiere-interchange-root-"));
|
||||
const allowedMedia = join(root, "media.mov");
|
||||
writeFileSync(allowedMedia, "media");
|
||||
const nested = join(root, "nested");
|
||||
mkdirSync(nested);
|
||||
const missingMedia = join(nested, "missing.mov");
|
||||
const outsideMedia = writeFixture("private.mov", "not for inspection");
|
||||
const fcpxml = writeFixture("roundtrip.fcpxml", [
|
||||
"<fcpxml version=\"1.10\"><resources>",
|
||||
`<asset id=\"r1\" name=\"inside\" src=\"${pathToFileURL(allowedMedia)}\" />`,
|
||||
`<asset id=\"r2\" name=\"missing\" src=\"${pathToFileURL(missingMedia)}\" />`,
|
||||
`<asset id=\"r3\" name=\"outside\" src=\"${pathToFileURL(outsideMedia)}\" />`,
|
||||
"<asset id=\"r4\" name=\"remote\" src=\"https://example.test/media.mov\" />",
|
||||
"<asset id=\"r5\" name=\"unset\" /></resources></fcpxml>",
|
||||
].join(""));
|
||||
|
||||
const result = await tools.verify_fcpxml_media_references.handler({ path: fcpxml, allowed_roots: [root] });
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
checkedReferenceCount: 5,
|
||||
references: [
|
||||
{ assetId: "r1", status: "available", path: allowedMedia },
|
||||
{ assetId: "r2", status: "missing", path: missingMedia },
|
||||
{ assetId: "r3", status: "outside_allowed_roots" },
|
||||
{ assetId: "r4", status: "non_file_url" },
|
||||
{ assetId: "r5", status: "missing_source" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain(outsideMedia);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interchange tool contracts", () => {
|
||||
it("returns parsed, validated, compared, and inspected local results", async () => {
|
||||
const beforePath = writeFixture("before.edl", BASE_EDL);
|
||||
const afterPath = writeFixture("after.edl", BASE_EDL.replace("CAM_B", "CAM_C"));
|
||||
const xmlPath = writeFixture("before.xml", "<fcpxml version=\"1.8\"><sequence /></fcpxml>");
|
||||
|
||||
await expect(tools.inspect_cmx3600_edl.handler({ path: beforePath })).resolves.toMatchObject({ success: true, data: { eventCount: 2 } });
|
||||
await expect(tools.validate_cmx3600_edl.handler({ path: beforePath, frame_rate: 24 })).resolves.toMatchObject({ success: true, data: { valid: true } });
|
||||
await expect(tools.compare_cmx3600_edls.handler({ before_path: beforePath, after_path: afterPath })).resolves.toMatchObject({ success: true, data: { changes: [{ eventNumber: 2, type: "changed" }] } });
|
||||
await expect(tools.inspect_fcpxml_interchange.handler({ path: xmlPath })).resolves.toMatchObject({ success: true, data: { version: "1.8" } });
|
||||
});
|
||||
|
||||
it("fails closed for invalid rates, unsafe roots, missing paths, and incorrect extensions", async () => {
|
||||
const textPath = writeFixture("not-interchange.txt", "not interchange");
|
||||
await expect(tools.inspect_cmx3600_edl.handler({ path: "missing.edl" })).resolves.toMatchObject({ success: false, error: expect.stringContaining("does not exist") });
|
||||
await expect(tools.inspect_cmx3600_edl.handler({ path: textPath })).resolves.toMatchObject({ success: false, error: expect.stringContaining(".edl") });
|
||||
await expect(tools.validate_cmx3600_edl.handler({ path: "missing.edl", frame_rate: 23 as never })).resolves.toEqual({ success: false, error: "frame_rate must be 24, 25, 29.97, 30, 50, 59.94, or 60" });
|
||||
await expect(tools.compare_cmx3600_edls.handler({ before_path: textPath, after_path: textPath })).resolves.toMatchObject({ success: false, error: expect.stringContaining(".edl") });
|
||||
await expect(tools.inspect_fcpxml_interchange.handler({ path: textPath })).resolves.toMatchObject({ success: false, error: expect.stringContaining(".fcpxml") });
|
||||
await expect(tools.verify_fcpxml_media_references.handler({ path: textPath, allowed_roots: [] })).resolves.toMatchObject({ success: false, error: expect.stringContaining(".fcpxml") });
|
||||
|
||||
const xmlPath = writeFixture("empty.fcpxml", "<fcpxml />");
|
||||
await expect(tools.verify_fcpxml_media_references.handler({ path: xmlPath, allowed_roots: ["relative-root"] })).resolves.toMatchObject({ success: false, error: expect.stringContaining("absolute directory") });
|
||||
await expect(tools.verify_fcpxml_media_references.handler({ path: xmlPath, allowed_roots: [join(tmpdir(), "missing-root")] })).resolves.toMatchObject({ success: false, error: expect.stringContaining("not an existing directory") });
|
||||
});
|
||||
});
|
||||
Executable
+180
@@ -0,0 +1,180 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getHelpersSource } from "../../src/bridge/script-builder.js";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getClipboardTools } from "../../src/tools/clipboard.js";
|
||||
import { getDiscoveryTools } from "../../src/tools/discovery.js";
|
||||
import { getHealthTools } from "../../src/tools/health.js";
|
||||
import { getMetadataTools } from "../../src/tools/metadata.js";
|
||||
import { getProjectTools } from "../../src/tools/project.js";
|
||||
import { getSequenceTools } from "../../src/tools/sequence.js";
|
||||
import { getUtilityTools } from "../../src/tools/utility.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const bridgeOptions = { tempDir: "/tmp/test-bridge", timeoutMs: 5_000 };
|
||||
|
||||
async function scriptFor(tool: { handler: (args: any) => Promise<unknown> }, args: unknown) {
|
||||
mockedSendCommand.mockClear();
|
||||
await tool.handler(args);
|
||||
expect(mockedSendCommand).toHaveBeenCalledTimes(1);
|
||||
return String(mockedSendCommand.mock.calls[0][0]);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("issue #239 — metadata writes require a readback-capable payload", () => {
|
||||
const metadata = getMetadataTools(bridgeOptions);
|
||||
const project = getProjectTools(bridgeOptions);
|
||||
|
||||
it("refuses the old field/value form before it reaches Premiere", async () => {
|
||||
const result = await metadata.set_metadata.handler({
|
||||
item_id: "clip-1",
|
||||
field_name: "Column.Intrinsic.Description",
|
||||
value: "unique value",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ success: false, error: expect.stringContaining("partial field_name/value") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes complete Project Metadata XML only with field paths and exact readback", async () => {
|
||||
const script = await scriptFor(metadata.set_metadata, {
|
||||
item_id: "clip-1",
|
||||
metadata_xml: "<xmpmeta><Description>unique value</Description></xmpmeta>",
|
||||
updated_fields: ["Column.Intrinsic.Description"],
|
||||
});
|
||||
|
||||
expect(script).toContain("item.setProjectMetadata(requestedMetadata, updatedFields)");
|
||||
expect(script).toContain("String(readback) !== requestedMetadata");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("verifies project-panel metadata and labels schema creation as non-value-writing", async () => {
|
||||
const panelScript = await scriptFor(project.set_project_panel_metadata, { metadata_xml: "<columns />" });
|
||||
expect(panelScript).toContain("app.project.getProjectPanelMetadata()");
|
||||
expect(panelScript).toContain("String(readback) !== requestedMetadata");
|
||||
|
||||
const schemaScript = await scriptFor(project.add_custom_metadata_field, {
|
||||
field_name: "ReviewStatus", field_label: "Review Status", field_type: 2,
|
||||
});
|
||||
expect(schemaScript).toContain("outcome: \"committed_unverified\"");
|
||||
expect(schemaScript).toContain("perItemValue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue #240 — legacy structural tools verify or refuse the operation", () => {
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
const project = getProjectTools(bridgeOptions);
|
||||
const sequence = getSequenceTools(bridgeOptions);
|
||||
|
||||
it("uses documented bin/sequence deletion paths and checks target absence", async () => {
|
||||
const itemScript = await scriptFor(utility.delete_project_item, { item_id: "item-1" });
|
||||
expect(itemScript).not.toContain("deleteProjectItem(");
|
||||
expect(itemScript).toContain("item.deleteBin()");
|
||||
expect(itemScript).toContain("app.project.deleteSequence(sequence)");
|
||||
expect(itemScript).toContain("if (__findProjectItem(nodeId))");
|
||||
|
||||
const manyScript = await scriptFor(utility.delete_multiple_project_items, { item_ids: ["item-1", "item-2"] });
|
||||
expect(manyScript).toContain("No project items were deleted");
|
||||
expect(manyScript).toContain("The batch is not reported as successful");
|
||||
|
||||
const binScript = await scriptFor(project.delete_bin, { bin_id: "bin-1" });
|
||||
expect(binScript).toContain("if (__findProjectItem(nodeId))");
|
||||
|
||||
const sequenceScript = await scriptFor(sequence.delete_sequence, { sequence_id: "sequence-1" });
|
||||
expect(sequenceScript).toContain("var seq = __findSequence");
|
||||
expect(sequenceScript).toContain("if (__findSequence(sequenceId))");
|
||||
});
|
||||
|
||||
it("does not misrepresent createSubsequence as timeline nesting", async () => {
|
||||
const subsequenceScript = await scriptFor(sequence.create_subsequence, {});
|
||||
expect(subsequenceScript).toContain("nested: false");
|
||||
expect(subsequenceScript).toContain("The source timeline selection is intentionally unchanged");
|
||||
expect(subsequenceScript).toContain("before.indexOf(newId) !== -1");
|
||||
|
||||
mockedSendCommand.mockClear();
|
||||
const result = await utility.nest_clips.handler({ name: "Verified nest" });
|
||||
expect(result).toMatchObject({ success: false, error: expect.stringContaining("does not replace the original clips") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a linked A/V unnest before it can leave an audio reference behind", async () => {
|
||||
const script = await scriptFor(sequence.unnest_sequence, { node_id: "nested-clip" });
|
||||
expect(script).toContain("collectLinkedReferences(seq.videoTracks, \"video\")");
|
||||
expect(script).toContain("collectLinkedReferences(seq.audioTracks, \"audio\")");
|
||||
expect(script).toContain("linkedReferences.length !== 1");
|
||||
expect(script).toContain("No clips were changed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue #241 — sequence rates use Time durations and readback", () => {
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
|
||||
it("converts a requested audio Hz value to ticks per sample and verifies it", async () => {
|
||||
const script = await scriptFor(utility.set_sequence_audio_settings, { sample_rate: 48_000 });
|
||||
expect(script).toContain("TICKS_PER_SECOND / requestedSampleRate");
|
||||
expect(script).toContain("var sampleDuration = new Time()");
|
||||
expect(script).toContain("settings.audioSampleRate = sampleDuration");
|
||||
expect(script).toContain("Math.abs(appliedTicksPerSample - requestedTicksPerSample) > 1");
|
||||
expect(script).not.toContain("settings.audioSampleRate = 48000");
|
||||
});
|
||||
|
||||
it("rejects unsafe audio-rate inputs without contacting Premiere", async () => {
|
||||
const result = await utility.set_sequence_audio_settings.handler({ sample_rate: 0 });
|
||||
expect(result).toMatchObject({ success: false, error: expect.stringContaining("between 1 and 768000") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue #242 — stale active sequences are not surfaced or trusted", () => {
|
||||
const discovery = getDiscoveryTools(bridgeOptions);
|
||||
const health = getHealthTools(bridgeOptions);
|
||||
const project = getProjectTools(bridgeOptions);
|
||||
|
||||
it("defines one current-project active-sequence guard", () => {
|
||||
const helpers = getHelpersSource();
|
||||
expect(helpers).toContain("function __isCurrentProjectSequence(sequence)");
|
||||
expect(helpers).toContain("function __getCurrentActiveSequence()");
|
||||
});
|
||||
|
||||
it("uses the guard in project info, active-sequence inspection, and ping", async () => {
|
||||
const projectInfo = await scriptFor(discovery.get_project_info, {});
|
||||
expect(projectInfo).toContain("var activeSeq = __getCurrentActiveSequence()");
|
||||
|
||||
const activeSequence = await scriptFor(discovery.get_active_sequence, {});
|
||||
expect(activeSequence).toContain("var seq = __getCurrentActiveSequence()");
|
||||
expect(activeSequence).toContain("No active sequence in the current project");
|
||||
|
||||
const ping = await scriptFor(health.ping, {});
|
||||
expect(ping).toContain("var activeSequence = app.project ? __getCurrentActiveSequence() : null");
|
||||
});
|
||||
|
||||
it("verifies project creation and sequence activation before reporting success", async () => {
|
||||
const create = await scriptFor(project.create_project, { path: "/tmp/New project.prproj" });
|
||||
expect(create).toContain("project.sequences.numSequences === 0 && active");
|
||||
expect(create).toContain("activeSequence: active ?");
|
||||
|
||||
const activate = await scriptFor(project.set_active_sequence, { sequence_id: "sequence-1" });
|
||||
expect(activate).toContain("String(active.sequenceID) !== String(seq.sequenceID)");
|
||||
expect(activate).toContain("verified: true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue #243 — copy_effect_values refuses unsafe Blend Mode writes", () => {
|
||||
const clipboard = getClipboardTools(bridgeOptions);
|
||||
|
||||
it("does not copy Blend Mode through the generic enum setter and verifies every copied scalar", async () => {
|
||||
const script = await scriptFor(clipboard.copy_effect_values, {
|
||||
source_node_id: "source-clip", target_node_id: "target-clip", effect_name: "Opacity",
|
||||
});
|
||||
|
||||
expect(script).toContain('srcProp.displayName === "Blend Mode"');
|
||||
expect(script).toContain("Legacy CEP enum writes can corrupt Blend Mode; no write was attempted.");
|
||||
expect(script).toContain("var readback = tgtComp.properties[q].getValue(0, 0)");
|
||||
expect(script).toContain("Effect-value copy was not fully verified");
|
||||
});
|
||||
});
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BridgeOptions } from "../../src/bridge/file-bridge.js";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: { covered: true } }),
|
||||
sendRawCommand: vi.fn().mockResolvedValue({ success: true, data: { covered: true } }),
|
||||
}));
|
||||
|
||||
import { sendCommand, sendRawCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getAdvancedTools } from "../../src/tools/advanced.js";
|
||||
import { getAudioTools } from "../../src/tools/audio.js";
|
||||
import { getExportTools } from "../../src/tools/export.js";
|
||||
import { getHealthTools } from "../../src/tools/health.js";
|
||||
import { getInspectionTools } from "../../src/tools/inspection.js";
|
||||
import { getClipboardTools } from "../../src/tools/clipboard.js";
|
||||
import { getCaptionTools } from "../../src/tools/captions.js";
|
||||
import { getDiscoveryTools } from "../../src/tools/discovery.js";
|
||||
import { getEffectsTools } from "../../src/tools/effects.js";
|
||||
import { getMarkerTools } from "../../src/tools/markers.js";
|
||||
import { getMediaTools } from "../../src/tools/media.js";
|
||||
import { getMetadataTools } from "../../src/tools/metadata.js";
|
||||
import { getProjectManagerTools } from "../../src/tools/project-manager.js";
|
||||
import { getProjectTools } from "../../src/tools/project.js";
|
||||
import { getRecoveryTools } from "../../src/tools/recovery.js";
|
||||
import { getScriptingTools } from "../../src/tools/scripting.js";
|
||||
import { getSelectionTools } from "../../src/tools/selection.js";
|
||||
import { getSequenceTools } from "../../src/tools/sequence.js";
|
||||
import { getSourceMonitorTools } from "../../src/tools/source-monitor.js";
|
||||
import { getTextTools } from "../../src/tools/text.js";
|
||||
import { getTimelineTools } from "../../src/tools/timeline.js";
|
||||
import { getTrackTools } from "../../src/tools/tracks.js";
|
||||
import { getTrackTargetingTools } from "../../src/tools/track-targeting.js";
|
||||
import { getUtilityTools } from "../../src/tools/utility.js";
|
||||
|
||||
type Schema = {
|
||||
type?: string;
|
||||
enum?: unknown[];
|
||||
properties?: Record<string, Schema>;
|
||||
required?: string[];
|
||||
items?: Schema;
|
||||
default?: unknown;
|
||||
};
|
||||
|
||||
type Tool = {
|
||||
parameters: Schema;
|
||||
handler: (args: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const bridgeOptions: BridgeOptions = { tempDir: "/tmp/handler-coverage", timeoutMs: 25 };
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const mockedSendRawCommand = vi.mocked(sendRawCommand);
|
||||
|
||||
const FIELD_VALUES: Record<string, unknown> = {
|
||||
path: "/tmp/coverage.prproj",
|
||||
file_path: "/tmp/coverage.mov",
|
||||
output_path: "/tmp/coverage.json",
|
||||
folder_path: "/tmp",
|
||||
node_id: "coverage-node",
|
||||
clip_node_id: "coverage-node",
|
||||
project_item_node_id: "coverage-item",
|
||||
sequence_id: "coverage-sequence",
|
||||
sequence_name: "Coverage Sequence",
|
||||
track_index: 1,
|
||||
source_track_index: 0,
|
||||
destination_track_index: 1,
|
||||
time_seconds: 2.5,
|
||||
start_seconds: 1,
|
||||
end_seconds: 3,
|
||||
duration_seconds: 2,
|
||||
frame_number: 12,
|
||||
name: "Coverage Name",
|
||||
text: "Coverage text",
|
||||
script: "return __result({ covered: true });",
|
||||
code: "return __result({ covered: true });",
|
||||
};
|
||||
|
||||
function valueFor(schema: Schema, field: string, includeOptional: boolean): unknown {
|
||||
if (field in FIELD_VALUES) return FIELD_VALUES[field];
|
||||
if (schema.enum?.length) return includeOptional ? schema.enum.at(-1) : schema.enum[0];
|
||||
if (schema.default !== undefined) return schema.default;
|
||||
switch (schema.type) {
|
||||
case "boolean": return includeOptional;
|
||||
case "number": return 1;
|
||||
case "array": return [valueFor(schema.items ?? { type: "string" }, field, includeOptional)];
|
||||
case "object": return argsFor(schema, includeOptional);
|
||||
default: return `coverage-${field}`;
|
||||
}
|
||||
}
|
||||
|
||||
function argsFor(schema: Schema, includeOptional: boolean): Record<string, unknown> {
|
||||
const args: Record<string, unknown> = {};
|
||||
const required = new Set(schema.required ?? []);
|
||||
for (const [field, property] of Object.entries(schema.properties ?? {})) {
|
||||
if (includeOptional || required.has(field)) args[field] = valueFor(property, field, includeOptional);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
const modules: Array<[string, () => Record<string, Tool>, Set<string>?]> = [
|
||||
["advanced", () => getAdvancedTools(bridgeOptions) as Record<string, Tool>],
|
||||
["audio", () => getAudioTools(bridgeOptions) as Record<string, Tool>, new Set(["detect_silence", "detect_beats", "analyze_loudness", "normalize_loudness_file"])],
|
||||
["export", () => getExportTools(bridgeOptions) as Record<string, Tool>, new Set(["validate_export_preset", "verify_delivery_file", "analyze_video_qc", "detect_source_scene_changes"])],
|
||||
["clipboard", () => getClipboardTools(bridgeOptions) as Record<string, Tool>],
|
||||
["captions", () => getCaptionTools(bridgeOptions) as Record<string, Tool>],
|
||||
["discovery", () => getDiscoveryTools(bridgeOptions) as Record<string, Tool>],
|
||||
["effects", () => getEffectsTools(bridgeOptions) as Record<string, Tool>],
|
||||
["inspection", () => getInspectionTools(bridgeOptions) as Record<string, Tool>],
|
||||
["markers", () => getMarkerTools(bridgeOptions) as Record<string, Tool>],
|
||||
["media", () => getMediaTools(bridgeOptions) as Record<string, Tool>],
|
||||
["metadata", () => getMetadataTools(bridgeOptions) as Record<string, Tool>],
|
||||
["project-manager", () => getProjectManagerTools(bridgeOptions) as Record<string, Tool>],
|
||||
["project", () => getProjectTools(bridgeOptions) as Record<string, Tool>],
|
||||
["recovery", () => getRecoveryTools(bridgeOptions) as Record<string, Tool>, new Set(["get_bridge_telemetry"])],
|
||||
["scripting", () => getScriptingTools(bridgeOptions) as Record<string, Tool>],
|
||||
["selection", () => getSelectionTools(bridgeOptions) as Record<string, Tool>],
|
||||
["sequence", () => getSequenceTools(bridgeOptions) as Record<string, Tool>],
|
||||
["source-monitor", () => getSourceMonitorTools(bridgeOptions) as Record<string, Tool>],
|
||||
["text", () => getTextTools(bridgeOptions) as Record<string, Tool>],
|
||||
["timeline", () => getTimelineTools(bridgeOptions) as Record<string, Tool>],
|
||||
["track-targeting", () => getTrackTargetingTools(bridgeOptions) as Record<string, Tool>],
|
||||
["tracks", () => getTrackTools(bridgeOptions) as Record<string, Tool>],
|
||||
["utility", () => getUtilityTools(bridgeOptions) as Record<string, Tool>],
|
||||
];
|
||||
|
||||
describe("large tool handler coverage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedSendCommand.mockResolvedValue({ success: true, data: { covered: true } });
|
||||
mockedSendRawCommand.mockResolvedValue({ success: true, data: { covered: true } });
|
||||
});
|
||||
|
||||
for (const [moduleName, getTools, excludedTools = new Set<string>()] of modules) {
|
||||
describe(moduleName, () => {
|
||||
for (const [toolName, tool] of Object.entries(getTools())) {
|
||||
if (excludedTools.has(toolName)) continue;
|
||||
it.each([
|
||||
["required arguments", false],
|
||||
["all arguments", true],
|
||||
])(`${toolName} handles %s`, async (_label, includeOptional) => {
|
||||
const commandCount = mockedSendCommand.mock.calls.length + mockedSendRawCommand.mock.calls.length;
|
||||
const result = await tool.handler(argsFor(tool.parameters, includeOptional));
|
||||
|
||||
expect(result).toBeDefined();
|
||||
const nextCommandCount = mockedSendCommand.mock.calls.length + mockedSendRawCommand.mock.calls.length;
|
||||
if (nextCommandCount === commandCount) {
|
||||
expect(result).toMatchObject({ success: false });
|
||||
return;
|
||||
}
|
||||
const script = mockedSendCommand.mock.calls.at(-1)?.[0]
|
||||
?? mockedSendRawCommand.mock.calls.at(-1)?.[0];
|
||||
expect(script).toEqual(expect.any(String));
|
||||
expect(script.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
for (const [field, property] of Object.entries(tool.parameters.properties ?? {})) {
|
||||
for (const enumValue of property.enum?.slice(1, -1) ?? []) {
|
||||
it(`${toolName} handles ${field}=${String(enumValue)}`, async () => {
|
||||
const args = argsFor(tool.parameters, true);
|
||||
args[field] = enumValue;
|
||||
const commandCount = mockedSendCommand.mock.calls.length + mockedSendRawCommand.mock.calls.length;
|
||||
const result = await tool.handler(args);
|
||||
const nextCommandCount = mockedSendCommand.mock.calls.length + mockedSendRawCommand.mock.calls.length;
|
||||
if (nextCommandCount === commandCount) {
|
||||
expect(result).toMatchObject({ success: false });
|
||||
return;
|
||||
}
|
||||
expect(nextCommandCount).toBeGreaterThan(commandCount);
|
||||
});
|
||||
}
|
||||
if (property.type === "boolean") {
|
||||
it(`${toolName} handles ${field}=false`, async () => {
|
||||
const args = argsFor(tool.parameters, true);
|
||||
args[field] = false;
|
||||
const commandCount = mockedSendCommand.mock.calls.length + mockedSendRawCommand.mock.calls.length;
|
||||
const result = await tool.handler(args);
|
||||
const nextCommandCount = mockedSendCommand.mock.calls.length + mockedSendRawCommand.mock.calls.length;
|
||||
if (nextCommandCount === commandCount) {
|
||||
expect(result).toMatchObject({ success: false });
|
||||
return;
|
||||
}
|
||||
expect(nextCommandCount).toBeGreaterThan(commandCount);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it.each([Number.NaN, 0, 241])("rejects invalid sequence frame rate %s locally", async (frameRate) => {
|
||||
const tool = getUtilityTools(bridgeOptions).set_sequence_frame_rate;
|
||||
const result = await tool.handler({ frame_rate: frameRate });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "frame_rate must be a finite value between 1 and 240 fps",
|
||||
});
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["smoothness only", { node_id: "coverage-node", smoothness: 25 }],
|
||||
["method only", { node_id: "coverage-node", method: "Subspace Warp" }],
|
||||
])("stabilizes with %s", async (_label, args) => {
|
||||
await getEffectsTools(bridgeOptions).stabilize_clip.handler(args);
|
||||
expect(mockedSendCommand).toHaveBeenCalledWith(expect.stringContaining("Warp Stabilizer"), bridgeOptions);
|
||||
});
|
||||
|
||||
describe("health diagnostic failure branches", () => {
|
||||
it("reports successful, primitive, and rejected UXP diagnostic responses", async () => {
|
||||
const request = vi.fn()
|
||||
.mockResolvedValueOnce({ projectOpen: true, sequenceOpen: true })
|
||||
.mockResolvedValueOnce("unexpected")
|
||||
.mockRejectedValueOnce(new Error("offline"));
|
||||
const tools = getHealthTools(bridgeOptions, undefined, undefined, {
|
||||
uxpBridge: { request } as never,
|
||||
});
|
||||
|
||||
await expect(tools.verify_premiere_connection.handler({ backend: "uxp" }))
|
||||
.resolves.toMatchObject({ success: true, data: { overall: "ready" } });
|
||||
await expect(tools.verify_premiere_connection.handler({ backend: "uxp" }))
|
||||
.resolves.toMatchObject({ success: true, data: { overall: "needs_attention" } });
|
||||
await expect(tools.verify_premiere_connection.handler({ backend: "uxp" }))
|
||||
.resolves.toMatchObject({ success: true, data: { overall: "needs_attention" } });
|
||||
});
|
||||
|
||||
it("turns a thrown CEP diagnostic into a completed needs-attention report", async () => {
|
||||
mockedSendCommand.mockRejectedValueOnce(new Error("CEP unavailable"));
|
||||
const result = await getHealthTools(bridgeOptions).verify_premiere_connection.handler({ backend: "cep" });
|
||||
expect(result).toMatchObject({ success: true, data: { overall: "needs_attention" } });
|
||||
});
|
||||
});
|
||||
});
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getExportTools } from "../../src/tools/export.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const tool = getExportTools({ tempDir: "/tmp/test", timeoutMs: 1000 }).export_sequence_marker_review_frames;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("export_sequence_marker_review_frames", () => {
|
||||
it("exports chronological marker anchors in one bounded bridge request", async () => {
|
||||
await tool.handler({
|
||||
output_dir: "/tmp/review",
|
||||
marker_type: "Comment",
|
||||
start_seconds: 2,
|
||||
end_seconds: 18,
|
||||
limit: 8,
|
||||
});
|
||||
|
||||
expect(mockedSendCommand).toHaveBeenCalledTimes(1);
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('var requiredType = "Comment"');
|
||||
expect(script).toContain("matched.sort(function(a, b)");
|
||||
expect(script).toContain("var requested = Math.min(matched.length, 8)");
|
||||
expect(script).toContain("marker_review_");
|
||||
expect(script).toContain("__exportStillFrame(");
|
||||
expect(script).toContain("truncated: matched.length > requested");
|
||||
});
|
||||
|
||||
it("rejects invalid filters and bounds before contacting Premiere", async () => {
|
||||
await expect(tool.handler({ output_dir: "/tmp/review", limit: 25 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("1 through 24") });
|
||||
await expect(tool.handler({ output_dir: "/tmp/review", marker_type: " " }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("non-empty") });
|
||||
await expect(tool.handler({ output_dir: "/tmp/review", start_seconds: 9, end_seconds: 2 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("greater than") });
|
||||
await expect(tool.handler({ output_dir: "", limit: 1 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("non-empty") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports file verification and partial-export boundaries in the generated request", async () => {
|
||||
await tool.handler({ output_dir: "/tmp/review", limit: 2 });
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("failures.push");
|
||||
expect(script).toContain("complete: frames.length === requested");
|
||||
expect(script).toContain("verified on disk by the Premiere bridge at the matched marker start");
|
||||
});
|
||||
});
|
||||
Executable
+229
@@ -0,0 +1,229 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockedExecFile, mockedExecFileAsync } = vi.hoisted(() => {
|
||||
const mockedExecFileAsync = vi.fn();
|
||||
const mockedExecFile = vi.fn();
|
||||
mockedExecFile[Symbol.for("nodejs.util.promisify.custom")] = mockedExecFileAsync;
|
||||
return { mockedExecFile, mockedExecFileAsync };
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({ execFile: mockedExecFile }));
|
||||
|
||||
import {
|
||||
analyzeRgbScopes,
|
||||
compareScopeReadings,
|
||||
getMediaAnalysisTools,
|
||||
parseCropDetectOutput,
|
||||
parseIdetOutput,
|
||||
parseMediaProbeJson,
|
||||
parseMotionPeakCandidates,
|
||||
parseTransientCandidates,
|
||||
} from "../../src/tools/media-analysis.js";
|
||||
|
||||
const tools = getMediaAnalysisTools({ tempDir: "/tmp/test" });
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function fixture(extension = ".mov") {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-media-analysis-"));
|
||||
const path = join(directory, `fixture${extension}`);
|
||||
writeFileSync(path, "fixture");
|
||||
return path;
|
||||
}
|
||||
|
||||
describe("media analysis parsers", () => {
|
||||
it("normalizes ffprobe JSON", () => {
|
||||
expect(parseMediaProbeJson(JSON.stringify({
|
||||
format: { duration: "2.0" }, streams: [{ codec_type: "video" }, null], chapters: [{ id: 1 }],
|
||||
}))).toEqual({ format: { duration: "2.0" }, streams: [{ codec_type: "video" }], chapters: [{ id: 1 }] });
|
||||
expect(parseMediaProbeJson("{}")).toEqual({ format: {}, streams: [], chapters: [] });
|
||||
expect(parseMediaProbeJson(JSON.stringify({ format: "bad", streams: "bad", chapters: [null, "bad"] })))
|
||||
.toEqual({ format: {}, streams: [], chapters: [] });
|
||||
});
|
||||
|
||||
it("keeps the strongest transient inside the minimum interval", () => {
|
||||
const output = [
|
||||
"frame:0 pts_time:0.10", "lavfi.astats.Overall.Peak_level=-8",
|
||||
"frame:1 pts_time:0.20", "lavfi.astats.Overall.Peak_level=-3",
|
||||
"frame:2 pts_time:1.00", "lavfi.astats.Overall.Peak_level=-20",
|
||||
"frame:3 pts_time:1.50", "lavfi.astats.Overall.Peak_level=-6",
|
||||
].join("\n");
|
||||
expect(parseTransientCandidates(output, -12, 0.25)).toEqual([
|
||||
{ timeSeconds: 0.2, peakDbfs: -3 }, { timeSeconds: 1.5, peakDbfs: -6 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps local motion maxima and the strongest peak inside the minimum interval", () => {
|
||||
const output = [
|
||||
"frame:0 pts_time:0", "lavfi.signalstats.YAVG=2",
|
||||
"frame:1 pts_time:0.25", "lavfi.signalstats.YAVG=15",
|
||||
"frame:2 pts_time:0.5", "lavfi.signalstats.YAVG=2",
|
||||
"frame:3 pts_time:0.75", "lavfi.signalstats.YAVG=20",
|
||||
"frame:4 pts_time:1", "lavfi.signalstats.YAVG=2",
|
||||
"frame:5 pts_time:2", "lavfi.signalstats.YAVG=18",
|
||||
].join("\n");
|
||||
expect(parseMotionPeakCandidates(output, 12, 1)).toEqual([
|
||||
{ timeSeconds: 0.75, difference: 20 }, { timeSeconds: 2, difference: 18 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("represents an equal-valued motion plateau with one peak", () => {
|
||||
const output = [
|
||||
"frame:0 pts_time:0", "lavfi.signalstats.YAVG=2",
|
||||
"frame:1 pts_time:0.25", "lavfi.signalstats.YAVG=15",
|
||||
"frame:2 pts_time:0.5", "lavfi.signalstats.YAVG=15",
|
||||
"frame:3 pts_time:0.75", "lavfi.signalstats.YAVG=2",
|
||||
].join("\n");
|
||||
expect(parseMotionPeakCandidates(output, 12, 0.1)).toEqual([
|
||||
{ timeSeconds: 0.25, difference: 15 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("computes waveform, parade, saturation, and RGB endpoint occupancy", () => {
|
||||
const reading = analyzeRgbScopes(Uint8Array.from([0, 0, 0, 255, 255, 255, 255, 0, 0]));
|
||||
expect(reading).toMatchObject({
|
||||
pixels: 3,
|
||||
waveform: { black: 0, white: 100 },
|
||||
rgbParade: { red: { high: 100 }, green: { low: 0 }, blue: { median: 0 } },
|
||||
saturation: { high: 100 },
|
||||
rgbExtremes: { nearBlackPercent: 33.33, nearWhitePercent: 33.33 },
|
||||
});
|
||||
expect(() => analyzeRgbScopes(Uint8Array.from([0, 1]))).toThrow("complete RGB24 pixels");
|
||||
});
|
||||
|
||||
it("compares scope readings with target-minus-reference deltas and coarse directions", () => {
|
||||
const reference = analyzeRgbScopes(Uint8Array.from([100, 100, 100, 120, 120, 120]));
|
||||
const target = analyzeRgbScopes(Uint8Array.from([60, 70, 80, 70, 80, 90]));
|
||||
expect(compareScopeReadings(reference, target)).toMatchObject({
|
||||
targetMinusReference: { medianLuma: expect.any(Number), redBlueBalance: expect.any(Number) },
|
||||
suggestedDirections: { exposure: "raise", warmth: "warmer", saturation: "lower" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the warmth direction stable across proportional exposure changes", () => {
|
||||
const reference = analyzeRgbScopes(Uint8Array.from([100, 75, 50]));
|
||||
const target = analyzeRgbScopes(Uint8Array.from([200, 150, 100]));
|
||||
expect(compareScopeReadings(reference, target)).toMatchObject({
|
||||
targetMinusReference: { redBlueBalance: -0.01 },
|
||||
suggestedDirections: { exposure: "lower", warmth: "hold", saturation: "hold" },
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies progressive, mixed, and absent idet summaries", () => {
|
||||
expect(parseIdetOutput("Multi frame detection: TFF: 1 BFF: 0 Progressive: 99 Undetermined: 0").classification).toBe("progressive");
|
||||
expect(parseIdetOutput("Multi frame detection: TFF: 50 BFF: 0 Progressive: 50 Undetermined: 0").classification).toBe("mixed");
|
||||
expect(parseIdetOutput("").classification).toBe("undetermined");
|
||||
expect(parseIdetOutput("Multi frame detection: TFF: 90 BFF: 5 Progressive: 5 Undetermined: 1").classification).toBe("tff");
|
||||
expect(parseIdetOutput("Multi frame detection: TFF: 5 BFF: 90 Progressive: 5 Undetermined: 1").classification).toBe("bff");
|
||||
});
|
||||
|
||||
it("selects the most frequent active-picture rectangle", () => {
|
||||
const output = "crop=1920:800:0:140\ncrop=1920:800:0:140\ncrop=1900:800:10:140";
|
||||
expect(parseCropDetectOutput(output)).toEqual({ width: 1920, height: 800, x: 0, y: 140, samples: 2 });
|
||||
expect(parseCropDetectOutput("no crop data")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("media analysis tool contracts", () => {
|
||||
it("rejects missing files and unsafe output arguments before spawning", async () => {
|
||||
await expect(tools.inspect_media_streams.handler({ media_path: "" })).resolves.toMatchObject({ success: false });
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-media-directory-"));
|
||||
mkdirSync(join(directory, "nested"));
|
||||
await expect(tools.inspect_media_streams.handler({ media_path: join(directory, "nested") })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.inspect_media_streams.handler({ media_path: "missing.mov" })).resolves.toMatchObject({ success: false });
|
||||
const mediaPath = fixture();
|
||||
await expect(tools.generate_media_contact_sheet.handler({ media_path: mediaPath, output_path: "sheet.jpg" })).resolves.toMatchObject({ success: false, error: expect.stringContaining(".png") });
|
||||
const existingOutput = fixture(".png");
|
||||
await expect(tools.generate_media_contact_sheet.handler({ media_path: mediaPath, output_path: existingOutput })).resolves.toMatchObject({ success: false, error: expect.stringContaining("already exists") });
|
||||
await expect(tools.generate_media_contact_sheet.handler({ media_path: mediaPath, output_path: join(directory, "sheet.png"), columns: 1 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.generate_media_contact_sheet.handler({ media_path: mediaPath, output_path: join(directory, "sheet.png"), thumbnail_width: 20 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_audio_transients.handler({ media_path: mediaPath, threshold_dbfs: 1 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_audio_transients.handler({ media_path: mediaPath, minimum_interval_seconds: 20 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_audio_transients.handler({ media_path: mediaPath, maximum_events: 0 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_motion_peaks.handler({ media_path: mediaPath, sample_seconds: 0 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_motion_peaks.handler({ media_path: mediaPath, samples_per_second: 11 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_motion_peaks.handler({ media_path: mediaPath, threshold: 256 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_motion_peaks.handler({ media_path: mediaPath, minimum_interval_seconds: 0 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_motion_peaks.handler({ media_path: mediaPath, maximum_events: 0 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.read_video_scopes.handler({ media_path: mediaPath, time_seconds: -1 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.plan_shot_match.handler({ reference_media_path: mediaPath, target_media_path: mediaPath, target_time_seconds: -1 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.analyze_video_interlacing.handler({ media_path: mediaPath, sample_seconds: 0 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_active_picture_bounds.handler({ media_path: mediaPath, sample_seconds: 301 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_active_picture_bounds.handler({ media_path: mediaPath, limit: 300 })).resolves.toMatchObject({ success: false });
|
||||
expect(mockedExecFileAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns structured media inspection", async () => {
|
||||
const mediaPath = fixture();
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: JSON.stringify({ format: { format_name: "mov" }, streams: [{ codec_name: "h264" }] }), stderr: "" });
|
||||
await expect(tools.inspect_media_streams.handler({ media_path: mediaPath })).resolves.toMatchObject({
|
||||
success: true, data: { mediaPath, streamCount: 1, format: { format_name: "mov" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns bounded transient, interlace, and picture findings", async () => {
|
||||
const mediaPath = fixture();
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "pts_time:1\nlavfi.astats.Overall.Peak_level=-4" });
|
||||
await expect(tools.detect_audio_transients.handler({ media_path: mediaPath, maximum_events: 1 })).resolves.toMatchObject({ success: true, data: { candidates: [{ timeSeconds: 1, peakDbfs: -4 }] } });
|
||||
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "pts_time:1\nlavfi.signalstats.YAVG=20" });
|
||||
await expect(tools.detect_motion_peaks.handler({ media_path: mediaPath, threshold: 12, maximum_events: 1 })).resolves.toMatchObject({ success: true, data: { candidates: [{ timeSeconds: 1, difference: 20 }] } });
|
||||
expect(mockedExecFileAsync.mock.calls.at(-1)?.[1]).toEqual(expect.arrayContaining(["-t", "60", "-an", "-f", "null", "-"]));
|
||||
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: Buffer.alloc(320 * 180 * 3, 128), stderr: Buffer.alloc(0) });
|
||||
await expect(tools.read_video_scopes.handler({ media_path: mediaPath, time_seconds: 2 })).resolves.toMatchObject({ success: true, data: { timeSeconds: 2, pixels: 57_600, sampleSize: { width: 320, height: 180 } } });
|
||||
expect(mockedExecFileAsync.mock.calls.at(-1)?.[1]).toEqual(expect.arrayContaining(["-ss", "2", "-frames:v", "1", "rgb24", "pipe:1"]));
|
||||
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("timeout"), { killed: true }));
|
||||
await expect(tools.read_video_scopes.handler({ media_path: mediaPath })).resolves.toMatchObject({ success: false, error: expect.stringContaining("timed out after 60 seconds") });
|
||||
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("decode"), { stderr: Buffer.from("invalid video stream") }));
|
||||
await expect(tools.read_video_scopes.handler({ media_path: mediaPath })).resolves.toMatchObject({ success: false, error: expect.stringContaining("invalid video stream") });
|
||||
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: Buffer.alloc(320 * 180 * 3, 100), stderr: Buffer.alloc(0) });
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: Buffer.alloc(320 * 180 * 3, 80), stderr: Buffer.alloc(0) });
|
||||
await expect(tools.plan_shot_match.handler({ reference_media_path: mediaPath, target_media_path: mediaPath })).resolves.toMatchObject({ success: true, data: { comparison: { suggestedDirections: { exposure: "raise" } } } });
|
||||
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "Multi frame detection: TFF: 0 BFF: 0 Progressive: 20 Undetermined: 0" });
|
||||
await expect(tools.analyze_video_interlacing.handler({ media_path: mediaPath })).resolves.toMatchObject({ success: true, data: { classification: "progressive", passesProgressiveDelivery: true } });
|
||||
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "crop=1920:800:0:140" });
|
||||
await expect(tools.detect_active_picture_bounds.handler({ media_path: mediaPath })).resolves.toMatchObject({ success: true, data: { activePicture: { width: 1920, height: 800 } } });
|
||||
});
|
||||
|
||||
it("creates and verifies a new contact sheet without overwriting", async () => {
|
||||
const mediaPath = fixture();
|
||||
const outputPath = join(mkdtempSync(join(tmpdir(), "premiere-contact-sheet-")), "sheet.png");
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "4", stderr: "" });
|
||||
mockedExecFileAsync.mockImplementationOnce(async (_command: string, args: string[]) => {
|
||||
writeFileSync(args.at(-1)!, "png-output");
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
await expect(tools.generate_media_contact_sheet.handler({ media_path: mediaPath, output_path: outputPath }))
|
||||
.resolves.toMatchObject({ success: true, data: { outputPath, verified: true, grid: { requestedFrames: 12 } } });
|
||||
});
|
||||
|
||||
it("fails closed when contact-sheet duration or output verification is unavailable", async () => {
|
||||
const mediaPath = fixture();
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-contact-sheet-failure-"));
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "not-a-duration", stderr: "" });
|
||||
await expect(tools.generate_media_contact_sheet.handler({ media_path: mediaPath, output_path: join(directory, "duration.png") }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("positive media duration") });
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "2", stderr: "" }).mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||
await expect(tools.generate_media_contact_sheet.handler({ media_path: mediaPath, output_path: join(directory, "missing.png") }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("non-empty") });
|
||||
});
|
||||
|
||||
it("fails closed on tool errors and missing measurements", async () => {
|
||||
const mediaPath = fixture();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("timed out"), { killed: true }));
|
||||
await expect(tools.inspect_media_streams.handler({ media_path: mediaPath })).resolves.toMatchObject({ success: false, error: expect.stringContaining("timed out") });
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||
await expect(tools.detect_active_picture_bounds.handler({ media_path: mediaPath })).resolves.toMatchObject({ success: false, error: expect.stringContaining("no active-picture") });
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("decode failed"), { stderr: "bad stream" }));
|
||||
await expect(tools.analyze_video_interlacing.handler({ media_path: mediaPath })).resolves.toMatchObject({ success: false, error: expect.stringContaining("bad stream") });
|
||||
});
|
||||
});
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
import { mkdirSync, mkdtempSync, utimesSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { MediaWatchRegistry, getMediaWatchTools } from "../../src/tools/media-watch.js";
|
||||
|
||||
const registries: MediaWatchRegistry[] = [];
|
||||
afterEach(() => registries.splice(0).forEach((registry) => registry.close()));
|
||||
describe("media watch", () => {
|
||||
it("proposes new media without disclosing paths by default", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "premiere-watch-"));
|
||||
writeFileSync(path.join(root, "existing.mp4"), "old");
|
||||
const registry = new MediaWatchRegistry(); registries.push(registry);
|
||||
const started = registry.start({ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"] }) as any;
|
||||
writeFileSync(path.join(root, "new.mp4"), "new");
|
||||
const preview = registry.preview({ watch_id: started.watch_id }) as any;
|
||||
expect(preview.proposed_count).toBe(1);
|
||||
expect(preview.proposed_imports[0]).not.toHaveProperty("media_path");
|
||||
expect(preview.applied).toBe(false);
|
||||
});
|
||||
it("supports disclosure, rescan, stop, and handler failures", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "premiere-watch-"));
|
||||
const registry = new MediaWatchRegistry(); registries.push(registry);
|
||||
const started = registry.start({ approved_workspace_path: root, watch_path: root, allowed_extensions: [".MP4"], recursive: false }) as any;
|
||||
writeFileSync(path.join(root, "new.mp4"), "new");
|
||||
const preview = registry.preview({ watch_id: started.watch_id, include_paths: true, known_media_path_hashes: [] }) as any;
|
||||
expect(preview.proposed_imports[0].media_path).toContain("new.mp4");
|
||||
expect((registry.rescan() as any).baseline_file_count).toBe(1);
|
||||
registry.close(); expect(registry.status()).toMatchObject({ active: false });
|
||||
expect(() => registry.preview({ watch_id: started.watch_id })).toThrow(/no media watch/);
|
||||
});
|
||||
it("rejects unsafe starts and stale preview arguments", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "premiere-watch-")), outside = mkdtempSync(path.join(tmpdir(), "outside-"));
|
||||
const registry = new MediaWatchRegistry(); registries.push(registry);
|
||||
expect(() => registry.start({ approved_workspace_path: root, watch_path: outside, allowed_extensions: ["mp4"] })).toThrow(/contained/);
|
||||
const started = registry.start({ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"] }) as any;
|
||||
expect(() => registry.start({})).toThrow(/already active/);
|
||||
expect(() => registry.preview({ watch_id: "wrong" })).toThrow(/does not match/);
|
||||
expect(() => registry.preview({ watch_id: started.watch_id, include_paths: "yes" })).toThrow(/boolean/);
|
||||
expect(() => registry.preview({ watch_id: started.watch_id, known_media_path_hashes: ["bad"] })).toThrow(/sha256/);
|
||||
});
|
||||
it("validates paths, extensions, recursion, bins, and inactive scans", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "premiere-watch-"));
|
||||
for (const args of [
|
||||
{ approved_workspace_path: "relative", watch_path: root, allowed_extensions: ["mp4"] },
|
||||
{ approved_workspace_path: root, watch_path: root, allowed_extensions: [] },
|
||||
{ approved_workspace_path: root, watch_path: root, allowed_extensions: [1] },
|
||||
{ approved_workspace_path: root, watch_path: root, allowed_extensions: ["bad!"] },
|
||||
{ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4", ".MP4"] },
|
||||
{ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"], recursive: "yes" },
|
||||
{ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"], target_bin_id: "" },
|
||||
{ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"], target_bin_id: "x".repeat(513) },
|
||||
]) expect(() => new MediaWatchRegistry().start(args)).toThrow();
|
||||
expect(() => new MediaWatchRegistry().rescan()).toThrow(/no media watch/);
|
||||
});
|
||||
it("routes every management action through the public handlers", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "premiere-watch-")), registry = new MediaWatchRegistry(); registries.push(registry);
|
||||
const tools = getMediaWatchTools(registry);
|
||||
const started = await tools.manage_media_watch.handler({ action: "start", approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"] }) as any;
|
||||
expect((await tools.manage_media_watch.handler({ action: "status" })).success).toBe(true);
|
||||
expect((await tools.manage_media_watch.handler({ action: "scan" })).success).toBe(true);
|
||||
expect((await tools.preview_watched_media_import.handler({ watch_id: started.data.watch_id })).success).toBe(true);
|
||||
expect((await tools.manage_media_watch.handler({ action: "unknown" })).success).toBe(false);
|
||||
expect((await tools.manage_media_watch.handler({ action: "stop" })).success).toBe(true);
|
||||
expect((await tools.preview_watched_media_import.handler({ watch_id: started.data.watch_id })).success).toBe(false);
|
||||
});
|
||||
it("filters known hashes and proposes changed baseline files to a bin", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "premiere-watch-")), file = path.join(root, "clip.mp4"); writeFileSync(file, "a");
|
||||
const registry = new MediaWatchRegistry(); registries.push(registry);
|
||||
const started = registry.start({ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"], target_bin_id: "bin" }) as any;
|
||||
writeFileSync(file, "changed-size");
|
||||
const first = registry.preview({ watch_id: started.watch_id }) as any, hash = first.proposed_imports[0].path_hash;
|
||||
expect(first.proposed_imports[0].target_bin_id).toBe("bin");
|
||||
expect((registry.preview({ watch_id: started.watch_id, known_media_path_hashes: [hash] }) as any).proposed_count).toBe(0);
|
||||
expect(() => registry.preview({ watch_id: started.watch_id, known_media_path_hashes: "bad" })).toThrow(/at most 5000/);
|
||||
});
|
||||
it("does not propose an unchanged baseline file", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "premiere-watch-")); writeFileSync(path.join(root, "clip.mp4"), "same");
|
||||
const registry = new MediaWatchRegistry(); registries.push(registry);
|
||||
const started = registry.start({ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"] }) as any;
|
||||
expect((registry.preview({ watch_id: started.watch_id }) as any).proposed_count).toBe(0);
|
||||
});
|
||||
it("detects same-size timestamp changes and recursively scans subfolders", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "premiere-watch-")), nested = path.join(root, "nested"); mkdirSync(nested);
|
||||
const file = path.join(nested, "clip.mp4"); writeFileSync(file, "same");
|
||||
const registry = new MediaWatchRegistry(); registries.push(registry);
|
||||
const started = registry.start({ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"], recursive: true }) as any;
|
||||
const changed = new Date(Date.now() + 10_000); utimesSync(file, changed, changed);
|
||||
expect((registry.preview({ watch_id: started.watch_id }) as any).proposed_count).toBe(1);
|
||||
});
|
||||
});
|
||||
Executable
+188
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getMogrtAuthoringTools } from "../../src/tools/mogrt-authoring.js";
|
||||
|
||||
const bridgeOptions = { tempDir: "D:/PremiereBridge", timeoutMs: 5000 };
|
||||
|
||||
describe("MOGRT authoring tools", () => {
|
||||
it("issues a bounded lower-third preview and consumes its token exactly once", async () => {
|
||||
const scripts: string[] = [];
|
||||
const tools = getMogrtAuthoringTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
tokenFactory: () => "preview-token",
|
||||
send: async (script) => {
|
||||
scripts.push(script);
|
||||
return { success: true, data: { exportRequested: true, hostExportReturn: false } };
|
||||
},
|
||||
artifactStatus: () => ({ exists: false, size_bytes: null, zip_header_valid: false }),
|
||||
operationIdFactory: () => "operation-1",
|
||||
});
|
||||
|
||||
const preview = await tools.preview_mogrt_recipe.handler({
|
||||
recipe: "lower_third",
|
||||
template_name: "Launch Title",
|
||||
headline: "A headline",
|
||||
subtitle: "A subtitle",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
});
|
||||
expect(preview).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
applied: false,
|
||||
previewToken: "preview-token",
|
||||
plan: { output_path: "D:\\Approved\\templates\\Launch Title.mogrt" },
|
||||
},
|
||||
});
|
||||
|
||||
const created = await tools.create_mogrt_recipe.handler({
|
||||
preview_token: "preview-token",
|
||||
confirm_export: true,
|
||||
});
|
||||
expect(created).toMatchObject({ success: true, data: { operationId: "operation-1" } });
|
||||
expect(scripts).toHaveLength(1);
|
||||
expect(scripts[0]).toContain("exportAsMotionGraphicsTemplate");
|
||||
expect(scripts[0]).toContain("addToMotionGraphicsTemplate");
|
||||
expect(scripts[0]).toContain("Open a saved After Effects project inside approved_workspace_path");
|
||||
expect(scripts[0]).toContain("existing.exists");
|
||||
|
||||
await expect(tools.create_mogrt_recipe.handler({
|
||||
preview_token: "preview-token",
|
||||
confirm_export: true,
|
||||
})).rejects.toThrow("already used");
|
||||
});
|
||||
|
||||
it("fails closed when an output path leaves its approved workspace", async () => {
|
||||
const tools = getMogrtAuthoringTools(bridgeOptions, { directoryExists: () => true });
|
||||
await expect(tools.preview_mogrt_recipe.handler({
|
||||
template_name: "Outside",
|
||||
headline: "Nope",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Elsewhere",
|
||||
})).rejects.toThrow("inside approved_workspace_path");
|
||||
});
|
||||
|
||||
it("supports only the bounded recipe library and applies contained brand-kit defaults", async () => {
|
||||
const tools = getMogrtAuthoringTools(bridgeOptions, { directoryExists: () => true });
|
||||
await expect(tools.preview_mogrt_recipe.handler({
|
||||
recipe: "social_end_card",
|
||||
template_name: "Brand End Card",
|
||||
headline: "Follow us",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
brand_kit: { name: "Brand", name_prefix: "Brand", accent_color: "#102030", text_color: "#FFFFFF", safe_margin_percent: 0.1 },
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { plan: { recipe: "social_end_card", accent_color: "#102030", text_color: "#FFFFFF" } },
|
||||
});
|
||||
await expect(tools.preview_mogrt_recipe.handler({
|
||||
recipe: "arbitrary_script",
|
||||
template_name: "Nope",
|
||||
headline: "Nope",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
})).rejects.toThrow("recipe must be one of");
|
||||
});
|
||||
|
||||
it("does not report a valid ZIP header as visual or import verification", async () => {
|
||||
const tools = getMogrtAuthoringTools(bridgeOptions, {
|
||||
artifactStatus: () => ({ exists: true, size_bytes: 42, zip_header_valid: true }),
|
||||
operationIdFactory: () => "operation-verify",
|
||||
});
|
||||
await expect(tools.verify_mogrt_artifact.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { visualVerified: false, importVerified: false, artifact: { zip_header_valid: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unapproved preview inputs before a connector call", async () => {
|
||||
const tools = getMogrtAuthoringTools(bridgeOptions, { directoryExists: () => false });
|
||||
await expect(tools.preview_mogrt_recipe.handler(null)).rejects.toThrow("arguments must be an object");
|
||||
await expect(tools.preview_mogrt_recipe.handler({
|
||||
template_name: "Missing output",
|
||||
headline: "No file",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
})).rejects.toThrow("must already exist");
|
||||
await expect(tools.preview_mogrt_recipe.handler({
|
||||
template_name: "Bad.",
|
||||
headline: "No file",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
})).rejects.toThrow("template_name");
|
||||
await expect(tools.preview_mogrt_recipe.handler({
|
||||
template_name: "Safe",
|
||||
headline: "No file",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
unexpected: true,
|
||||
})).rejects.toThrow("unsupported field");
|
||||
|
||||
const existingDirectory = getMogrtAuthoringTools(bridgeOptions, { directoryExists: () => true });
|
||||
await expect(existingDirectory.preview_mogrt_recipe.handler({
|
||||
recipe: "not_a_recipe",
|
||||
template_name: "Safe",
|
||||
headline: "No file",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
})).rejects.toThrow("recipe must be one of");
|
||||
await expect(existingDirectory.preview_mogrt_recipe.handler({
|
||||
template_name: "Safe",
|
||||
headline: "No file",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "/approved/templates",
|
||||
})).rejects.toThrow("same path format");
|
||||
await expect(existingDirectory.preview_mogrt_recipe.handler({
|
||||
template_name: "Safe",
|
||||
headline: "No file",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
frame_rate: 31,
|
||||
})).rejects.toThrow("frame_rate must be one of");
|
||||
});
|
||||
|
||||
it("requires confirmation, preserves bridge errors, and reports local artifact failures", async () => {
|
||||
let now = 0;
|
||||
const tools = getMogrtAuthoringTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
now: () => now,
|
||||
tokenFactory: () => "expiring-token",
|
||||
operationIdFactory: () => "operation-error",
|
||||
send: async () => ({ success: false, error: "connector unavailable" }),
|
||||
artifactStatus: () => ({ exists: true, size_bytes: 10, zip_header_valid: false }),
|
||||
});
|
||||
await tools.preview_mogrt_recipe.handler({
|
||||
template_name: "Safe",
|
||||
headline: "A headline",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
});
|
||||
await expect(tools.create_mogrt_recipe.handler({
|
||||
preview_token: "expiring-token",
|
||||
confirm_export: false,
|
||||
})).rejects.toThrow("confirm_export");
|
||||
await expect(tools.create_mogrt_recipe.handler({
|
||||
preview_token: "expiring-token",
|
||||
confirm_export: true,
|
||||
})).resolves.toMatchObject({ success: false, error: "connector unavailable (operation operation-error)" });
|
||||
|
||||
await expect(tools.verify_mogrt_artifact.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
})).resolves.toMatchObject({ success: false, error: "The artifact is not a recognizable ZIP-based .mogrt file" });
|
||||
|
||||
await tools.preview_mogrt_recipe.handler({
|
||||
template_name: "Expired",
|
||||
headline: "A headline",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
});
|
||||
now = 10 * 60 * 1000;
|
||||
await expect(tools.create_mogrt_recipe.handler({
|
||||
preview_token: "expiring-token",
|
||||
confirm_export: true,
|
||||
})).rejects.toThrow("expired");
|
||||
});
|
||||
});
|
||||
Executable
+485
@@ -0,0 +1,485 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getMogrtStudioTools } from "../../src/tools/mogrt-studio.js";
|
||||
|
||||
const bridgeOptions = { tempDir: "D:/PremiereBridge", timeoutMs: 5000 };
|
||||
const artifact = { exists: true, size_bytes: 64, zip_header_valid: true };
|
||||
|
||||
describe("MOGRT studio tools", () => {
|
||||
it("validates a contained brand kit and serially creates a bounded JSON batch", async () => {
|
||||
const afterEffectsScripts: string[] = [];
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate.endsWith("templates.json"),
|
||||
readText: () => JSON.stringify([
|
||||
{ recipe: "title_card", template_name: "Brand Intro", headline: "Hello" },
|
||||
{ recipe: "callout", template_name: "Brand Tip", headline: "Tip" },
|
||||
]),
|
||||
artifactStatus: () => artifact,
|
||||
tokenFactory: (() => { const tokens = ["batch-preview", "unused"]; return () => tokens.shift() ?? "token"; })(),
|
||||
operationIdFactory: () => "operation-1",
|
||||
sendAfterEffects: async (script) => {
|
||||
afterEffectsScripts.push(script);
|
||||
return { success: true, data: { exportRequested: true } };
|
||||
},
|
||||
});
|
||||
|
||||
await expect(tools.validate_mogrt_brand_kit.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
brand_kit: { name: "Brand", name_prefix: "Brand", font_family: "Inter", accent_color: "#112233", text_color: "#FFFFFF", safe_margin_percent: 0.12 },
|
||||
})).resolves.toMatchObject({ success: true, data: { brandKit: { name: "Brand", accent_color: "#112233", safe_margin_percent: 0.12 } } });
|
||||
|
||||
const preview = await tools.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "D:/Approved/data/templates.json",
|
||||
brand_kit: { name: "Brand", name_prefix: "Brand", accent_color: "#112233", safe_margin_percent: 0.1 },
|
||||
});
|
||||
expect(preview).toMatchObject({ success: true, data: { previewToken: "batch-preview", plans: [{ recipe: "title_card", accent_color: "#112233" }, { recipe: "callout", accent_color: "#112233" }] } });
|
||||
|
||||
await expect(tools.create_mogrt_batch.handler({ preview_token: "batch-preview", confirm_export: true })).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { completed: [{ templateName: "Brand Intro" }, { templateName: "Brand Tip" }], batchAtomic: false },
|
||||
});
|
||||
expect(afterEffectsScripts).toHaveLength(2);
|
||||
expect(afterEffectsScripts[0]).toContain('recipe: "title_card"');
|
||||
expect(afterEffectsScripts[1]).toContain('recipe: "callout"');
|
||||
});
|
||||
|
||||
it("publishes only the previewed artifact to a new immutable local-library version", async () => {
|
||||
const copied: Array<[string, string]> = [];
|
||||
const directories: string[] = [];
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate === "D:\\Approved\\templates\\Launch.mogrt",
|
||||
artifactStatus: () => artifact,
|
||||
tokenFactory: () => "library-preview",
|
||||
operationIdFactory: () => "operation-library",
|
||||
copyFile: (source, destination) => copied.push([source, destination]),
|
||||
makeDirectory: (candidate) => directories.push(candidate),
|
||||
});
|
||||
|
||||
const preview = await tools.preview_mogrt_library_publish.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
library_directory: "D:/Approved/library",
|
||||
template_name: "Launch",
|
||||
});
|
||||
expect(preview).toMatchObject({ success: true, data: { previewToken: "library-preview", version: "v001" } });
|
||||
|
||||
await expect(tools.publish_mogrt_to_library.handler({ preview_token: "library-preview", confirm_publish: true })).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { version: "v001", overwrite: false },
|
||||
});
|
||||
expect(directories[0]).toContain("v001");
|
||||
expect(copied[0]?.[1]).toContain("Launch.mogrt");
|
||||
});
|
||||
|
||||
it("requires a disposable named Premiere target and reports import rather than visual verification", async () => {
|
||||
const premiereScripts: string[] = [];
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
artifactStatus: () => artifact,
|
||||
tokenFactory: () => "handoff-preview",
|
||||
operationIdFactory: () => "operation-handoff",
|
||||
sendPremiere: async (script) => {
|
||||
premiereScripts.push(script);
|
||||
return { success: true, data: { imported: true, visualVerified: false, exposedControlDescriptors: [] } };
|
||||
},
|
||||
});
|
||||
|
||||
const preview = await tools.preview_mogrt_premiere_handoff.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
sequence_id: "sequence-1",
|
||||
disposable_sequence_name: "MOGRT Verify - Launch",
|
||||
});
|
||||
expect(preview).toMatchObject({ success: true, data: { previewToken: "handoff-preview" } });
|
||||
await expect(tools.apply_mogrt_premiere_handoff.handler({ preview_token: "handoff-preview", confirm_import: true })).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { visualVerified: false },
|
||||
});
|
||||
expect(premiereScripts[0]).toContain("MOGRT Verify - Launch");
|
||||
expect(premiereScripts[0]).toContain("titleTrack.clips.numItems !== 0");
|
||||
});
|
||||
|
||||
it("previews then queues an existing composition without starting a render", async () => {
|
||||
const scripts: string[] = [];
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: () => false,
|
||||
tokenFactory: () => "render-preview",
|
||||
operationIdFactory: () => "operation-render",
|
||||
sendAfterEffects: async (script) => {
|
||||
scripts.push(script);
|
||||
return { success: true, data: { queued: true, renderStarted: false } };
|
||||
},
|
||||
});
|
||||
const preview = await tools.preview_after_effects_render.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
composition_name: "Launch",
|
||||
output_path: "D:/Approved/renders/Launch.mov",
|
||||
render_settings_template: "Best Settings",
|
||||
output_module_template: "Lossless",
|
||||
});
|
||||
expect(preview).toMatchObject({ success: true, data: { previewToken: "render-preview" } });
|
||||
await expect(tools.enqueue_after_effects_render.handler({ preview_token: "render-preview", confirm_enqueue: true })).resolves.toMatchObject({ success: true, data: { renderStarted: false } });
|
||||
expect(scripts[0]).toContain("project.renderQueue.items.add(comp)");
|
||||
expect(scripts[0]).toContain("renderStarted: false");
|
||||
});
|
||||
|
||||
it("uses AE controller-name readback only when the host exposes that API", async () => {
|
||||
const scripts: string[] = [];
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
operationIdFactory: () => "operation-inspect",
|
||||
sendAfterEffects: async (script) => {
|
||||
scripts.push(script);
|
||||
return { success: true, data: { exposedControlsReadbackAvailable: true, exposedControlNames: ["Headline"] } };
|
||||
},
|
||||
});
|
||||
await expect(tools.inspect_after_effects_template_source.handler({ composition_name: "Launch" })).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { exposedControlsReadbackAvailable: true, exposedControlNames: ["Headline"] },
|
||||
});
|
||||
expect(scripts[0]).toContain("motionGraphicsTemplateControllerCount");
|
||||
expect(scripts[0]).toContain("getMotionGraphicsTemplateControllerName");
|
||||
});
|
||||
|
||||
it("rejects unsafe batch input and returns partial progress without promising rollback", async () => {
|
||||
const sent: string[] = [];
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate.endsWith("batch.json"),
|
||||
readText: () => JSON.stringify([
|
||||
{ recipe: "title_card", template_name: "First", headline: "One" },
|
||||
{ recipe: "quote_card", template_name: "Second", headline: "Two" },
|
||||
]),
|
||||
tokenFactory: () => "batch-token",
|
||||
operationIdFactory: () => "operation-batch-failure",
|
||||
artifactStatus: () => artifact,
|
||||
sendAfterEffects: async (script) => {
|
||||
sent.push(script);
|
||||
return sent.length === 1
|
||||
? { success: true, data: { exportRequested: true } }
|
||||
: { success: false, error: "After Effects refused the second export" };
|
||||
},
|
||||
});
|
||||
|
||||
await expect(tools.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "D:/Approved/data/batch.txt",
|
||||
})).rejects.toThrow(".json or .csv");
|
||||
await expect(tools.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "D:/Approved/data/missing.json",
|
||||
})).rejects.toThrow("does not exist");
|
||||
|
||||
await tools.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "D:/Approved/data/batch.json",
|
||||
});
|
||||
await expect(tools.create_mogrt_batch.handler({ preview_token: "batch-token", confirm_export: false })).rejects.toThrow("confirm_export must be true");
|
||||
await expect(tools.create_mogrt_batch.handler({ preview_token: "batch-token", confirm_export: true })).resolves.toMatchObject({
|
||||
success: false,
|
||||
data: { completed: [{ templateName: "First" }], failedTemplate: "Second", batchAtomic: false },
|
||||
});
|
||||
await expect(tools.create_mogrt_batch.handler({ preview_token: "batch-token", confirm_export: true })).rejects.toThrow("already used");
|
||||
});
|
||||
|
||||
it("parses bounded CSV batches and rejects duplicate output artifacts", async () => {
|
||||
const csvTools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate.endsWith("batch.csv"),
|
||||
readText: () => "recipe,template_name,headline,duration_seconds\nquote_card,Quote,Hello,7\n",
|
||||
tokenFactory: () => "csv-preview",
|
||||
operationIdFactory: () => "operation-csv",
|
||||
});
|
||||
await expect(csvTools.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "D:/Approved/data/batch.csv",
|
||||
})).resolves.toMatchObject({ success: true, data: { plans: [{ recipe: "quote_card", duration_seconds: 7 }] } });
|
||||
|
||||
const duplicateTools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate.endsWith("duplicate.json"),
|
||||
readText: () => JSON.stringify([
|
||||
{ template_name: "Same", headline: "One" },
|
||||
{ template_name: "Same", headline: "Two" },
|
||||
]),
|
||||
});
|
||||
await expect(duplicateTools.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "D:/Approved/data/duplicate.json",
|
||||
})).rejects.toThrow("unique template_name");
|
||||
});
|
||||
|
||||
it("keeps library publishing immutable when the source or destination changes", async () => {
|
||||
let sourceIsValid = true;
|
||||
let destinationExists = false;
|
||||
const tokens = ["library-one", "library-two"];
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate === "D:\\Approved\\templates\\Launch.mogrt" || destinationExists,
|
||||
artifactStatus: () => ({ ...artifact, zip_header_valid: sourceIsValid }),
|
||||
tokenFactory: () => tokens.shift() ?? "library-extra",
|
||||
operationIdFactory: () => "operation-library-race",
|
||||
});
|
||||
const request = {
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
library_directory: "D:/Approved/library",
|
||||
template_name: "Launch",
|
||||
};
|
||||
await tools.preview_mogrt_library_publish.handler(request);
|
||||
sourceIsValid = false;
|
||||
await expect(tools.publish_mogrt_to_library.handler({ preview_token: "library-one", confirm_publish: true })).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("no longer a recognizable ZIP"),
|
||||
});
|
||||
|
||||
sourceIsValid = true;
|
||||
await tools.preview_mogrt_library_publish.handler(request);
|
||||
destinationExists = true;
|
||||
await expect(tools.publish_mogrt_to_library.handler({ preview_token: "library-two", confirm_publish: true })).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("destination now exists"),
|
||||
});
|
||||
});
|
||||
|
||||
it("lists an existing library but never treats a missing root as a publishable target", async () => {
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: (candidate) => !candidate.endsWith("missing") && !candidate.endsWith("Flat"),
|
||||
listDirectory: (candidate) => candidate.endsWith("library") ? ["Launch", "Flat"] : ["v001", "notes", "v002"],
|
||||
operationIdFactory: () => "operation-library-inspect",
|
||||
});
|
||||
await expect(tools.inspect_mogrt_library.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
library_directory: "D:/Approved/missing",
|
||||
})).resolves.toMatchObject({ success: false, error: "library_directory does not exist" });
|
||||
await expect(tools.inspect_mogrt_library.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
library_directory: "D:/Approved/library",
|
||||
})).resolves.toMatchObject({ success: true, data: { templates: [{ name: "Launch", versions: ["v001", "v002"] }, { name: "Flat", versions: [] }] } });
|
||||
});
|
||||
|
||||
it("requires confirmation and reports host failures for render enqueueing", async () => {
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: () => false,
|
||||
tokenFactory: () => "render-token",
|
||||
operationIdFactory: () => "operation-render-failure",
|
||||
sendAfterEffects: async () => ({ success: false, error: "No matching composition" }),
|
||||
});
|
||||
const request = {
|
||||
approved_workspace_path: "D:/Approved",
|
||||
composition_name: "Launch",
|
||||
output_path: "D:/Approved/renders/Launch.mov",
|
||||
render_settings_template: "Best Settings",
|
||||
output_module_template: "Lossless",
|
||||
};
|
||||
await tools.preview_after_effects_render.handler(request);
|
||||
await expect(tools.enqueue_after_effects_render.handler({ preview_token: "render-token", confirm_enqueue: false })).rejects.toThrow("confirm_enqueue must be true");
|
||||
await expect(tools.enqueue_after_effects_render.handler({ preview_token: "render-token", confirm_enqueue: true })).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("No matching composition"),
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a handoff artifact changes or the disposable target is invalid", async () => {
|
||||
let artifactIsValid = true;
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
artifactStatus: () => ({ ...artifact, zip_header_valid: artifactIsValid }),
|
||||
tokenFactory: () => "handoff-token",
|
||||
operationIdFactory: () => "operation-handoff-failure",
|
||||
});
|
||||
const base = {
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
sequence_id: "sequence-1",
|
||||
};
|
||||
await expect(tools.preview_mogrt_premiere_handoff.handler({ ...base, disposable_sequence_name: "Launch" })).rejects.toThrow("must begin");
|
||||
await tools.preview_mogrt_premiere_handoff.handler({ ...base, disposable_sequence_name: "MOGRT Verify - Launch" });
|
||||
await expect(tools.apply_mogrt_premiere_handoff.handler({ preview_token: "handoff-token", confirm_import: false })).rejects.toThrow("confirm_import must be true");
|
||||
artifactIsValid = false;
|
||||
await expect(tools.apply_mogrt_premiere_handoff.handler({ preview_token: "handoff-token", confirm_import: true })).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("no longer a recognizable ZIP"),
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves inspection failure boundaries without creating render queue items", async () => {
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
operationIdFactory: () => "operation-inspection-failure",
|
||||
sendAfterEffects: async () => ({ success: false, error: "No open After Effects project" }),
|
||||
});
|
||||
await expect(tools.inspect_after_effects_template_source.handler({ composition_name: "Launch" })).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("No open After Effects project"),
|
||||
});
|
||||
await expect(tools.inspect_after_effects_render_templates.handler()).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("No open After Effects project"),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed batch files and accepts escaped CSV values without changing a project", async () => {
|
||||
const preview = (contents: string) => getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate.endsWith("rows.csv"),
|
||||
readText: () => contents,
|
||||
tokenFactory: () => "csv-token",
|
||||
}).preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "D:/Approved/data/rows.csv",
|
||||
});
|
||||
|
||||
await expect(preview("recipe,template_name,headline\r\nquote_card,Quote,\"Hello, \"\"world\"\"\"\r\n")).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { plans: [{ headline: "Hello, \"world\"" }] },
|
||||
});
|
||||
await expect(preview("recipe,unsupported\nquote_card,nope\n")).rejects.toThrow("unsupported column");
|
||||
await expect(preview("recipe,template_name,headline\n\"quote_card,Quote,broken")).rejects.toThrow("unterminated quoted field");
|
||||
await expect(preview("recipe,template_name,headline\n")).rejects.toThrow("header row and at least one template row");
|
||||
});
|
||||
|
||||
it("rejects malformed arguments, cross-format paths, and out-of-range handoff coordinates", async () => {
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate.endsWith("rows.json"),
|
||||
readText: () => "{}",
|
||||
artifactStatus: () => artifact,
|
||||
});
|
||||
await expect(tools.preview_mogrt_batch.handler(null)).rejects.toThrow("arguments must be an object");
|
||||
await expect(tools.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "relative",
|
||||
output_directory: "relative/templates",
|
||||
data_file_path: "relative/rows.json",
|
||||
})).rejects.toThrow("Path must be absolute");
|
||||
await expect(tools.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "/Approved/data/rows.json",
|
||||
})).rejects.toThrow("same path format");
|
||||
|
||||
const handoff = {
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
sequence_id: "sequence-1",
|
||||
disposable_sequence_name: "MOGRT Verify - Launch",
|
||||
};
|
||||
await expect(tools.preview_mogrt_premiere_handoff.handler({ ...handoff, mogrt_path: "D:/Approved/templates/Launch.txt" })).rejects.toThrow("must end in .mogrt");
|
||||
await expect(tools.preview_mogrt_premiere_handoff.handler({ ...handoff, video_track_index: 1.5 })).rejects.toThrow("must be an integer");
|
||||
await expect(tools.preview_mogrt_premiere_handoff.handler({ ...handoff, start_seconds: -1 })).rejects.toThrow("must be a finite number");
|
||||
});
|
||||
|
||||
it("rejects missing render and library roots before issuing a confirmation token", async () => {
|
||||
const renderTools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => false,
|
||||
fileExists: () => false,
|
||||
});
|
||||
await expect(renderTools.preview_after_effects_render.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
composition_name: "Launch",
|
||||
output_path: "D:/Approved/renders/Launch.mov",
|
||||
render_settings_template: "Best Settings",
|
||||
output_module_template: "Lossless",
|
||||
})).rejects.toThrow("parent directory must already exist");
|
||||
|
||||
const libraryTools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => false,
|
||||
fileExists: (candidate) => candidate === "D:\\Approved\\templates\\Launch.mogrt",
|
||||
artifactStatus: () => artifact,
|
||||
});
|
||||
await expect(libraryTools.preview_mogrt_library_publish.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
library_directory: "D:/Approved/library",
|
||||
template_name: "Launch",
|
||||
})).rejects.toThrow("will not create a new library root");
|
||||
});
|
||||
|
||||
it("returns host errors after consuming only a valid Premiere handoff preview", async () => {
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
artifactStatus: () => artifact,
|
||||
tokenFactory: () => "handoff-host-error",
|
||||
operationIdFactory: () => "operation-handoff-host-error",
|
||||
sendPremiere: async () => ({ success: false, error: "The verification track is occupied" }),
|
||||
sendAfterEffects: async () => ({ success: true, data: { readOnly: true } }),
|
||||
});
|
||||
await expect(tools.inspect_after_effects_template_source.handler()).resolves.toMatchObject({ success: true, data: { readOnly: true } });
|
||||
await expect(tools.inspect_after_effects_render_templates.handler()).resolves.toMatchObject({ success: true, data: { readOnly: true } });
|
||||
await tools.preview_mogrt_premiere_handoff.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
sequence_id: "sequence-1",
|
||||
disposable_sequence_name: "MOGRT Verify - Launch",
|
||||
video_track_index: 2,
|
||||
audio_track_index: 1,
|
||||
start_seconds: 12.5,
|
||||
});
|
||||
await expect(tools.apply_mogrt_premiere_handoff.handler({ preview_token: "handoff-host-error", confirm_import: true })).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("verification track is occupied"),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed authoring boundaries and expires abandoned render previews", async () => {
|
||||
const guarded = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate.endsWith("logo.png") || candidate.endsWith("Existing.mov"),
|
||||
readText: () => "[]",
|
||||
artifactStatus: () => ({ exists: false, size_bytes: null, zip_header_valid: false }),
|
||||
});
|
||||
await expect(guarded.validate_mogrt_brand_kit.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
brand_kit: { name: "Brand", logo_path: "D:/Approved/logo.png" },
|
||||
})).resolves.toMatchObject({ success: true, data: { logoPathExists: true } });
|
||||
await expect(guarded.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "D:/Approved/data/rows.json",
|
||||
unsupported: true,
|
||||
})).rejects.toThrow("unsupported field");
|
||||
await expect(guarded.preview_after_effects_render.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
composition_name: "Bad.",
|
||||
output_path: "D:/Approved/renders/New.mov",
|
||||
render_settings_template: "Best Settings",
|
||||
output_module_template: "Lossless",
|
||||
})).rejects.toThrow("composition_name must use");
|
||||
await expect(guarded.preview_after_effects_render.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
composition_name: "Existing",
|
||||
output_path: "D:/Approved/renders/Existing.mov",
|
||||
render_settings_template: "Best Settings",
|
||||
output_module_template: "Lossless",
|
||||
})).rejects.toThrow("already exists");
|
||||
await expect(guarded.preview_mogrt_premiere_handoff.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Invalid.mogrt",
|
||||
sequence_id: "sequence-1",
|
||||
disposable_sequence_name: "MOGRT Verify - Invalid",
|
||||
})).rejects.toThrow("must exist and have a ZIP header");
|
||||
|
||||
let now = 0;
|
||||
const expiring = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: () => false,
|
||||
tokenFactory: () => "expired-render",
|
||||
now: () => now,
|
||||
});
|
||||
await expiring.preview_after_effects_render.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
composition_name: "Launch",
|
||||
output_path: "D:/Approved/renders/Launch.mov",
|
||||
render_settings_template: "Best Settings",
|
||||
output_module_template: "Lossless",
|
||||
});
|
||||
now = 600_000;
|
||||
await expect(expiring.enqueue_after_effects_render.handler({ preview_token: "expired-render", confirm_enqueue: true })).rejects.toThrow("expired");
|
||||
});
|
||||
});
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockedExecFile, mockedExecFileAsync } = vi.hoisted(() => {
|
||||
const mockedExecFileAsync = vi.fn();
|
||||
const mockedExecFile = vi.fn();
|
||||
mockedExecFile[Symbol.for("nodejs.util.promisify.custom")] = mockedExecFileAsync;
|
||||
return { mockedExecFile, mockedExecFileAsync };
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({ execFile: mockedExecFile }));
|
||||
|
||||
import { getAudioTools } from "../../src/tools/audio.js";
|
||||
|
||||
const tool = getAudioTools({ tempDir: "/tmp/test" }).normalize_loudness_file;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function fixture() {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-normalize-"));
|
||||
const inputPath = join(directory, "input.wav");
|
||||
const outputPath = join(directory, "normalized.wav");
|
||||
writeFileSync(inputPath, "input-audio");
|
||||
return { inputPath, outputPath };
|
||||
}
|
||||
|
||||
function measuredSummary(integratedLufs = -16.2, truePeakDbfs = -1.5) {
|
||||
return `Summary:\n Integrated loudness:\n I: ${integratedLufs} LUFS\n Loudness range:\n LRA: 4.1 LU\n True peak:\n Peak: ${truePeakDbfs} dBFS`;
|
||||
}
|
||||
|
||||
describe("normalize_loudness_file safety boundary", () => {
|
||||
it("rejects invalid standards values before filesystem or FFmpeg work", async () => {
|
||||
await expect(tool.handler({ input_path: "in.wav", output_path: "out.wav", target_lufs: 0 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("target_lufs") });
|
||||
await expect(tool.handler({ input_path: "in.wav", output_path: "out.wav", max_true_peak_dbfs: 2 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("max_true_peak") });
|
||||
});
|
||||
|
||||
it("never permits in-place normalization", async () => {
|
||||
await expect(tool.handler({ input_path: "same.wav", output_path: "same.wav" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("differ") });
|
||||
});
|
||||
|
||||
it("fails before FFmpeg when the input is missing", async () => {
|
||||
await expect(tool.handler({ input_path: "missing.wav", output_path: "new.wav" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("not found") });
|
||||
});
|
||||
|
||||
it("writes a new derivative and remeasures that exact output", async () => {
|
||||
const { inputPath, outputPath } = fixture();
|
||||
mockedExecFileAsync.mockImplementationOnce(async () => {
|
||||
writeFileSync(outputPath, "normalized-audio");
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: measuredSummary() });
|
||||
|
||||
await expect(tool.handler({
|
||||
input_path: inputPath,
|
||||
output_path: outputPath,
|
||||
target_lufs: -16,
|
||||
max_true_peak_dbfs: -1,
|
||||
tolerance_lu: 0.5,
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
inputPath,
|
||||
outputPath,
|
||||
verified: true,
|
||||
loudnessPass: true,
|
||||
truePeakPass: true,
|
||||
},
|
||||
});
|
||||
expect(mockedExecFileAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports a new but out-of-target derivative without claiming verification", async () => {
|
||||
const { inputPath, outputPath } = fixture();
|
||||
mockedExecFileAsync.mockImplementationOnce(async () => {
|
||||
writeFileSync(outputPath, "normalized-audio");
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: measuredSummary(-12, -0.5) });
|
||||
|
||||
await expect(tool.handler({
|
||||
input_path: inputPath,
|
||||
output_path: outputPath,
|
||||
target_lufs: -16,
|
||||
max_true_peak_dbfs: -1,
|
||||
tolerance_lu: 0.5,
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { verified: false, loudnessPass: false, truePeakPass: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("makes missing tools, timeouts, failed writes, and unverifiable outputs explicit", async () => {
|
||||
const missingTool = fixture();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
await expect(tool.handler({ input_path: missingTool.inputPath, output_path: missingTool.outputPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("not found on PATH") });
|
||||
|
||||
const timeout = fixture();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("timeout"), { killed: true }));
|
||||
await expect(tool.handler({ input_path: timeout.inputPath, output_path: timeout.outputPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("timed out") });
|
||||
|
||||
const noOutput = fixture();
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||
await expect(tool.handler({ input_path: noOutput.inputPath, output_path: noOutput.outputPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("no non-empty output") });
|
||||
|
||||
const noMeasurement = fixture();
|
||||
mockedExecFileAsync.mockImplementationOnce(async () => {
|
||||
writeFileSync(noMeasurement.outputPath, "normalized-audio");
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: measuredSummary(Number.NEGATIVE_INFINITY) });
|
||||
await expect(tool.handler({ input_path: noMeasurement.inputPath, output_path: noMeasurement.outputPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("not measurable") });
|
||||
});
|
||||
|
||||
it("rejects unsafe destinations and preserves an FFmpeg failure detail", async () => {
|
||||
const unsafe = fixture();
|
||||
await expect(tool.handler({ input_path: unsafe.inputPath, output_path: unsafe.outputPath, tolerance_lu: -1 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("tolerance_lu") });
|
||||
|
||||
writeFileSync(unsafe.outputPath, "do-not-overwrite");
|
||||
await expect(tool.handler({ input_path: unsafe.inputPath, output_path: unsafe.outputPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("will not be overwritten") });
|
||||
|
||||
const failure = fixture();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("decode failed"), { stderr: "bad audio stream" }));
|
||||
await expect(tool.handler({ input_path: failure.inputPath, output_path: failure.outputPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("bad audio stream") });
|
||||
});
|
||||
});
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getProjectIntakeTools, projectIntakeCaptureScript } from "../../src/tools/project-intake.js";
|
||||
|
||||
const template = {
|
||||
schemaVersion: 1,
|
||||
id: "facility-intake",
|
||||
version: "1",
|
||||
requiredBins: [{ name: "Incoming" }],
|
||||
allowedExtensions: ["mov"],
|
||||
allowedFrameRates: [],
|
||||
proxyPolicy: "ignore",
|
||||
approvedPathPrefixes: [],
|
||||
requiredEvidence: [],
|
||||
organizationRules: [{ id: "camera", destinationBinName: "Camera", match: { extensions: ["mov"] } }],
|
||||
};
|
||||
|
||||
describe("preview_project_intake tool", () => {
|
||||
it("returns a bounded read-only report without requiring an active sequence", async () => {
|
||||
const captureSnapshot = vi.fn(async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
project: { id: "project-1", name: "Feature" },
|
||||
items: [
|
||||
{ id: "bin-1", name: "Incoming", type: "bin", parentId: "root", treePath: "Incoming" },
|
||||
{ id: "clip-1", name: "A001.mov", type: "clip", parentId: "bin-1", mediaPath: "D:/Secret/A001.mov", offline: false },
|
||||
],
|
||||
truncated: false,
|
||||
unavailableEvidence: [],
|
||||
},
|
||||
}));
|
||||
const tools = getProjectIntakeTools({}, { captureSnapshot });
|
||||
const result = await tools.preview_project_intake.handler({ template, max_items: 25 });
|
||||
|
||||
expect(captureSnapshot).toHaveBeenCalledWith(25);
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
applied: false,
|
||||
capture: { pathDisclosure: "redacted" },
|
||||
organizationPlan: { applied: false, proposedMoves: [{ projectItemId: "clip-1" }] },
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("D:/Secret");
|
||||
});
|
||||
|
||||
it("does not abort the preview when one host item has malformed frame-rate evidence", async () => {
|
||||
const captureSnapshot = vi.fn(async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
project: { id: "project-1", name: "Feature" },
|
||||
items: [{ id: "clip-1", name: "A001.mov", type: "clip", frameRate: null }],
|
||||
truncated: false,
|
||||
unavailableEvidence: [],
|
||||
},
|
||||
}));
|
||||
const tools = getProjectIntakeTools({}, { captureSnapshot });
|
||||
const result = await tools.preview_project_intake.handler({
|
||||
template: { ...template, allowedFrameRates: [29.97], requiredEvidence: ["frame_rate"] },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
status: "incomplete",
|
||||
findings: expect.arrayContaining([
|
||||
expect.objectContaining({ code: "FRAME_RATE_UNSUPPORTED", itemId: "clip-1" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates bridge failures and rejects invalid bounds before capture", async () => {
|
||||
const captureSnapshot = vi.fn(async () => ({ success: false, error: "No project is open" }));
|
||||
const tools = getProjectIntakeTools({}, { captureSnapshot });
|
||||
|
||||
await expect(tools.preview_project_intake.handler({ template })).resolves.toEqual({ success: false, error: "No project is open" });
|
||||
await expect(tools.preview_project_intake.handler({ template, max_items: 0 })).resolves.toMatchObject({ success: false, error: expect.stringContaining("max_items") });
|
||||
expect(captureSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("builds a read-only bounded ExtendScript capture", () => {
|
||||
const script = projectIntakeCaptureScript(17);
|
||||
expect(script).toContain("var maximumItems = 17");
|
||||
expect(script).toContain("No project is open");
|
||||
expect(script).toContain("items.length >= maximumItems");
|
||||
expect(script).not.toContain("activeSequence");
|
||||
expect(script).not.toMatch(/\.moveBin\(|\.createBin\(|\.deleteBin\(/);
|
||||
});
|
||||
});
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fsFaults = vi.hoisted(() => ({
|
||||
inaccessibleDirectories: new Set<string>(),
|
||||
vanishedFiles: new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
readdirSync: (...args: Parameters<typeof actual.readdirSync>) => {
|
||||
if (fsFaults.inaccessibleDirectories.has(resolve(String(args[0])))) {
|
||||
const error = Object.assign(new Error("permission denied"), { code: "EACCES" });
|
||||
throw error;
|
||||
}
|
||||
return actual.readdirSync(...args);
|
||||
},
|
||||
statSync: (...args: Parameters<typeof actual.statSync>) => {
|
||||
if (fsFaults.vanishedFiles.has(resolve(String(args[0])))) {
|
||||
const error = Object.assign(new Error("file disappeared"), { code: "ENOENT" });
|
||||
throw error;
|
||||
}
|
||||
return actual.statSync(...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../src/bridge/file-bridge.js")>();
|
||||
return { ...actual, sendCommand: vi.fn() };
|
||||
});
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import {
|
||||
collectBridgeTelemetry,
|
||||
discoverAdjacentRecoveryCandidates,
|
||||
getRecoveryTools,
|
||||
} from "../../src/tools/recovery.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
function temporaryDirectory(): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-recovery-edge-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
fsFaults.inaccessibleDirectories.clear();
|
||||
fsFaults.vanishedFiles.clear();
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("recovery discovery filesystem edges", () => {
|
||||
it("keeps an autosave for a missing project path but marks its project timestamp as unknown", () => {
|
||||
const directory = temporaryDirectory();
|
||||
const missingProject = join(directory, "Rough Cut.prproj");
|
||||
const autosaveDirectory = join(directory, "Premiere Pro Auto-Save");
|
||||
mkdirSync(autosaveDirectory);
|
||||
const autosave = join(autosaveDirectory, "Rough Cut backup.PRPROJ");
|
||||
writeFileSync(autosave, "autosave");
|
||||
mkdirSync(join(autosaveDirectory, "Rough Cut folder.prproj"));
|
||||
|
||||
const candidates = discoverAdjacentRecoveryCandidates(missingProject);
|
||||
|
||||
expect(candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
path: autosave,
|
||||
fileName: "Rough Cut backup.PRPROJ",
|
||||
newerThanProjectFile: null,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits inaccessible autosave directories and candidates that disappear during inspection", () => {
|
||||
const directory = temporaryDirectory();
|
||||
const project = join(directory, "Episode.prproj");
|
||||
writeFileSync(project, "project");
|
||||
const inaccessibleAutosaves = join(directory, "Adobe Premiere Pro Auto-Save");
|
||||
mkdirSync(inaccessibleAutosaves);
|
||||
writeFileSync(join(inaccessibleAutosaves, "Episode inaccessible.prproj"), "autosave");
|
||||
fsFaults.inaccessibleDirectories.add(resolve(inaccessibleAutosaves));
|
||||
const vanishing = join(directory, "Episode vanishing.prproj");
|
||||
writeFileSync(vanishing, "autosave");
|
||||
fsFaults.vanishedFiles.add(resolve(vanishing));
|
||||
|
||||
expect(discoverAdjacentRecoveryCandidates(project)).toEqual([]);
|
||||
});
|
||||
|
||||
it("bounds discovery to fifty candidates even when more matching autosaves exist", () => {
|
||||
const directory = temporaryDirectory();
|
||||
const project = join(directory, "Assembly.prproj");
|
||||
writeFileSync(project, "project");
|
||||
const autosaveDirectory = join(directory, "Adobe Premiere Pro Auto-Save");
|
||||
mkdirSync(autosaveDirectory);
|
||||
for (let index = 0; index < 51; index++) {
|
||||
const candidate = join(autosaveDirectory, `Assembly-${String(index).padStart(2, "0")}.prproj`);
|
||||
writeFileSync(candidate, String(index));
|
||||
utimesSync(candidate, new Date(1_000 + index), new Date(1_000 + index));
|
||||
}
|
||||
|
||||
const candidates = discoverAdjacentRecoveryCandidates(project);
|
||||
expect(candidates).toHaveLength(50);
|
||||
expect(candidates[0].modifiedMs).toBeGreaterThanOrEqual(candidates.at(-1)!.modifiedMs);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bridge telemetry filesystem edges", () => {
|
||||
it("remains useful when an individual pending file disappears", () => {
|
||||
const directory = temporaryDirectory();
|
||||
const pending = join(directory, "cmd_transient.jsx");
|
||||
writeFileSync(pending, "private script");
|
||||
fsFaults.vanishedFiles.add(resolve(pending));
|
||||
|
||||
expect(collectBridgeTelemetry({ tempDir: directory }, 50_000)).toMatchObject({
|
||||
directoryAccessible: true,
|
||||
pendingCommands: 1,
|
||||
pendingResponses: 0,
|
||||
busyOperations: 0,
|
||||
oldestPendingAgeMs: null,
|
||||
healthy: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an inaccessible bridge directory as unhealthy without exposing its details", async () => {
|
||||
const directory = temporaryDirectory();
|
||||
fsFaults.inaccessibleDirectories.add(resolve(directory));
|
||||
const tools = getRecoveryTools({ tempDir: directory });
|
||||
|
||||
const result = await tools.get_bridge_telemetry.handler();
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
directoryAccessible: false,
|
||||
pendingCommands: 0,
|
||||
pendingResponses: 0,
|
||||
busyOperations: 0,
|
||||
healthy: false,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain(directory);
|
||||
});
|
||||
|
||||
it("marks an old response unhealthy while ignoring unrelated bridge files", () => {
|
||||
const directory = temporaryDirectory();
|
||||
const response = join(directory, "res_old.json");
|
||||
writeFileSync(response, "{}");
|
||||
writeFileSync(join(directory, "not-a-queue-file.txt"), "private");
|
||||
utimesSync(response, new Date(1_000), new Date(1_000));
|
||||
|
||||
expect(collectBridgeTelemetry({ tempDir: directory }, 32_000)).toMatchObject({
|
||||
directoryAccessible: true,
|
||||
pendingCommands: 0,
|
||||
pendingResponses: 1,
|
||||
busyOperations: 0,
|
||||
oldestPendingAgeMs: 31_000,
|
||||
healthy: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("recovery inspection snapshots", () => {
|
||||
it.each([
|
||||
[null],
|
||||
[{}],
|
||||
[{ name: "Project", path: 17 }],
|
||||
[{ name: 17, path: "project.prproj" }],
|
||||
])("rejects an invalid Premiere snapshot: %j", async (data) => {
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: true, data });
|
||||
const result = await getRecoveryTools({ tempDir: temporaryDirectory() }).inspect_project_recovery.handler();
|
||||
expect(result).toEqual({ success: false, error: "Premiere returned an invalid project snapshot" });
|
||||
});
|
||||
|
||||
it("preserves bridge failures instead of attempting local recovery discovery", async () => {
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: false, error: "No project is open" });
|
||||
await expect(getRecoveryTools({ tempDir: temporaryDirectory() }).inspect_project_recovery.handler())
|
||||
.resolves.toEqual({ success: false, error: "No project is open" });
|
||||
});
|
||||
|
||||
it("explains the unsaved-project boundary and missing saved project boundary distinctly", async () => {
|
||||
const tools = getRecoveryTools({ tempDir: temporaryDirectory() });
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { name: "Untitled", path: "" } });
|
||||
await expect(tools.inspect_project_recovery.handler()).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
project: { name: "Untitled", path: null, hasSavedPath: false, fileExists: false },
|
||||
recovery: {
|
||||
candidateCount: 0,
|
||||
guidance: expect.stringContaining("Save the project"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const missingProject = join(temporaryDirectory(), "missing.prproj");
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { name: "Missing", path: missingProject } });
|
||||
await expect(tools.inspect_project_recovery.handler()).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
project: { name: "Missing", path: missingProject, hasSavedPath: true, fileExists: false },
|
||||
recovery: {
|
||||
candidateCount: 0,
|
||||
guidance: expect.stringContaining("No adjacent .prproj"),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../src/bridge/file-bridge.js")>();
|
||||
return { ...actual, sendCommand: vi.fn() };
|
||||
});
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import {
|
||||
collectBridgeTelemetry,
|
||||
discoverAdjacentRecoveryCandidates,
|
||||
getRecoveryTools,
|
||||
} from "../../src/tools/recovery.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
function temporaryDirectory() {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-recovery-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("recovery candidate discovery", () => {
|
||||
it("finds matching adjacent autosaves newest-first without modifying them", () => {
|
||||
const directory = temporaryDirectory();
|
||||
const project = join(directory, "Episode 01.prproj");
|
||||
writeFileSync(project, "project");
|
||||
const autosaveDirectory = join(directory, "Adobe Premiere Pro Auto-Save");
|
||||
mkdirSync(autosaveDirectory);
|
||||
const older = join(autosaveDirectory, "Episode 01-1.prproj");
|
||||
const newer = join(autosaveDirectory, "Episode 01-2.prproj");
|
||||
const unrelated = join(autosaveDirectory, "Other Project.prproj");
|
||||
writeFileSync(older, "old");
|
||||
writeFileSync(newer, "new");
|
||||
writeFileSync(unrelated, "unrelated");
|
||||
utimesSync(project, new Date(1_000), new Date(1_000));
|
||||
utimesSync(older, new Date(2_000), new Date(2_000));
|
||||
utimesSync(newer, new Date(3_000), new Date(3_000));
|
||||
|
||||
const candidates = discoverAdjacentRecoveryCandidates(project);
|
||||
expect(candidates.map((item) => item.fileName)).toEqual([
|
||||
"Episode 01-2.prproj",
|
||||
"Episode 01-1.prproj",
|
||||
]);
|
||||
expect(candidates.every((item) => item.newerThanProjectFile)).toBe(true);
|
||||
expect(readFileSync(newer, "utf8")).toBe("new");
|
||||
});
|
||||
|
||||
it("does not scan when no saved Premiere project path exists", () => {
|
||||
expect(discoverAdjacentRecoveryCandidates("")).toEqual([]);
|
||||
expect(discoverAdjacentRecoveryCandidates("/tmp/not-a-project.txt")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("privacy-preserving bridge telemetry", () => {
|
||||
it("does not report a quiet directory as healthy when no CEP heartbeat is present", () => {
|
||||
const directory = temporaryDirectory();
|
||||
expect(collectBridgeTelemetry({ tempDir: directory })).toMatchObject({
|
||||
directoryAccessible: true,
|
||||
heartbeat: { state: "unknown" },
|
||||
healthy: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns aggregate state without paths, filenames, or contents", () => {
|
||||
const directory = temporaryDirectory();
|
||||
writeFileSync(join(directory, "cmd_secret.jsx"), "private project script");
|
||||
writeFileSync(join(directory, "res_secret.json"), '{"project":"Private"}');
|
||||
writeFileSync(join(directory, "busy_secret.json"), "{}");
|
||||
writeFileSync(join(directory, "bridge-heartbeat.json"), JSON.stringify({
|
||||
protocolVersion: 1,
|
||||
state: "running",
|
||||
}));
|
||||
writeFileSync(join(directory, "helpers_ignored.jsx"), "helper");
|
||||
|
||||
const telemetry = collectBridgeTelemetry(
|
||||
{ tempDir: directory },
|
||||
Date.now() + 100,
|
||||
);
|
||||
expect(telemetry).toMatchObject({
|
||||
directoryAccessible: true,
|
||||
pendingCommands: 1,
|
||||
pendingResponses: 1,
|
||||
busyOperations: 1,
|
||||
heartbeat: { state: "running" },
|
||||
healthy: false,
|
||||
});
|
||||
const serialized = JSON.stringify(telemetry);
|
||||
expect(serialized).not.toContain(directory);
|
||||
expect(serialized).not.toContain("secret");
|
||||
expect(serialized).not.toContain("Private");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recovery tools", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("reports the unavailable dirty flag and never emits restore code", async () => {
|
||||
const directory = temporaryDirectory();
|
||||
const project = join(directory, "Documentary.prproj");
|
||||
writeFileSync(project, "project");
|
||||
mockedSendCommand.mockResolvedValue({
|
||||
success: true,
|
||||
data: { name: "Documentary", path: project },
|
||||
});
|
||||
const tools = getRecoveryTools({ tempDir: join(directory, "bridge") });
|
||||
const result = await tools.inspect_project_recovery.handler();
|
||||
expect(result.success).toBe(true);
|
||||
expect((result.data as any).unsavedChanges).toMatchObject({
|
||||
status: "not_exposed",
|
||||
dirty: null,
|
||||
});
|
||||
expect((result.data as any).recovery.automaticRestoreSupported).toBe(false);
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).not.toContain("openDocument");
|
||||
expect(script).not.toContain("openProject");
|
||||
expect(script).not.toContain(".save");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bridge panel accessibility", () => {
|
||||
it("uses semantic live regions, labels, keyboard focus, and forced-colors CSS", () => {
|
||||
const cepHtml = readFileSync(
|
||||
new URL("../../cep-plugin/index.html", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const cepCss = readFileSync(
|
||||
new URL("../../cep-plugin/styles.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const uxpHtml = readFileSync(
|
||||
new URL("../../uxp-plugin/index.html", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
expect(cepHtml).toContain('role="status"');
|
||||
expect(cepHtml).toContain('aria-label="Bridge activity log"');
|
||||
expect(cepHtml).toContain('tabindex="0"');
|
||||
expect(cepCss).toContain("@media (forced-colors: active)");
|
||||
expect(cepCss).toContain("focus-visible");
|
||||
expect(uxpHtml).toContain('role="status"');
|
||||
expect(uxpHtml).toContain('lang="en"');
|
||||
});
|
||||
});
|
||||
Executable
+871
@@ -0,0 +1,871 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { getHelpersSource } from "../../src/bridge/script-builder.js";
|
||||
import { BridgeOptions } from "../../src/bridge/file-bridge.js";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
sendRawCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
getTempDir: vi.fn().mockReturnValue("/tmp/test"),
|
||||
cleanupTempDir: vi.fn(),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getMarkerTools } from "../../src/tools/markers.js";
|
||||
import { getExportTools } from "../../src/tools/export.js";
|
||||
import { getUtilityTools } from "../../src/tools/utility.js";
|
||||
import { getTrackTargetingTools } from "../../src/tools/track-targeting.js";
|
||||
import { getEffectsTools } from "../../src/tools/effects.js";
|
||||
import { getClipboardTools } from "../../src/tools/clipboard.js";
|
||||
import { getTimelineTools } from "../../src/tools/timeline.js";
|
||||
import { getAdvancedTools } from "../../src/tools/advanced.js";
|
||||
import { getProjectTools } from "../../src/tools/project.js";
|
||||
import { getMediaTools } from "../../src/tools/media.js";
|
||||
import { getTextTools } from "../../src/tools/text.js";
|
||||
import { getKeyframeTools } from "../../src/tools/keyframes.js";
|
||||
import { getCaptionTools } from "../../src/tools/captions.js";
|
||||
import { getSequenceTools } from "../../src/tools/sequence.js";
|
||||
import { getPlayheadTools } from "../../src/tools/playhead.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const bridgeOptions: BridgeOptions = { tempDir: "/tmp/test-bridge", timeoutMs: 5000 };
|
||||
|
||||
/** Run a tool handler and return the ExtendScript it generated. */
|
||||
async function scriptFor(tool: { handler: (args: never) => Promise<unknown> }, args: unknown) {
|
||||
mockedSendCommand.mockClear();
|
||||
await tool.handler(args as never);
|
||||
expect(mockedSendCommand).toHaveBeenCalled();
|
||||
return mockedSendCommand.mock.calls[0][0] as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same, with comments removed. The helpers name the broken APIs in prose to explain
|
||||
* why they're avoided ("ProjectItem has no createProxy()"), so an assertion that a
|
||||
* method is never *called* has to look at code only.
|
||||
*/
|
||||
async function codeFor(tool: { handler: (args: never) => Promise<unknown> }, args: unknown) {
|
||||
const script = await scriptFor(tool, args);
|
||||
return script.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
async function executePixelAspectRatioScript(sequence: unknown, ratio = "1.4222") {
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
const script = await scriptFor(utility.set_sequence_pixel_aspect_ratio, { ratio });
|
||||
return JSON.parse(String(runInNewContext(`${getHelpersSource()}\n${script}`, {
|
||||
app: { project: { activeSequence: sequence } },
|
||||
})));
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("real-host social sequence regressions", () => {
|
||||
const sequence = getSequenceTools(bridgeOptions);
|
||||
const playhead = getPlayheadTools(bridgeOptions);
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
|
||||
it("calls Auto Reframe with its required five arguments and reports the derivative", async () => {
|
||||
const script = await scriptFor(sequence.auto_reframe_sequence, {
|
||||
sequence_id: "source-1",
|
||||
target_width: 1080,
|
||||
target_height: 1920,
|
||||
motion_preset: "faster",
|
||||
new_name: "TikTok Test",
|
||||
});
|
||||
expect(script).toContain('seq.autoReframeSequence(9, 16, "faster", newName, false)');
|
||||
expect(script).toContain("if (!reframed) return __error");
|
||||
expect(script).toContain("id: reframed.sequenceID");
|
||||
});
|
||||
|
||||
it("sets and reads sequence in/out points in seconds with verification", async () => {
|
||||
const setScript = await scriptFor(playhead.set_sequence_in_out_points, { in_seconds: 0, out_seconds: 60 });
|
||||
expect(setScript).toContain("seq.setInPoint(0)");
|
||||
expect(setScript).toContain("seq.setOutPoint(60)");
|
||||
expect(setScript).not.toContain("__secondsToTicks(60)");
|
||||
expect(setScript).toContain("Math.abs(observedOut - 60)");
|
||||
|
||||
const getScript = await scriptFor(playhead.get_sequence_in_out_points, {});
|
||||
expect(getScript).toContain("outSeconds: Number(seq.getOutPoint())");
|
||||
expect(getScript).not.toContain("__ticksToSeconds(seq.getOutPoint())");
|
||||
});
|
||||
|
||||
it("uses documented DOM work-area accessors instead of unavailable properties and method names", async () => {
|
||||
const workArea = await codeFor(playhead.get_work_area, {});
|
||||
expect(workArea).toContain("seq.getWorkAreaInPoint()");
|
||||
expect(workArea).toContain("seq.getWorkAreaOutPoint()");
|
||||
expect(workArea).not.toContain("seq.workInPoint");
|
||||
expect(workArea).not.toContain("seq.workOutPoint");
|
||||
|
||||
const enabled = await codeFor(sequence.is_work_area_enabled, {});
|
||||
expect(enabled).toContain("seq.isWorkAreaEnabled()");
|
||||
expect(enabled).not.toContain("seq.isWorkAreaBarEnabled()");
|
||||
});
|
||||
|
||||
it("matches a sequence through projectItem.nodeId and verifies both names", async () => {
|
||||
const script = await scriptFor(utility.rename_project_item, { item_id: "item-1", new_name: "Instagram Test" });
|
||||
expect(script).toContain("candidate.projectItem.nodeId === item.nodeId");
|
||||
expect(script).toContain('sequence.name = "Instagram Test"');
|
||||
expect(script).toContain("sequence && sequence.name !==");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/6
|
||||
describe("issue #6 — markers must use seconds, not ticks", () => {
|
||||
const markers = getMarkerTools(bridgeOptions);
|
||||
|
||||
it("add_marker passes seconds straight to createMarker", async () => {
|
||||
const script = await scriptFor(markers.add_marker, { time_seconds: 2.0 });
|
||||
|
||||
expect(script).toContain("createMarker(2)");
|
||||
// The old bug: __secondsToTicks(2) -> 508032000000 handed to createMarker(),
|
||||
// placing the marker ~508 billion seconds down the timeline.
|
||||
expect(script).not.toContain("__secondsToTicks(2).toString()");
|
||||
expect(script).not.toMatch(/createMarker\(parseFloat/);
|
||||
});
|
||||
|
||||
it("add_marker sets marker.end in seconds when given a duration", async () => {
|
||||
const script = await scriptFor(markers.add_marker, { time_seconds: 2.0, duration_seconds: 3.0 });
|
||||
|
||||
expect(script).toContain("marker.end = 5");
|
||||
expect(script).not.toMatch(/marker\.end = __secondsToTicks/);
|
||||
});
|
||||
|
||||
it("list_markers reads Time.seconds rather than re-converting ticks", async () => {
|
||||
const script = await scriptFor(markers.list_markers, {});
|
||||
|
||||
expect(script).toContain("startSeconds: marker.start.seconds");
|
||||
expect(script).toContain("endSeconds: marker.end.seconds");
|
||||
expect(script).not.toContain("__ticksToSeconds(marker.start.ticks)");
|
||||
});
|
||||
|
||||
it("delete_marker still compares ticks against ticks", async () => {
|
||||
// This path was always correct — both sides are ticks. Guard it so the #6
|
||||
// fix doesn't get over-applied here.
|
||||
const script = await scriptFor(markers.delete_marker, { time_seconds: 2.0 });
|
||||
|
||||
expect(script).toContain("__secondsToTicks(2)");
|
||||
expect(script).toContain("marker.start.ticks");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/7
|
||||
describe("issue #7 — no calls to nonexistent ExtendScript methods", () => {
|
||||
const exportTools = getExportTools(bridgeOptions);
|
||||
const trackTargeting = getTrackTargetingTools(bridgeOptions);
|
||||
|
||||
it("manage_proxies never calls ProjectItem.createProxy()", async () => {
|
||||
const code = await codeFor(exportTools.manage_proxies, {
|
||||
item_id: "clip1",
|
||||
action: "create",
|
||||
output_path: "/tmp/proxy.mov",
|
||||
});
|
||||
|
||||
// ProjectItem has no createProxy(); proxies must go through Media Encoder.
|
||||
expect(code).not.toContain("createProxy");
|
||||
expect(code).toContain("encodeProjectItem");
|
||||
});
|
||||
|
||||
it("manage_proxies 'create' refuses to report false success without an output path", async () => {
|
||||
const script = await scriptFor(exportTools.manage_proxies, { item_id: "clip1", action: "create" });
|
||||
|
||||
expect(script).toContain("output_path is required");
|
||||
expect(script).not.toContain("Proxy creation started");
|
||||
});
|
||||
|
||||
it("manage_proxies 'toggle' reports the state it actually set", async () => {
|
||||
const script = await scriptFor(exportTools.manage_proxies, { item_id: "clip1", action: "toggle" });
|
||||
|
||||
// The old code reported !isProxyEnabled() *after* flipping it — i.e. the inverse
|
||||
// of the truth, every single time.
|
||||
expect(script).toContain("proxiesEnabled: enabled");
|
||||
expect(script).not.toContain("proxiesEnabled: !app.project.isProxyEnabled()");
|
||||
});
|
||||
|
||||
it("get_encoder_presets never calls encoder.getFormatList()", async () => {
|
||||
const code = await codeFor(trackTargeting.get_encoder_presets, { format: "H.264" });
|
||||
|
||||
// EncoderManager has no getFormatList(); presets are found by scanning .epr files.
|
||||
expect(code).not.toContain("getFormatList");
|
||||
expect(code).toContain("__collectAllPresets()");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/9
|
||||
describe("issue #9 — frame export uses the QE DOM and verifies the file landed", () => {
|
||||
const exportTools = getExportTools(bridgeOptions);
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
|
||||
const frameTools: Array<[string, { handler: (args: never) => Promise<unknown> }, unknown]> = [
|
||||
["export_frame", exportTools.export_frame, { output_path: "/tmp/f.png" }],
|
||||
["capture_frame", exportTools.capture_frame, {}],
|
||||
["freeze_frame", utility.freeze_frame, { output_path: "/tmp/f.png" }],
|
||||
];
|
||||
|
||||
for (const [name, tool, args] of frameTools) {
|
||||
it(`${name} does not call exportFramePNG on the public DOM sequence`, async () => {
|
||||
const script = await scriptFor(tool, args);
|
||||
|
||||
// exportFramePNG exists only on the QE sequence. Calling it on
|
||||
// app.project.activeSequence throws "seq.exportFramePNG is not a function".
|
||||
expect(script).not.toMatch(/seq\.exportFramePNG/);
|
||||
expect(script).toContain("__exportStillFrame(");
|
||||
});
|
||||
|
||||
it(`${name} surfaces an error instead of claiming success when no file is written`, async () => {
|
||||
const script = await scriptFor(tool, args);
|
||||
|
||||
expect(script).toContain("if (!res.ok) return __error(");
|
||||
});
|
||||
}
|
||||
|
||||
it("sets and restores the AME one-frame range in seconds", () => {
|
||||
const helpers = getHelpersSource();
|
||||
|
||||
// Sequence.setInPoint/setOutPoint accept seconds. Passing ticks here made
|
||||
// the fallback target an enormous range and prevented a still from landing.
|
||||
expect(helpers).toContain("seq.setInPoint(__ticksToSeconds(startTicks))");
|
||||
expect(helpers).toContain("seq.setOutPoint(__ticksToSeconds(startTicks + frameTicks))");
|
||||
expect(helpers).toContain("seq.setInPoint(__ticksToSeconds(savedIn))");
|
||||
expect(helpers).toContain("seq.setOutPoint(__ticksToSeconds(savedOut))");
|
||||
expect(helpers).not.toContain("seq.setInPoint(String(startTicks))");
|
||||
});
|
||||
});
|
||||
|
||||
// Defects found while reviewing PR #3 (repair 6 broken tools on Premiere Pro 2026).
|
||||
describe("PR #3 follow-ups — color_correct and export_sequence", () => {
|
||||
const effects = getEffectsTools(bridgeOptions);
|
||||
const exportTools = getExportTools(bridgeOptions);
|
||||
|
||||
it("color_correct applies a value of 0 exactly once", async () => {
|
||||
const code = await codeFor(effects.color_correct, { node_id: "clip1", saturation: 0 });
|
||||
|
||||
// saturation: 0 is a valid full desaturate. Guarding on `!changes.saturation`
|
||||
// leaves the guard permanently open for 0, re-firing setValue on every later
|
||||
// Lumetri sub-section that repeats the "Saturation" display name.
|
||||
expect(code).toContain("!taken.saturation");
|
||||
expect(code).toContain("taken.saturation = true");
|
||||
expect(code).not.toContain("!changes.saturation");
|
||||
});
|
||||
|
||||
it("color_correct carries no dead helper", async () => {
|
||||
const code = await codeFor(effects.color_correct, { node_id: "clip1", exposure: 1 });
|
||||
expect(code).not.toContain("trySet");
|
||||
});
|
||||
|
||||
it("color_correct only emits setters for the controls it was given", async () => {
|
||||
const code = await codeFor(effects.color_correct, { node_id: "clip1", exposure: 1.5 });
|
||||
|
||||
expect(code).toContain('name === "Exposure"');
|
||||
expect(code).not.toContain('name === "Saturation"');
|
||||
});
|
||||
|
||||
it("export_sequence finds its default preset without hardcoding a version year", async () => {
|
||||
const code = await codeFor(exportTools.export_sequence, { output_path: "/tmp/out.mp4" });
|
||||
|
||||
expect(code).toContain("__findH264Preset()");
|
||||
// Hardcoded install paths rot the moment Adobe ships the next version.
|
||||
expect(code).not.toContain("Adobe Media Encoder 2025");
|
||||
expect(code).not.toContain("Adobe Media Encoder 2026");
|
||||
});
|
||||
});
|
||||
|
||||
describe("script-builder helpers used by the fixes are actually defined", () => {
|
||||
const exportTools = getExportTools(bridgeOptions);
|
||||
|
||||
it("defines every helper the generated scripts call", async () => {
|
||||
const script = getHelpersSource();
|
||||
|
||||
for (const helper of [
|
||||
"function __exportStillFrame(",
|
||||
"function __firstWrittenFile(",
|
||||
"function __findStillPreset(",
|
||||
"function __collectAllPresets(",
|
||||
"function __findProxyPreset(",
|
||||
"function __findH264Preset(",
|
||||
"function __adobeAppFolders(",
|
||||
"function __collectEprFiles(",
|
||||
]) {
|
||||
expect(script).toContain(helper);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/189
|
||||
describe("issue #189 — Premiere 26.3 capability boundaries and macOS presets", () => {
|
||||
const trackTargeting = getTrackTargetingTools(bridgeOptions);
|
||||
const timeline = getTimelineTools(bridgeOptions);
|
||||
const advanced = getAdvancedTools(bridgeOptions);
|
||||
const text = getTextTools(bridgeOptions);
|
||||
const keyframes = getKeyframeTools(bridgeOptions);
|
||||
const captions = getCaptionTools(bridgeOptions);
|
||||
|
||||
it("searches resources inside macOS application bundles and normalizes H.264 filters", async () => {
|
||||
const helpers = getHelpersSource();
|
||||
const code = await codeFor(trackTargeting.get_encoder_presets, { format: "H.264" });
|
||||
|
||||
expect(helpers).toContain("function __adobeApplicationResourceFolder(");
|
||||
expect(helpers).toContain('"/Contents/"');
|
||||
expect(helpers).toContain('"MediaIO/systempresets"');
|
||||
expect(code).toContain('__presetSearchText("H.264")');
|
||||
expect(code).toContain("__presetSearchText(p.name)");
|
||||
});
|
||||
|
||||
it("never calls unsupported speed setters", async () => {
|
||||
const variants = [
|
||||
timeline.speed_change.handler({ node_id: "clip-1", speed_percent: 65 }),
|
||||
advanced.set_clip_speed_qe.handler({ node_id: "clip-1", speed_percent: 65 }),
|
||||
timeline.set_clip_properties.handler({ node_id: "clip-1", speed: 0.65 }),
|
||||
];
|
||||
|
||||
await expect(Promise.all(variants)).resolves.toEqual([
|
||||
expect.objectContaining({ success: false, error: expect.stringContaining("No mutation was attempted") }),
|
||||
expect.objectContaining({ success: false, error: expect.stringContaining("No mutation was attempted") }),
|
||||
expect.objectContaining({ success: false, error: expect.stringContaining("No mutation was attempted") }),
|
||||
]);
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails raw-text caption creation before it can call an unsupported signature", async () => {
|
||||
const result = await text.add_text_overlay.handler({ text: "TREINO UPPER" });
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.stringContaining("No mutation was attempted"),
|
||||
}));
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("labels keyframe and caption results as storage or structure verification, not rendered output", async () => {
|
||||
const keyframeScript = await scriptFor(keyframes.add_keyframe, {
|
||||
node_id: "clip-1", effect_name: "Opacity", property_name: "Opacity", time_seconds: 0, value: 0,
|
||||
});
|
||||
expect(keyframeScript).toContain("getValueAtKey(time)");
|
||||
expect(keyframeScript).toContain("renderVerified: false");
|
||||
expect(keyframeScript).toContain("Premiere parameter readback only");
|
||||
|
||||
const captionScript = await scriptFor(captions.create_caption_track, { item_id: "captions.srt" });
|
||||
expect(captionScript).toContain("renderVerified: false");
|
||||
expect(captionScript).toContain("verify playback or exported frames");
|
||||
});
|
||||
|
||||
it("validates and structurally verifies timeline insertion instead of trusting insertClip", async () => {
|
||||
const invalid = await timeline.add_to_timeline.handler({ item_id: "clip-1", start_seconds: -1 });
|
||||
expect(invalid).toEqual(expect.objectContaining({ success: false }));
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
|
||||
const script = await scriptFor(timeline.add_to_timeline, {
|
||||
item_id: "clip-1", track_index: 0, audio_track_index: 0, start_seconds: 3.4,
|
||||
});
|
||||
expect(script).toContain("beforeVideoCount");
|
||||
expect(script).toContain("afterVideoCount > beforeVideoCount + 1");
|
||||
expect(script).toContain("residual frame fragment");
|
||||
expect(script).toContain("matchedItem");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/194
|
||||
describe("issue #194 — string-backed MOGRT effect properties", () => {
|
||||
const keyframes = getKeyframeTools(bridgeOptions);
|
||||
|
||||
it("accepts and safely serializes the JSON string exposed by MOGRT text properties", async () => {
|
||||
expect(keyframes.set_effect_property.parameters.properties.value).toMatchObject({
|
||||
type: ["number", "string"],
|
||||
});
|
||||
|
||||
const script = await scriptFor(keyframes.set_effect_property, {
|
||||
node_id: "clip-1",
|
||||
effect_name: "AE.ADBE Capsule",
|
||||
property_name: "Source Text",
|
||||
value: '{"textEditValue":"Hello \\"editor\\""}',
|
||||
});
|
||||
|
||||
expect(script).toContain('var requestedValue = "{\\"textEditValue\\":\\"Hello \\\\\\\"editor\\\\\\\"\\"}";');
|
||||
expect(script).toContain("prop.setValue(requestedValue, true)");
|
||||
expect(script).toContain("readbackVerified: readbackAvailable && readbackValue === requestedValue");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/196
|
||||
describe("issue #196 — empty Premiere 26.x QE effect catalogs", () => {
|
||||
const effects = getEffectsTools(bridgeOptions);
|
||||
|
||||
it("probes an exact QE effect name before treating an empty catalog as unavailable", async () => {
|
||||
const script = await scriptFor(effects.apply_effect, {
|
||||
node_id: "clip-1",
|
||||
effect_name: "Transform",
|
||||
});
|
||||
|
||||
expect(script).toContain("qe.project.getVideoEffectByName(effectName)");
|
||||
expect(script).toContain('var effectCatalog = __getQeEffectCatalog("video")');
|
||||
expect(script).toContain("Direct QE lookup for");
|
||||
expect(script).toContain('lookupSource: lookupSource');
|
||||
|
||||
const listScript = await scriptFor(effects.list_available_effects, {});
|
||||
expect(listScript).toContain("var commonNames = [");
|
||||
expect(listScript).toContain("qe.byName.partial");
|
||||
expect(listScript).toContain("Direct lookup did not resolve any bounded fallback effects.");
|
||||
|
||||
const helpers = getHelpersSource();
|
||||
expect(helpers).toContain("function __getQeEffectCatalog(kind)");
|
||||
expect(helpers).toContain("Premiere returned an empty legacy QE");
|
||||
expect(helpers).toContain("no effect was applied");
|
||||
expect(helpers).toContain("manage_clip_effects_uxp");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/129
|
||||
describe("issue #129 — CEP component removal is capability-gated", () => {
|
||||
const effects = getEffectsTools(bridgeOptions);
|
||||
const clipboard = getClipboardTools(bridgeOptions);
|
||||
|
||||
it.each([
|
||||
["index", { node_id: "clip1", effect_index: 3 }],
|
||||
["name", { node_id: "clip1", effect_name: "Amplify" }],
|
||||
])("remove_effect by %s guards an unavailable Component.remove()", async (_mode, args) => {
|
||||
const code = await codeFor(effects.remove_effect, args);
|
||||
|
||||
expect(code).toContain('typeof component.remove !== "function"');
|
||||
expect(code).toContain("No safe targeted QE fallback exists");
|
||||
expect(code).toContain("return __error(removal.error)");
|
||||
expect(code).not.toContain("qeClip.removeEffects()");
|
||||
});
|
||||
|
||||
it("preflights every matching component before remove_effect_by_name mutates any", async () => {
|
||||
const code = await codeFor(clipboard.remove_effect_by_name, {
|
||||
node_id: "clip1",
|
||||
effect_name: "Amplify",
|
||||
});
|
||||
|
||||
expect(code).toContain("var matches = []");
|
||||
expect(code).toContain("if (!canRemoveComponent(component))");
|
||||
expect(code).toContain("No matching components were removed");
|
||||
expect(code).not.toContain("qeClip.removeEffects()");
|
||||
expect(code.indexOf("if (!canRemoveComponent(component))"))
|
||||
.toBeLessThan(code.lastIndexOf("component.remove()"));
|
||||
});
|
||||
|
||||
it("documents the capability boundary in each targeted removal tool", () => {
|
||||
expect(effects.remove_effect.description).toContain("capability error");
|
||||
expect(clipboard.remove_effect_by_name.description).toContain("capability error");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/37
|
||||
describe("issue #37 — sequence frame rate uses ticks per frame", () => {
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
|
||||
it("converts fps to a Time duration and verifies the applied ticks", async () => {
|
||||
const script = await scriptFor(utility.set_sequence_frame_rate, { frame_rate: 30 });
|
||||
|
||||
expect(script).toContain("TICKS_PER_SECOND / requestedFps");
|
||||
expect(script).toContain("var frameDuration = new Time()");
|
||||
expect(script).toContain("frameDuration.ticks = requestedTicks.toString()");
|
||||
expect(script).toContain("settings.videoFrameRate = frameDuration");
|
||||
expect(script).toContain("Math.abs(appliedTicks - requestedTicks) > 1");
|
||||
expect(script).not.toContain("settings.videoFrameRate = 30");
|
||||
});
|
||||
|
||||
it("rejects invalid frame rates before sending a Premiere command", async () => {
|
||||
mockedSendCommand.mockClear();
|
||||
const result = await utility.set_sequence_frame_rate.handler({ frame_rate: 0 });
|
||||
|
||||
expect(result).toMatchObject({ success: false });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/335
|
||||
describe("issue #335 — pixel aspect ratio must fail closed on unsupported CEP hosts", () => {
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
|
||||
it("checks host support and verifies the readback before reporting success", async () => {
|
||||
const script = await codeFor(utility.set_sequence_pixel_aspect_ratio, { ratio: "1.0" });
|
||||
|
||||
expect(script).toContain("currentRatio = settings.videoPixelAspectRatio");
|
||||
expect(script).toContain('typeof currentRatio === "undefined"');
|
||||
expect(script).toContain("No sequence settings were changed");
|
||||
expect(script).toContain("settings.videoPixelAspectRatio = requestedRatio");
|
||||
expect(script).toContain("observed = seq.getSettings()");
|
||||
expect(script).toContain("observedRatio = String(observed.videoPixelAspectRatio)");
|
||||
expect(script.indexOf('typeof currentRatio === "undefined"'))
|
||||
.toBeLessThan(script.indexOf("settings.videoPixelAspectRatio = requestedRatio"));
|
||||
});
|
||||
|
||||
it("returns structured failures for unavailable and rejected host settings", async () => {
|
||||
const unavailableSetSettings = vi.fn();
|
||||
await expect(executePixelAspectRatioScript({
|
||||
name: "Unsupported setting",
|
||||
getSettings: () => ({}),
|
||||
setSettings: unavailableSetSettings,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("does not expose a writable"),
|
||||
});
|
||||
expect(unavailableSetSettings).not.toHaveBeenCalled();
|
||||
|
||||
const lockedSettings: Record<string, string> = {};
|
||||
Object.defineProperty(lockedSettings, "videoPixelAspectRatio", {
|
||||
get: () => "1.0",
|
||||
set: () => { throw new Error("locked"); },
|
||||
});
|
||||
const rejectedSetSettings = vi.fn();
|
||||
await expect(executePixelAspectRatioScript({
|
||||
name: "Read-only setting",
|
||||
getSettings: () => lockedSettings,
|
||||
setSettings: rejectedSetSettings,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("rejected the sequence pixel-aspect-ratio update"),
|
||||
});
|
||||
expect(rejectedSetSettings).not.toHaveBeenCalled();
|
||||
|
||||
await expect(executePixelAspectRatioScript({
|
||||
name: "Apply failure",
|
||||
getSettings: () => ({ videoPixelAspectRatio: "1.0" }),
|
||||
setSettings: () => { throw new Error("host refused"); },
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("could not apply the sequence pixel-aspect-ratio update"),
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a false host result and mismatched readback as unverified", async () => {
|
||||
await expect(executePixelAspectRatioScript({
|
||||
name: "Rejected return value",
|
||||
getSettings: () => ({ videoPixelAspectRatio: "1.0" }),
|
||||
setSettings: () => false,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("rejected the sequence pixel-aspect-ratio update"),
|
||||
});
|
||||
|
||||
const initialSettings = { videoPixelAspectRatio: "1.0" };
|
||||
await expect(executePixelAspectRatioScript({
|
||||
name: "Mismatched readback",
|
||||
getSettings: vi.fn().mockReturnValueOnce(initialSettings).mockReturnValueOnce({ videoPixelAspectRatio: "1.0" }),
|
||||
setSettings: () => undefined,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("did not apply the requested sequence pixel aspect ratio"),
|
||||
});
|
||||
});
|
||||
|
||||
it("reports success only after an applied setting reads back exactly", async () => {
|
||||
const settings = { videoPixelAspectRatio: "1.0" };
|
||||
const result = await executePixelAspectRatioScript({
|
||||
name: "Verified sequence",
|
||||
getSettings: () => settings,
|
||||
setSettings: () => true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { ratio: "1.4222", sequence: "Verified sequence", verified: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-string aspect ratios before sending a Premiere command", async () => {
|
||||
mockedSendCommand.mockClear();
|
||||
const result = await utility.set_sequence_pixel_aspect_ratio.handler({ ratio: 1 as never });
|
||||
|
||||
expect(result).toMatchObject({ success: false });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/235
|
||||
describe("issue #235 — CEP tool calls use the host's documented argument types", () => {
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
const tracks = getTrackTargetingTools(bridgeOptions);
|
||||
const project = getProjectTools(bridgeOptions);
|
||||
|
||||
it("uses a string-backed pixel aspect ratio and verifies the settings readback", async () => {
|
||||
const script = await scriptFor(utility.set_sequence_pixel_aspect_ratio, { ratio: "1.0" });
|
||||
|
||||
expect(utility.set_sequence_pixel_aspect_ratio.parameters.properties.ratio).toMatchObject({ type: "string" });
|
||||
expect(script).toContain('var requestedRatio = "1.0"');
|
||||
expect(script).toContain("settings.videoPixelAspectRatio = requestedRatio");
|
||||
expect(script).toContain("seq.getSettings()");
|
||||
expect(script).toContain("Premiere did not apply the requested sequence pixel aspect ratio");
|
||||
});
|
||||
|
||||
it("clears sequence points with seconds derived from their tick values", async () => {
|
||||
const script = await scriptFor(tracks.clear_sequence_in_out, {});
|
||||
|
||||
expect(script).toContain("var zeroSeconds = __ticksToSeconds(seq.zeroPoint)");
|
||||
expect(script).toContain("var endSeconds = __ticksToSeconds(seq.end)");
|
||||
expect(script).toContain("seq.setInPoint(zeroSeconds)");
|
||||
expect(script).toContain("seq.setOutPoint(endSeconds)");
|
||||
expect(script).not.toContain("seq.zeroPoint.ticks");
|
||||
});
|
||||
|
||||
it("passes every positional bars-and-tone argument and captures the created item", async () => {
|
||||
const script = await scriptFor(project.create_bars_and_tone, {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
pixel_aspect_numerator: 1,
|
||||
pixel_aspect_denominator: 1,
|
||||
audio_sample_rate: 48000,
|
||||
name: "Bars",
|
||||
});
|
||||
|
||||
expect(script).toContain("app.project.newBarsAndTone(");
|
||||
expect(script).toContain("1920,");
|
||||
expect(script).toContain("1080,");
|
||||
expect(script).toContain("1,");
|
||||
expect(script).toContain("48000,");
|
||||
expect(script).toContain('"Bars"');
|
||||
expect(script).toContain("if (!item) return __error");
|
||||
});
|
||||
|
||||
it("writes the Anti-flicker numeric stream value and verifies it", async () => {
|
||||
const script = await scriptFor(tracks.set_anti_alias_quality, { node_id: "clip-1", enabled: false });
|
||||
|
||||
expect(script).toContain("var requestedValue = 0");
|
||||
expect(script).toContain("antiFlicker.setValue(requestedValue, 1)");
|
||||
expect(script).toContain("antiFlicker.getValue()");
|
||||
expect(script).not.toContain("Use Composition's Shutter Angle");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/236
|
||||
describe("issue #236 — CEP tools supply required positional arguments", () => {
|
||||
const advanced = getAdvancedTools(bridgeOptions);
|
||||
const text = getTextTools(bridgeOptions);
|
||||
|
||||
it("passes action, linked-audio, and sensitivity to scene detection", async () => {
|
||||
const script = await scriptFor(advanced.scene_edit_detection, {
|
||||
action: "CreateMarkers",
|
||||
apply_cuts_to_linked_audio: false,
|
||||
sensitivity: "MediumSensitivity",
|
||||
});
|
||||
|
||||
expect(script).toContain("seq.performSceneEditDetectionOnSelection(");
|
||||
expect(script).toContain('"CreateMarkers"');
|
||||
expect(script).toContain("false,");
|
||||
expect(script).toContain('"MediumSensitivity"');
|
||||
expect(script).toContain("Select at least one clip");
|
||||
});
|
||||
|
||||
it("passes the Creative Cloud Library name before the MOGRT name", async () => {
|
||||
const script = await scriptFor(text.import_mogrt_from_library, {
|
||||
library_name: "Brand Library",
|
||||
mogrt_name: "Lower Third",
|
||||
});
|
||||
|
||||
expect(text.import_mogrt_from_library.parameters.required).toEqual(["library_name", "mogrt_name"]);
|
||||
expect(script).toContain("seq.importMGTFromLibrary(");
|
||||
expect(script).toContain('libraryName,');
|
||||
expect(script).toContain('mogrtName,');
|
||||
expect(script).toContain("startTicks,");
|
||||
});
|
||||
|
||||
it("fails raw-text overlay creation before sending an unsupported bridge call", async () => {
|
||||
const result = await text.add_text_overlay.handler({ text: "Title" });
|
||||
|
||||
expect(result).toMatchObject({ success: false, error: expect.stringContaining("No mutation was attempted") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/237
|
||||
describe("issue #237 — reported mutations must be observable or fail", () => {
|
||||
const advanced = getAdvancedTools(bridgeOptions);
|
||||
const project = getProjectTools(bridgeOptions);
|
||||
const tracks = getTrackTargetingTools(bridgeOptions);
|
||||
const media = getMediaTools(bridgeOptions);
|
||||
const exports = getExportTools(bridgeOptions);
|
||||
|
||||
it("makes trim tools read back their claimed changes", async () => {
|
||||
const slide = await scriptFor(advanced.slide_edit, { node_id: "clip-1", offset_seconds: 1 });
|
||||
const slip = await scriptFor(advanced.slip_edit, { node_id: "clip-1", offset_seconds: 1 });
|
||||
const roll = await scriptFor(advanced.roll_edit, { node_id: "clip-1", offset_seconds: 1 });
|
||||
|
||||
expect(slide).toContain("The slide edit returned without an observable timeline change");
|
||||
expect(roll).toContain("The roll edit returned without an observable timeline change");
|
||||
expect(slip).toContain("The slip edit returned without an observable source in/out change");
|
||||
expect(slip).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("uses structural receipts for import and duplicate consolidation", async () => {
|
||||
const imports = await scriptFor(project.import_sequences, {
|
||||
project_path: "/tmp/source.prproj",
|
||||
sequence_ids: ["source-sequence-id"],
|
||||
});
|
||||
const duplicates = await scriptFor(project.consolidate_duplicates, {});
|
||||
|
||||
expect(imports).toContain("new File");
|
||||
expect(imports).toContain("beforeIds");
|
||||
expect(duplicates).toContain("var nodeId = String(item.nodeId || \"\")");
|
||||
expect(duplicates).toContain("pathMap[mediaPath].nodeIds[nodeId]");
|
||||
expect(imports).toContain("Premiere did not add any of the requested sequences");
|
||||
expect(duplicates).toContain("__duplicateMediaStats");
|
||||
expect(duplicates).toContain("duplicate media groups did not decrease");
|
||||
});
|
||||
|
||||
it("checks image imports, source-point writes, disable state, and offline state", async () => {
|
||||
const image = await scriptFor(tracks.import_image_sequence, { first_file_path: "/tmp/frame_001.png" });
|
||||
const points = await scriptFor(tracks.set_item_in_out, { item_id: "item-1", in_seconds: 1 });
|
||||
const enabled = await scriptFor(tracks.batch_enable_disable, { target: "selected", enabled: false });
|
||||
const offline = await scriptFor(media.set_offline, { item_id: "item-1", offline: false });
|
||||
|
||||
expect(image).toContain("sourceFile.exists");
|
||||
expect(image).toContain("Premiere accepted the image-sequence import but no project item was added");
|
||||
expect(points).toContain("item.getInPoint");
|
||||
expect(points).toContain("Premiere did not apply the requested project-item in point");
|
||||
expect(enabled).toContain("track.clips[c].disabled = disabled");
|
||||
expect(enabled).toContain("No clips matched target 'selected'");
|
||||
expect(offline).toContain("item.refreshMedia()");
|
||||
expect(offline).toContain("item.isOffline()");
|
||||
});
|
||||
|
||||
it("labels sequence close as a tab operation and verifies an OMF file", async () => {
|
||||
const close = await scriptFor(advanced.close_sequence, { sequence_id: "sequence-1" });
|
||||
const omf = await scriptFor(exports.export_omf, { output_path: "/tmp/export.omf" });
|
||||
|
||||
expect(close).toContain("timelineTabCloseRequested: true");
|
||||
expect(close).toContain("sequenceRetainedInProject");
|
||||
expect(omf).toContain("outputFile.exists");
|
||||
expect(omf).toContain("Premiere did not write the requested OMF file");
|
||||
expect(exports.export_omf.parameters.properties.include_pan).toMatchObject({ type: "boolean" });
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/238
|
||||
describe("issue #238 — AME uses canonical paths and documented encodeFile positions", () => {
|
||||
const exports = getExportTools(bridgeOptions);
|
||||
|
||||
it("captures AME job IDs and does not present queueing as a completed encode", async () => {
|
||||
const queued = await scriptFor(exports.add_to_render_queue, { output_path: "/tmp/render.mp4" });
|
||||
const projectItem = await scriptFor(exports.encode_project_item, {
|
||||
item_id: "item-1",
|
||||
output_path: "/tmp/render.mp4",
|
||||
preset_path: "/tmp/preset.epr",
|
||||
});
|
||||
|
||||
expect(queued).toContain("var outputFile = new File");
|
||||
expect(queued).toContain("var jobId = encoder.encodeSequence");
|
||||
expect(queued).toContain("Queue presence and output-file creation are not verified");
|
||||
expect(projectItem).toContain("outputFile.fsName");
|
||||
expect(projectItem).toContain("var jobId = app.encoder.encodeProjectItem");
|
||||
});
|
||||
|
||||
it("passes work area before removal and never passes undefined Time values", async () => {
|
||||
const wholeFile = await scriptFor(exports.encode_file, {
|
||||
input_path: "/tmp/source.mov",
|
||||
output_path: "/tmp/render.mp4",
|
||||
preset_path: "/tmp/preset.epr",
|
||||
});
|
||||
const range = await scriptFor(exports.encode_file, {
|
||||
input_path: "/tmp/source.mov",
|
||||
output_path: "/tmp/render.mp4",
|
||||
preset_path: "/tmp/preset.epr",
|
||||
in_seconds: 1,
|
||||
out_seconds: 2,
|
||||
});
|
||||
|
||||
expect(wholeFile).toContain("var srcIn = new Time()");
|
||||
expect(wholeFile).toContain("var workArea = 0");
|
||||
expect(range).toContain("var workArea = 1");
|
||||
expect(range).toContain("workArea,");
|
||||
expect(range).not.toContain("var srcIn = undefined");
|
||||
expect(range).toContain("var jobId = app.encoder.encodeFile");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/322
|
||||
describe("issue #322 — marker filtering retains the requested sequence collection", () => {
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
|
||||
it("resolves the requested sequence once and returns its identity with the marker set", async () => {
|
||||
const script = await scriptFor(utility.get_sequence_markers_by_type, {
|
||||
sequence_id: "target-sequence", marker_type: "Comment",
|
||||
});
|
||||
|
||||
expect(script).toContain('var seq = __findSequence("target-sequence")');
|
||||
expect(script).toContain("var selectedSequence = { id: String(seq.sequenceID)");
|
||||
expect(script).toContain("var markerCollection = seq.markers");
|
||||
expect(script).toContain("markerCollection.getFirstMarker()");
|
||||
expect(script).toContain("markerCollection.getNextMarker(m)");
|
||||
expect(script).toContain("sequence: selectedSequence");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/323
|
||||
describe("issue #323 — AME handoffs are unverified until a queue or file readback", () => {
|
||||
const exports = getExportTools(bridgeOptions);
|
||||
const project = getProjectTools(bridgeOptions);
|
||||
|
||||
it("does not present AME acceptance as a queued, started, or completed encode", async () => {
|
||||
const render = await scriptFor(exports.add_to_render_queue, { output_path: "/tmp/render.mp4" });
|
||||
const item = await scriptFor(exports.encode_project_item, {
|
||||
item_id: "item-1", output_path: "/tmp/render.mp4", preset_path: "/tmp/preset.epr",
|
||||
});
|
||||
const proxy = await scriptFor(exports.manage_proxies, {
|
||||
item_id: "item-1", action: "create", output_path: "/tmp/proxy.mov", preset_path: "/tmp/proxy.epr",
|
||||
});
|
||||
const file = await scriptFor(exports.encode_file, {
|
||||
input_path: "/tmp/source.mov", output_path: "/tmp/render.mp4", preset_path: "/tmp/preset.epr",
|
||||
});
|
||||
const batch = await scriptFor(project.start_batch_encode, {});
|
||||
|
||||
for (const script of [render, item, proxy, file]) {
|
||||
expect(script).toContain('if (!jobId || String(jobId) === "0") return __error');
|
||||
expect(script).toContain("accepted: true");
|
||||
expect(script).toContain('outcome: "committed_unverified"');
|
||||
expect(script).not.toContain("queued: true");
|
||||
}
|
||||
expect(batch).toContain("requested: true");
|
||||
expect(batch).toContain('outcome: "committed_unverified"');
|
||||
expect(batch).not.toContain("started: true");
|
||||
expect(batch).toContain("var startResult = app.encoder.startBatch()");
|
||||
expect(batch).toContain("if (startResult !== 1 && startResult !== true)");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/324
|
||||
describe("issue #324 — duplicate media requires distinct project-item node IDs", () => {
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
|
||||
it("deduplicates each media path by stable node ID before forming a group", async () => {
|
||||
const script = await scriptFor(utility.get_duplicate_media, {});
|
||||
|
||||
expect(script).toContain("var nodeId = String(item.nodeId || \"\")");
|
||||
expect(script).toContain("pathMap[mp] = { items: [], nodeIds: {} }");
|
||||
expect(script).toContain("if (!pathMap[mp].nodeIds[nodeId])");
|
||||
expect(script).toContain("pathMap[path].items.length > 1");
|
||||
expect(script).not.toContain("pathMap[path].length > 1");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/326
|
||||
describe("issue #326 — sequence creation requires project-collection readback", () => {
|
||||
const sequence = getSequenceTools(bridgeOptions);
|
||||
|
||||
it("does not report a QE-active sequence as created unless it is discoverable", async () => {
|
||||
const script = await scriptFor(sequence.create_sequence, {
|
||||
name: "Verified Sequence", preset_path: "/tmp/sequence.sqpreset",
|
||||
});
|
||||
expect(script).toContain("var beforeSequenceIds = {}");
|
||||
expect(script).toContain("var sequenceId = String(seq.sequenceID)");
|
||||
expect(script).toContain("if (beforeSequenceIds[sequenceId])");
|
||||
expect(script).toContain("did not create a new sequence");
|
||||
expect(script).toContain("var created = __findSequence(sequenceId)");
|
||||
expect(script).toContain("no creation success is reported");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/327
|
||||
describe("issue #327 — legacy media replacement is fail-closed", () => {
|
||||
const clipboard = getClipboardTools(bridgeOptions);
|
||||
|
||||
it("never uses overwriteClip when duration and adjacent-track preservation cannot be verified", async () => {
|
||||
expect(clipboard.replace_clip_media.operationalCapability).toMatchObject({
|
||||
backend: "local", backends: ["local"], status: "unsupported", hostVerificationRequired: false,
|
||||
});
|
||||
const result = await clipboard.replace_clip_media.handler({ clip_node_id: "clip-1", new_item_id: "item-2" } as never);
|
||||
expect(result).toMatchObject({ success: false, error: expect.stringContaining("No mutation was attempted") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
existsSync: vi.fn(),
|
||||
readdirSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: mocks.existsSync,
|
||||
readdirSync: mocks.readdirSync,
|
||||
statSync: mocks.statSync,
|
||||
}));
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.PREMIERE_DEFAULT_SEQUENCE_PRESET;
|
||||
mocks.existsSync.mockReturnValue(true);
|
||||
mocks.statSync.mockReturnValue({ isDirectory: () => false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("default sequence preset discovery", () => {
|
||||
it("uses and caches an explicit existing preset", async () => {
|
||||
process.env.PREMIERE_DEFAULT_SEQUENCE_PRESET = "C:\\Presets\\Explicit.sqpreset";
|
||||
const { findDefaultSequencePreset } = await import("../../src/tools/sequence.js");
|
||||
expect(findDefaultSequencePreset()).toBe("C:\\Presets\\Explicit.sqpreset");
|
||||
mocks.existsSync.mockReturnValue(false);
|
||||
expect(findDefaultSequencePreset()).toBe("C:\\Presets\\Explicit.sqpreset");
|
||||
});
|
||||
|
||||
it("selects the preferred Windows preset while skipping unrelated installs", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
mocks.readdirSync.mockImplementation((directory) => {
|
||||
const path = String(directory);
|
||||
if (path === "C:\\Program Files\\Adobe") return ["Unrelated", "Adobe Premiere Pro 2026"] as never;
|
||||
if (path.endsWith("SequencePresets")) {
|
||||
return ["Other.sqpreset", "UHD (4K) 2160p 25 fps.sqpreset"] as never;
|
||||
}
|
||||
return [] as never;
|
||||
});
|
||||
const { findDefaultSequencePreset } = await import("../../src/tools/sequence.js");
|
||||
expect(findDefaultSequencePreset()).toMatch(/UHD \(4K\) 2160p 25 fps\.sqpreset$/);
|
||||
});
|
||||
|
||||
it("walks a macOS app bundle and falls back to any preset", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
||||
mocks.readdirSync.mockImplementation((directory) => {
|
||||
const path = String(directory);
|
||||
if (path === "/Applications") return ["Adobe Premiere Pro 2026"] as never;
|
||||
if (path.endsWith("Adobe Premiere Pro 2026")) return ["Adobe Premiere Pro.app", "README"] as never;
|
||||
if (path.endsWith("SequencePresets")) return ["Cinema.sqpreset"] as never;
|
||||
return [] as never;
|
||||
});
|
||||
const { findDefaultSequencePreset } = await import("../../src/tools/sequence.js");
|
||||
expect(findDefaultSequencePreset()).toMatch(/Cinema\.sqpreset$/);
|
||||
});
|
||||
|
||||
it("returns and caches null when installation discovery fails", async () => {
|
||||
mocks.readdirSync.mockImplementation(() => { throw new Error("access denied"); });
|
||||
const { findDefaultSequencePreset } = await import("../../src/tools/sequence.js");
|
||||
expect(findDefaultSequencePreset()).toBeNull();
|
||||
expect(findDefaultSequencePreset()).toBeNull();
|
||||
});
|
||||
});
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getExportTools } from "../../src/tools/export.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const tools = getExportTools({ tempDir: "/tmp/test", timeoutMs: 1000 });
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("export_sequence_review_frames", () => {
|
||||
it("reduces a six-frame review from six MCP/bridge calls to one bounded bridge call", async () => {
|
||||
await tools.export_sequence_review_frames.handler({ output_dir: "/tmp/review", frame_count: 6 });
|
||||
|
||||
expect(mockedSendCommand).toHaveBeenCalledTimes(1);
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("var requested = 6");
|
||||
expect(script).toContain("for (var i = 0; i < requested; i++)");
|
||||
expect(script).toContain("__exportStillFrame(");
|
||||
expect(script).toContain("frames.length === requested");
|
||||
});
|
||||
|
||||
it("fails invalid counts and ranges before contacting Premiere", async () => {
|
||||
await expect(tools.export_sequence_review_frames.handler({ output_dir: "/tmp/review", frame_count: 25 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("2 through 24") });
|
||||
await expect(tools.export_sequence_review_frames.handler({ output_dir: "/tmp/review", frame_count: 1 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("2 through 24") });
|
||||
await expect(tools.export_sequence_review_frames.handler({ output_dir: "/tmp/review", start_seconds: 8, end_seconds: 4 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("greater than") });
|
||||
await expect(tools.export_sequence_review_frames.handler({ output_dir: "/tmp/review", start_seconds: -1 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("non-negative") });
|
||||
await expect(tools.export_sequence_review_frames.handler({ output_dir: "", frame_count: 6 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("non-empty") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports partial export failures instead of claiming the whole review succeeded", async () => {
|
||||
await tools.export_sequence_review_frames.handler({ output_dir: "/tmp/review", frame_count: 4 });
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("failures.push");
|
||||
expect(script).toContain("complete: frames.length === requested");
|
||||
expect(script).toContain("Playback, audio, and editorial quality remain unverified");
|
||||
});
|
||||
});
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { planDerivedSilenceRemoval } from "../../src/tools/silence-removal.js";
|
||||
|
||||
describe("derived silence-removal planner", () => {
|
||||
it.each([24000 / 1001, 30000 / 1001, 60000 / 1001])("snaps removal inward at %s fps", (frameRate) => {
|
||||
const plan = planDerivedSilenceRemoval({ durationSeconds: 10, frameRate, silenceRanges: [{ startSeconds: 1.001, endSeconds: 2.009 }] });
|
||||
expect(plan.removalRanges).toEqual([{ startFrame: Math.ceil(1.001 * frameRate - 1e-7), endFrame: Math.floor(2.009 * frameRate + 1e-7) }]);
|
||||
expect(plan.keptFrames + plan.removedFrames).toBe(plan.totalFrames);
|
||||
});
|
||||
|
||||
it("merges overlapping frame ranges and retains requested handles", () => {
|
||||
const plan = planDerivedSilenceRemoval({ durationSeconds: 6, frameRate: 30, keepHandleFrames: 2, silenceRanges: [
|
||||
{ startSeconds: 1, endSeconds: 3 }, { startSeconds: 2.8, endSeconds: 4 },
|
||||
] });
|
||||
expect(plan.removalRanges).toEqual([{ startFrame: 32, endFrame: 118 }]);
|
||||
expect(plan.keepRanges).toEqual([{ startFrame: 0, endFrame: 32 }, { startFrame: 118, endFrame: 180 }]);
|
||||
});
|
||||
|
||||
it("merges snapped overlapping silences before applying outer handles", () => {
|
||||
const plan = planDerivedSilenceRemoval({ durationSeconds: 10, frameRate: 30, keepHandleFrames: 10, silenceRanges: [
|
||||
{ startSeconds: 0, endSeconds: 5 }, { startSeconds: 4.5, endSeconds: 10 },
|
||||
] });
|
||||
expect(plan.removalRanges).toEqual([{ startFrame: 10, endFrame: 290 }]);
|
||||
expect(plan.keepRanges).toEqual([{ startFrame: 0, endFrame: 10 }, { startFrame: 290, endFrame: 300 }]);
|
||||
});
|
||||
|
||||
it("refuses invalid, truncated, and all-silence plans", () => {
|
||||
expect(() => planDerivedSilenceRemoval({ durationSeconds: 1, frameRate: 30, silenceRanges: [{ startSeconds: 0, endSeconds: 1 }] })).toThrow("entire source");
|
||||
expect(() => planDerivedSilenceRemoval({ durationSeconds: 10, frameRate: 30, maximumRemovals: 1, silenceRanges: [
|
||||
{ startSeconds: 1, endSeconds: 2 }, { startSeconds: 3, endSeconds: 4 },
|
||||
] })).toThrow("maximumRemovals");
|
||||
expect(() => planDerivedSilenceRemoval({ durationSeconds: 2, frameRate: 30, silenceRanges: [{ startSeconds: -1, endSeconds: 1 }] })).toThrow("outside");
|
||||
});
|
||||
});
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockedExecFile, mockedExecFileAsync } = vi.hoisted(() => {
|
||||
const mockedExecFileAsync = vi.fn();
|
||||
const mockedExecFile = vi.fn();
|
||||
mockedExecFile[Symbol.for("nodejs.util.promisify.custom")] = mockedExecFileAsync;
|
||||
return { mockedExecFile, mockedExecFileAsync };
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({ execFile: mockedExecFile }));
|
||||
|
||||
import { getExportTools, parseSceneChangeOutput } from "../../src/tools/export.js";
|
||||
|
||||
describe("parseSceneChangeOutput", () => {
|
||||
it("pairs times with scores and keeps the strongest nearby event", () => {
|
||||
const output = [
|
||||
"n:1 pts:100 pts_time:1.00",
|
||||
"lavfi.scene_score=0.40",
|
||||
"n:2 pts:110 pts_time:1.10",
|
||||
"lavfi.scene_score=0.75",
|
||||
"n:3 pts:300 pts_time:3.00",
|
||||
"lavfi.scene_score=0.55",
|
||||
].join("\n");
|
||||
expect(parseSceneChangeOutput(output, 0.25)).toEqual([
|
||||
{ timeSeconds: 1.1, score: 0.75 },
|
||||
{ timeSeconds: 3, score: 0.55 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detect_source_scene_changes boundary", () => {
|
||||
const tool = getExportTools({ tempDir: "/tmp/test" }).detect_source_scene_changes;
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("rejects invalid thresholds and missing files", async () => {
|
||||
await expect(tool.handler({ media_path: "missing.mp4", threshold: 2 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tool.handler({ media_path: "missing.mp4", minimum_interval_seconds: -1 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tool.handler({ media_path: "missing.mp4", maximum_events: 0 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tool.handler({ media_path: "missing.mp4" })).resolves.toMatchObject({ success: false, error: expect.stringContaining("not found") });
|
||||
});
|
||||
|
||||
it("returns bounded source-relative scene candidates from one local FFmpeg pass", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-scene-detect-"));
|
||||
const mediaPath = join(directory, "source.mp4");
|
||||
writeFileSync(mediaPath, "video-fixture");
|
||||
mockedExecFileAsync.mockResolvedValueOnce({
|
||||
stdout: "n:1 pts:100 pts_time:1.00\nlavfi.scene_score=0.40\nn:2 pts:300 pts_time:3.00\nlavfi.scene_score=0.55",
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
await expect(tool.handler({ media_path: mediaPath, threshold: 0.3, minimum_interval_seconds: 0.25, maximum_events: 1 }))
|
||||
.resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
mediaPath,
|
||||
totalDetected: 2,
|
||||
truncated: true,
|
||||
sceneChanges: [{ timeSeconds: 1, score: 0.4 }],
|
||||
},
|
||||
});
|
||||
expect(mockedExecFileAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps an FFmpeg scene-detection failure explicit", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-scene-error-"));
|
||||
const mediaPath = join(directory, "source.mp4");
|
||||
writeFileSync(mediaPath, "video-fixture");
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("decode failed"), { stderr: "bad video stream" }));
|
||||
|
||||
await expect(tool.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("bad video stream") });
|
||||
});
|
||||
});
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getTimelineTools } from "../../src/tools/timeline.js";
|
||||
import { getTrackTargetingTools } from "../../src/tools/track-targeting.js";
|
||||
import { getAdvancedTools } from "../../src/tools/advanced.js";
|
||||
import { getTrackTools } from "../../src/tools/tracks.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const bridgeOptions = { tempDir: "/tmp/test", timeoutMs: 1000 };
|
||||
const timeline = getTimelineTools(bridgeOptions);
|
||||
const trackTargeting = getTrackTargetingTools(bridgeOptions);
|
||||
const advanced = getAdvancedTools(bridgeOptions);
|
||||
const tracks = getTrackTools(bridgeOptions);
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("trim_clip verification", () => {
|
||||
it("requires exactly one edit point instead of reporting a no-op or multi-write as success", async () => {
|
||||
const result = await timeline.trim_clip.handler({ node_id: "abc" });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("new_in_seconds");
|
||||
// The bridge must not be touched at all — there is nothing to apply.
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
|
||||
const multiple = await timeline.trim_clip.handler({
|
||||
node_id: "abc",
|
||||
new_in_seconds: 2,
|
||||
new_out_seconds: 8,
|
||||
});
|
||||
expect(multiple.success).toBe(false);
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("compares the read-back in point against the requested value", async () => {
|
||||
await timeline.trim_clip.handler({ node_id: "abc", new_in_seconds: 2 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("var actualIn = after.inPoint");
|
||||
expect(script).toContain("Math.abs(actualIn - 2) > tolerance");
|
||||
expect(script).toContain("did not apply a verified timeline trim");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("compares the read-back out point against the requested value", async () => {
|
||||
await timeline.trim_clip.handler({ node_id: "abc", new_out_seconds: 8 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("Math.abs(actualOut - 8) > tolerance");
|
||||
});
|
||||
|
||||
it("only writes the edge that was actually requested", async () => {
|
||||
await timeline.trim_clip.handler({ node_id: "abc", new_in_seconds: 2 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("clip.inPoint = __secondsToTicks(2).toString();");
|
||||
expect(script).not.toContain("clip.outPoint = __secondsToTicks(2).toString();");
|
||||
});
|
||||
|
||||
it("derives its tolerance from the sequence timebase so frame snapping is not a failure", async () => {
|
||||
await timeline.trim_clip.handler({ node_id: "abc", new_in_seconds: 2 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("seq.timebase");
|
||||
expect(script).toContain("TICKS_PER_SECOND / 24");
|
||||
expect(script).toContain("var tolerance = __ticksToSeconds(frameTicks)");
|
||||
});
|
||||
|
||||
it("refuses invalid numeric edit points before contacting Premiere", async () => {
|
||||
const result = await timeline.trim_clip.handler({
|
||||
node_id: "abc",
|
||||
new_out_seconds: Number.NaN,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("finite");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("verifies the visible timeline geometry as well as source metadata", async () => {
|
||||
await timeline.trim_clip.handler({ node_id: "abc", new_out_seconds: 8 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("var afterResult = __findClip(\"abc\")");
|
||||
expect(script).toContain("var expectedStart = before.start");
|
||||
expect(script).toContain("var expectedEnd = before.end + (actualOut - before.outPoint)");
|
||||
expect(script).toContain("visible timeline duration does not match the applied source range");
|
||||
expect(script).toContain("source metadata may have changed, but this is not reported as success");
|
||||
});
|
||||
|
||||
it("fails closed for retimed clips and unhandled out-of-range keyframes", async () => {
|
||||
await timeline.trim_clip.handler({ node_id: "abc", new_out_seconds: 8 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("does not support retimed or otherwise non-1x clips");
|
||||
expect(script).toContain("__findOutOfRangeKeyframes");
|
||||
expect(script).toContain("keyframe_policy: preserve");
|
||||
expect(script).toContain("keyframesOutsideVisibleRange");
|
||||
});
|
||||
|
||||
it("allows explicit keyframe preservation while reporting that it was not fully verified", async () => {
|
||||
await timeline.trim_clip.handler({
|
||||
node_id: "abc",
|
||||
new_out_seconds: 8,
|
||||
keyframe_policy: "preserve",
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('keyframePolicy: "preserve"');
|
||||
expect(script).toContain("keyframesVerified:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("split_clip verification", () => {
|
||||
it("refuses invalid local arguments before contacting Premiere", async () => {
|
||||
const invalidTime = await timeline.split_clip.handler({ time_seconds: -1 });
|
||||
expect(invalidTime.success).toBe(false);
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
|
||||
const invalidTrack = await timeline.split_clip.handler({
|
||||
time_seconds: 4,
|
||||
track_index: 1.5,
|
||||
});
|
||||
expect(invalidTrack.success).toBe(false);
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires a clip to span the requested cut before attempting QE razor", async () => {
|
||||
await timeline.split_clip.handler({ time_seconds: 4, track_type: "audio" });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("function __eligibleClips");
|
||||
expect(script).toContain("if (!eligibleBefore.length)");
|
||||
expect(script).toContain("no razor was attempted");
|
||||
});
|
||||
|
||||
it("verifies each left and right segment, not only a count increase", async () => {
|
||||
await timeline.split_clip.handler({ time_seconds: 4 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("var expectedClipCount = clipCountBefore + eligibleBefore.length");
|
||||
expect(script).toContain("function __hasSegment");
|
||||
expect(script).toContain("left segment");
|
||||
expect(script).toContain("right segment");
|
||||
expect(script).toContain('keyframeSemantics: "unverified"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("move_clip verification", () => {
|
||||
it("re-finds the clip after the move rather than trusting a stale reference", async () => {
|
||||
await timeline.move_clip.handler({ node_id: "abc", new_start_seconds: 5 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('var after = __findClip("abc")');
|
||||
expect(script).toContain("Math.abs(actualStart - 5) > tolerance");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("rejects an out-of-range track index instead of silently skipping the move", async () => {
|
||||
await timeline.move_clip.handler({
|
||||
node_id: "abc",
|
||||
new_start_seconds: 5,
|
||||
new_track_index: 99,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("is out of range");
|
||||
expect(script).toContain("targetTracks.numTracks");
|
||||
});
|
||||
|
||||
it("attempts the track change before writing the start time, so a failure leaves no partial edit", async () => {
|
||||
await timeline.move_clip.handler({
|
||||
node_id: "abc",
|
||||
new_start_seconds: 5,
|
||||
new_track_index: 1,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
const trackMoveAt = script.indexOf("qeClip.moveToTrack");
|
||||
const startWriteAt = script.indexOf("clip.start = __secondsToTicks");
|
||||
expect(trackMoveAt).toBeGreaterThan(-1);
|
||||
expect(startWriteAt).toBeGreaterThan(-1);
|
||||
expect(trackMoveAt).toBeLessThan(startWriteAt);
|
||||
});
|
||||
|
||||
it("matches the QE clip by start time because QE item indices include gaps", async () => {
|
||||
await timeline.move_clip.handler({
|
||||
node_id: "abc",
|
||||
new_start_seconds: 5,
|
||||
new_track_index: 1,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('String(cand.type) !== "Clip"');
|
||||
expect(script).not.toContain("getItemAt(result.clipIndex)");
|
||||
});
|
||||
|
||||
it("verifies the clip actually landed on the requested track", async () => {
|
||||
await timeline.move_clip.handler({
|
||||
node_id: "abc",
|
||||
new_start_seconds: 5,
|
||||
new_track_index: 1,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("after.trackIndex !== 1");
|
||||
});
|
||||
|
||||
it("does not emit any track-move code when no track change was requested", async () => {
|
||||
await timeline.move_clip.handler({ node_id: "abc", new_start_seconds: 5 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).not.toContain("moveToTrack");
|
||||
expect(script).not.toContain("after.trackIndex !==");
|
||||
});
|
||||
});
|
||||
|
||||
describe("razor_all_tracks verification", () => {
|
||||
it("counts tracks whose clip count actually changed, not razor attempts", async () => {
|
||||
await trackTargeting.razor_all_tracks.handler({ time_seconds: 7 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("domTrack.clips.numItems > before");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("treats only tracks with a clip spanning the cut point as eligible", async () => {
|
||||
await trackTargeting.razor_all_tracks.handler({ time_seconds: 7 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("function __spansPoint");
|
||||
expect(script).toContain("if (v > s && v < e) return true");
|
||||
expect(script).toContain("if (wasEligible) eligible++");
|
||||
});
|
||||
|
||||
it("errors when any eligible track did not split", async () => {
|
||||
await trackTargeting.razor_all_tracks.handler({ time_seconds: 7 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("eligible > 0 && razored < eligible");
|
||||
expect(script).toContain("only partially applied");
|
||||
expect(script).toContain("no-op on some Premiere Pro 26.x");
|
||||
});
|
||||
|
||||
it("surfaces per-track razor exceptions rather than swallowing them", async () => {
|
||||
await trackTargeting.razor_all_tracks.handler({ time_seconds: 7 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("failures.push");
|
||||
expect(script).toContain("failures: failures");
|
||||
});
|
||||
});
|
||||
|
||||
describe("move_clip_to_track verification", () => {
|
||||
it("matches the QE clip by start time instead of the DOM clip index", async () => {
|
||||
// getItemAt(result.clipIndex) returns an "Empty" gap on any track with a
|
||||
// leading gap, which is what made this fail with a misleading error.
|
||||
await advanced.move_clip_to_track.handler({
|
||||
node_id: "abc",
|
||||
target_track_index: 1,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).not.toContain("getItemAt(result.clipIndex)");
|
||||
expect(script).toContain('String(cand.type) !== "Clip"');
|
||||
expect(script).toContain("Math.abs(parseFloat(cand.start.ticks) - wantStart) < 1");
|
||||
});
|
||||
|
||||
it("rejects an out-of-range target track", async () => {
|
||||
await advanced.move_clip_to_track.handler({
|
||||
node_id: "abc",
|
||||
target_track_index: 99,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("is out of range");
|
||||
expect(script).toContain("targetTracks.numTracks");
|
||||
});
|
||||
|
||||
it("short-circuits when the clip is already on the requested track", async () => {
|
||||
await advanced.move_clip_to_track.handler({
|
||||
node_id: "abc",
|
||||
target_track_index: 1,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("alreadyOnTrack: true");
|
||||
});
|
||||
|
||||
it("confirms against the DOM rather than trusting the QE call", async () => {
|
||||
await advanced.move_clip_to_track.handler({
|
||||
node_id: "abc",
|
||||
target_track_index: 1,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('var after = __findClip("abc")');
|
||||
expect(script).toContain("after.trackIndex !== 1");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("explains the QE rejection instead of surfacing a raw parameter error", async () => {
|
||||
await advanced.move_clip_to_track.handler({
|
||||
node_id: "abc",
|
||||
target_track_index: 1,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("catch (moveErr)");
|
||||
expect(script).toContain("The clip was left untouched");
|
||||
expect(script).toContain("overwriteClip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("track creation verification", () => {
|
||||
it("rejects invalid add_track requests locally instead of generating unsafe host code", async () => {
|
||||
const result = await tracks.add_track.handler({ track_type: "audio", count: 0 });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("positive integer");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses a bounded QE fallback only after a public add_track failure with no mutation", async () => {
|
||||
await tracks.add_track.handler({ track_type: "audio", count: 2 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
|
||||
expect(script).toContain('typeof seq.insertAudioTrackAt !== "function"');
|
||||
expect(script).toContain("afterPublic !== expected && afterPublic !== before");
|
||||
expect(script).toContain("qeSeq.addTracks(0, 2, 0, 0)");
|
||||
expect(script).toContain("if (after !== expected)");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("does not report QE add_tracks success until exact video and audio count readback", async () => {
|
||||
await advanced.add_tracks.handler({
|
||||
video_tracks: 1,
|
||||
audio_tracks: 2,
|
||||
audio_mono_tracks: 1,
|
||||
audio_51_tracks: 1,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
|
||||
expect(script).toContain("var beforeVideo = seq.videoTracks.numTracks");
|
||||
expect(script).toContain("var expectedAudio = beforeAudio + 4");
|
||||
expect(script).toContain("typeof qeSeq.addTracks !== \"function\"");
|
||||
expect(script).toContain("afterVideo !== expectedVideo || afterAudio !== expectedAudio");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("rejects an empty add_tracks request locally rather than returning a successful no-op", async () => {
|
||||
const result = await advanced.add_tracks.handler({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("at least one");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("overwrite_clip verification", () => {
|
||||
it("rejects invalid indices locally before they can be interpolated into ExtendScript", async () => {
|
||||
const result = await advanced.overwrite_clip.handler({
|
||||
item_id: "source-1",
|
||||
audio_track_index: -1,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("audio_track_index");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks both target tracks and host capability before calling overwriteClip", async () => {
|
||||
await advanced.overwrite_clip.handler({
|
||||
item_id: "source-1",
|
||||
track_index: 1,
|
||||
audio_track_index: 2,
|
||||
start_seconds: 5,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
const videoCheck = script.indexOf("Video track index 1 is out of range");
|
||||
const audioCheck = script.indexOf("Audio track index 2 is out of range");
|
||||
const call = script.indexOf("seq.overwriteClip(item");
|
||||
|
||||
expect(videoCheck).toBeGreaterThan(-1);
|
||||
expect(audioCheck).toBeGreaterThan(-1);
|
||||
expect(call).toBeGreaterThan(audioCheck);
|
||||
expect(script).toContain('typeof seq.overwriteClip !== "function"');
|
||||
});
|
||||
|
||||
it("proves the requested source landed on a target track and errors on no new placement", async () => {
|
||||
await advanced.overwrite_clip.handler({ item_id: "source-1", start_seconds: 5 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
|
||||
expect(script).toContain("clip.projectItem ? String(clip.projectItem.nodeId)");
|
||||
expect(script).toContain("Math.abs(actualStartTicks - wantedStartTicks) <= frameTicks");
|
||||
expect(script).toContain("if (!videoPlaced && !audioPlaced)");
|
||||
expect(script).toContain("produced no verifiable new placement");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
});
|
||||
Executable
+979
@@ -0,0 +1,979 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { getHelpersSource } from "../../src/bridge/script-builder.js";
|
||||
import { BridgeOptions } from "../../src/bridge/file-bridge.js";
|
||||
|
||||
// Mock sendCommand and sendRawCommand so tool handlers don't do real I/O
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
sendRawCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
getTempDir: vi.fn().mockReturnValue("/tmp/test"),
|
||||
cleanupTempDir: vi.fn(),
|
||||
}));
|
||||
|
||||
import { sendCommand, sendRawCommand } from "../../src/bridge/file-bridge.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const mockedSendRawCommand = vi.mocked(sendRawCommand);
|
||||
|
||||
const bridgeOptions: BridgeOptions = { tempDir: "/tmp/test-bridge", timeoutMs: 5000 };
|
||||
|
||||
// Import all tool modules
|
||||
import { getDiscoveryTools } from "../../src/tools/discovery.js";
|
||||
import { getProjectTools } from "../../src/tools/project.js";
|
||||
import { getMediaTools } from "../../src/tools/media.js";
|
||||
import { getSequenceTools } from "../../src/tools/sequence.js";
|
||||
import { getTimelineTools } from "../../src/tools/timeline.js";
|
||||
import { getEffectsTools } from "../../src/tools/effects.js";
|
||||
import { getTransitionsTools } from "../../src/tools/transitions.js";
|
||||
import { getAudioTools } from "../../src/tools/audio.js";
|
||||
import { getTextTools } from "../../src/tools/text.js";
|
||||
import { getMarkerTools } from "../../src/tools/markers.js";
|
||||
import { getTrackTools } from "../../src/tools/tracks.js";
|
||||
import { getPlayheadTools } from "../../src/tools/playhead.js";
|
||||
import { getMetadataTools } from "../../src/tools/metadata.js";
|
||||
import { getExportTools } from "../../src/tools/export.js";
|
||||
import { getMediaAnalysisTools } from "../../src/tools/media-analysis.js";
|
||||
import { getInterchangeAnalysisTools } from "../../src/tools/interchange-analysis.js";
|
||||
import { getAdvancedTools } from "../../src/tools/advanced.js";
|
||||
import { getKeyframeTools } from "../../src/tools/keyframes.js";
|
||||
import { getScriptingTools } from "../../src/tools/scripting.js";
|
||||
import { getInspectionTools } from "../../src/tools/inspection.js";
|
||||
import { getSelectionTools } from "../../src/tools/selection.js";
|
||||
import { getClipboardTools } from "../../src/tools/clipboard.js";
|
||||
import { getSourceMonitorTools } from "../../src/tools/source-monitor.js";
|
||||
import { getTrackTargetingTools } from "../../src/tools/track-targeting.js";
|
||||
import { getUtilityTools } from "../../src/tools/utility.js";
|
||||
import { getHealthTools } from "../../src/tools/health.js";
|
||||
import { getWorkspaceTools } from "../../src/tools/workspace.js";
|
||||
import { getCaptionTools } from "../../src/tools/captions.js";
|
||||
import { getPlaybackTools } from "../../src/tools/playback.js";
|
||||
import { getProjectManagerTools } from "../../src/tools/project-manager.js";
|
||||
import { getRecoveryTools } from "../../src/tools/recovery.js";
|
||||
import { getAvSettingsTools } from "../../src/tools/av-settings.js";
|
||||
import { getProjectContextTools } from "../../src/tools/project-context.js";
|
||||
import { getEditorialContextPackTools } from "../../src/tools/editorial-context-pack.js";
|
||||
import { getEditorialPlanTools } from "../../src/tools/editorial-plans.js";
|
||||
import { getProjectIntakeTools } from "../../src/tools/project-intake.js";
|
||||
import { getCompetitorGapTools } from "../../src/tools/competitor-gaps.js";
|
||||
import { getSpotWorkflowTools } from "../../src/tools/spot-workflows.js";
|
||||
import { getMogrtAuthoringTools } from "../../src/tools/mogrt-authoring.js";
|
||||
import { getMogrtStudioTools } from "../../src/tools/mogrt-studio.js";
|
||||
import type { Telemetry, TelemetryProperties } from "../../src/telemetry.js";
|
||||
|
||||
interface ToolDef {
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
handler: (args: any) => Promise<{ success: boolean; data?: unknown; error?: string }>;
|
||||
}
|
||||
|
||||
type ToolModule = Record<string, ToolDef>;
|
||||
|
||||
// All modules with their expected tool counts and names
|
||||
const ALL_MODULES: Array<{
|
||||
name: string;
|
||||
getter: (opts: BridgeOptions) => ToolModule;
|
||||
minTools: number;
|
||||
}> = [
|
||||
{ name: "discovery", getter: getDiscoveryTools, minTools: 8 },
|
||||
{ name: "project", getter: getProjectTools, minTools: 20 },
|
||||
{ name: "media", getter: getMediaTools, minTools: 12 },
|
||||
{ name: "sequence", getter: getSequenceTools, minTools: 8 },
|
||||
{ name: "timeline", getter: getTimelineTools, minTools: 8 },
|
||||
{ name: "effects", getter: getEffectsTools, minTools: 6 },
|
||||
{ name: "transitions", getter: getTransitionsTools, minTools: 3 },
|
||||
{ name: "audio", getter: getAudioTools, minTools: 2 },
|
||||
{ name: "text", getter: getTextTools, minTools: 2 },
|
||||
{ name: "markers", getter: getMarkerTools, minTools: 3 },
|
||||
{ name: "tracks", getter: getTrackTools, minTools: 3 },
|
||||
{ name: "playhead", getter: getPlayheadTools, minTools: 4 },
|
||||
{ name: "metadata", getter: getMetadataTools, minTools: 6 },
|
||||
{ name: "export", getter: getExportTools, minTools: 10 },
|
||||
{ name: "media-analysis", getter: getMediaAnalysisTools, minTools: 5 },
|
||||
{ name: "interchange-analysis", getter: getInterchangeAnalysisTools, minTools: 5 },
|
||||
{ name: "advanced", getter: getAdvancedTools, minTools: 20 },
|
||||
{ name: "keyframes", getter: getKeyframeTools, minTools: 5 },
|
||||
{ name: "scripting", getter: getScriptingTools, minTools: 3 },
|
||||
{ name: "inspection", getter: getInspectionTools, minTools: 5 },
|
||||
{ name: "selection", getter: getSelectionTools, minTools: 5 },
|
||||
{ name: "clipboard", getter: getClipboardTools, minTools: 4 },
|
||||
{ name: "source-monitor", getter: getSourceMonitorTools, minTools: 5 },
|
||||
{ name: "track-targeting", getter: getTrackTargetingTools, minTools: 20 },
|
||||
{ name: "utility", getter: getUtilityTools, minTools: 15 },
|
||||
{ name: "health", getter: getHealthTools, minTools: 3 },
|
||||
{ name: "workspace", getter: getWorkspaceTools, minTools: 2 },
|
||||
{ name: "captions", getter: getCaptionTools, minTools: 1 },
|
||||
{ name: "playback", getter: getPlaybackTools, minTools: 3 },
|
||||
{ name: "project-manager", getter: getProjectManagerTools, minTools: 1 },
|
||||
{ name: "recovery", getter: getRecoveryTools, minTools: 2 },
|
||||
{ name: "av-settings", getter: getAvSettingsTools, minTools: 4 },
|
||||
{ name: "project-context", getter: getProjectContextTools, minTools: 3 },
|
||||
{ name: "editorial-context-pack", getter: getEditorialContextPackTools, minTools: 1 },
|
||||
{ name: "editorial-plans", getter: () => getEditorialPlanTools(), minTools: 2 },
|
||||
{ name: "project-intake", getter: getProjectIntakeTools, minTools: 1 },
|
||||
{ name: "competitor-gaps", getter: getCompetitorGapTools, minTools: 8 },
|
||||
{ name: "spot-workflows", getter: getSpotWorkflowTools, minTools: 4 },
|
||||
{ name: "mogrt-authoring", getter: getMogrtAuthoringTools, minTools: 4 },
|
||||
{ name: "mogrt-studio", getter: getMogrtStudioTools, minTools: 11 },
|
||||
];
|
||||
|
||||
describe("Tool Module Structure", () => {
|
||||
for (const mod of ALL_MODULES) {
|
||||
describe(`${mod.name} module`, () => {
|
||||
let tools: ToolModule;
|
||||
|
||||
beforeEach(() => {
|
||||
tools = mod.getter(bridgeOptions);
|
||||
});
|
||||
|
||||
it(`exports at least ${mod.minTools} tools`, () => {
|
||||
expect(Object.keys(tools).length).toBeGreaterThanOrEqual(mod.minTools);
|
||||
});
|
||||
|
||||
it("each tool has a description string", () => {
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
expect(typeof tool.description, `${name} description`).toBe("string");
|
||||
expect(tool.description.length, `${name} description length`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("each tool has a parameters object", () => {
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
expect(typeof tool.parameters, `${name} parameters`).toBe("object");
|
||||
}
|
||||
});
|
||||
|
||||
it("each tool has an async handler function", () => {
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
expect(typeof tool.handler, `${name} handler`).toBe("function");
|
||||
}
|
||||
});
|
||||
|
||||
it("parameter properties all have type fields", () => {
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
const props = (tool.parameters as any).properties;
|
||||
if (props) {
|
||||
for (const [propName, prop] of Object.entries(props) as [string, any][]) {
|
||||
expect(prop.type, `${name}.${propName} type`).toBeDefined();
|
||||
const types = Array.isArray(prop.type) ? prop.type : [prop.type];
|
||||
expect(
|
||||
types.length > 0 && types.every((type) =>
|
||||
["string", "number", "integer", "boolean", "array", "object"].includes(type)
|
||||
),
|
||||
`${name}.${propName} has valid type "${types.join(",")}"`
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("parameter properties all have description fields", () => {
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
const props = (tool.parameters as any).properties;
|
||||
if (props) {
|
||||
for (const [propName, prop] of Object.entries(props) as [string, any][]) {
|
||||
expect(
|
||||
typeof prop.description,
|
||||
`${name}.${propName} should have description`
|
||||
).toBe("string");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("required fields reference existing properties", () => {
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
const params = tool.parameters as any;
|
||||
const required = params.required || [];
|
||||
const propNames = Object.keys(params.properties || {});
|
||||
for (const req of required) {
|
||||
expect(
|
||||
propNames.includes(req),
|
||||
`${name}: required field "${req}" should exist in properties`
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("Total Tool Count", () => {
|
||||
it("all modules together have 342 tools", () => {
|
||||
let total = 0;
|
||||
for (const mod of ALL_MODULES) {
|
||||
total += Object.keys(mod.getter(bridgeOptions)).length;
|
||||
}
|
||||
expect(total).toBe(342);
|
||||
});
|
||||
|
||||
it("there are 40 directly enumerated modules", () => {
|
||||
expect(ALL_MODULES.length).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tool Handler Behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedSendCommand.mockResolvedValue({ success: true, data: { mock: true } });
|
||||
});
|
||||
|
||||
describe("health.ping", () => {
|
||||
it("calls sendCommand with shortened timeout", async () => {
|
||||
const tools = getHealthTools(bridgeOptions);
|
||||
await (tools.ping.handler as any)({});
|
||||
|
||||
expect(mockedSendCommand).toHaveBeenCalledTimes(1);
|
||||
const callArgs = mockedSendCommand.mock.calls[0];
|
||||
// Script should contain app.version
|
||||
expect(callArgs[0]).toContain("app.version");
|
||||
// Should use a 5-second timeout override
|
||||
expect(callArgs[1]?.timeoutMs).toBe(5000);
|
||||
expect(callArgs[1]?.failFastOnUnreadyHeartbeat).toBe(true);
|
||||
});
|
||||
|
||||
it("generates script that checks connectivity", async () => {
|
||||
const tools = getHealthTools(bridgeOptions);
|
||||
await (tools.ping.handler as any)({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("app.project");
|
||||
expect(script).toContain("__result");
|
||||
expect(script).toContain("connected: true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("health.verify_premiere_connection", () => {
|
||||
it("uses a read-only boolean check and emits activation only after readiness is confirmed", async () => {
|
||||
const events: Array<{ event: string; properties?: TelemetryProperties }> = [];
|
||||
const telemetry: Telemetry = {
|
||||
enabled: true,
|
||||
capture: (event, properties) => events.push({ event, properties }),
|
||||
shutdown: async () => {},
|
||||
};
|
||||
mockedSendCommand.mockResolvedValue({
|
||||
success: true,
|
||||
data: { projectOpen: true, sequenceOpen: true },
|
||||
});
|
||||
|
||||
const tools = getHealthTools(bridgeOptions, undefined, undefined, { telemetry });
|
||||
const result = await (tools.verify_premiere_connection.handler as any)({});
|
||||
const script = String(mockedSendCommand.mock.calls[0][0]);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: { overall: "ready", safeCheck: { readOnly: true, mutatesProject: false } },
|
||||
});
|
||||
expect(script).toContain("projectOpen");
|
||||
expect(script).toContain("sequenceOpen");
|
||||
expect(mockedSendCommand.mock.calls[0][1]?.failFastOnUnreadyHeartbeat).toBe(true);
|
||||
expect(script).not.toContain("projectName");
|
||||
expect(script).not.toContain("project.path");
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
event: "premiere_mcp_activation_completed",
|
||||
properties: { activation_stage: "verified_connection", backend: "cep" },
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(events)).not.toMatch(/prompt|path|token|project|media|argument|result|profile/i);
|
||||
});
|
||||
|
||||
it("does not record activation when the read-only check is incomplete", async () => {
|
||||
const events: Array<{ event: string; properties?: TelemetryProperties }> = [];
|
||||
const telemetry: Telemetry = {
|
||||
enabled: true,
|
||||
capture: (event, properties) => events.push({ event, properties }),
|
||||
shutdown: async () => {},
|
||||
};
|
||||
mockedSendCommand.mockResolvedValue({
|
||||
success: true,
|
||||
data: { projectOpen: true, sequenceOpen: false },
|
||||
});
|
||||
|
||||
const tools = getHealthTools(bridgeOptions, undefined, undefined, { telemetry });
|
||||
const result = await (tools.verify_premiere_connection.handler as any)({});
|
||||
|
||||
expect(result).toMatchObject({ success: true, data: { overall: "needs_attention" } });
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not fall back from an unavailable UXP check to CEP", async () => {
|
||||
const tools = getHealthTools(bridgeOptions);
|
||||
const result = await (tools.verify_premiere_connection.handler as any)({ backend: "uxp" });
|
||||
|
||||
expect(result).toMatchObject({ success: true, data: { backend: "uxp", overall: "needs_attention" } });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("project.create_project", () => {
|
||||
it("rejects a directory path without invoking Premiere", async () => {
|
||||
const tools = getProjectTools(bridgeOptions);
|
||||
|
||||
const result = await (tools.create_project.handler as any)({
|
||||
path: "/tmp/film-test",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error:
|
||||
"create_project path must be a full .prproj file path; Premiere cannot create a project from a directory path.",
|
||||
});
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("verifies the active project path after creating a project", async () => {
|
||||
const tools = getProjectTools(bridgeOptions);
|
||||
|
||||
await (tools.create_project.handler as any)({
|
||||
path: "/tmp/Film Test.prproj",
|
||||
});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('var requestedPath = "/tmp/Film Test.prproj"');
|
||||
expect(script).toContain("var beforePath = app.project");
|
||||
expect(script).toContain("var actualPath = project ? String(project.path || \"\") : \"\"");
|
||||
expect(script).toContain("__normalizedProjectPath(actualPath) !== __normalizedProjectPath(requestedPath)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reported tool regressions", () => {
|
||||
beforeEach(() => mockedSendCommand.mockClear());
|
||||
|
||||
it("does not report duplicate consolidation as successful without a reduced duplicate scan", async () => {
|
||||
const tools = getProjectTools(bridgeOptions);
|
||||
await tools.consolidate_duplicates.handler({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("function __duplicateMediaStats()");
|
||||
expect(script).toContain("var before = __duplicateMediaStats()");
|
||||
expect(script).toContain("var after = __duplicateMediaStats()");
|
||||
expect(script).toContain("duplicate media groups did not decrease");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("fails reverse_clip locally with an actionable unsupported-capability error", async () => {
|
||||
const tools = getAdvancedTools(bridgeOptions);
|
||||
const result = await tools.reverse_clip.handler({ node_id: "clip-1" });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining("not exposed by Premiere's supported ExtendScript or UXP APIs"),
|
||||
});
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("merges XMP patches through AdobeXMPScript and reparses host readback", async () => {
|
||||
const tools = getMetadataTools(bridgeOptions);
|
||||
await tools.set_xmp_metadata.handler({
|
||||
item_id: "source-1",
|
||||
xmp_xml: '<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"/></x:xmpmeta>',
|
||||
});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('new ExternalObject("lib:AdobeXMPScript")');
|
||||
expect(script).toContain("var existingXmp = new XMPMeta(existingPacket)");
|
||||
expect(script).toContain("XMPUtils.appendProperties(patchXmp, existingXmp, true, true, false)");
|
||||
expect(script).toContain("var writtenPacket = String(item.getXMPMetadata() || \"\")");
|
||||
expect(script).toContain('verification: "readback_xmp_packet_reparsed"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("health.get_capabilities", () => {
|
||||
it("reports platform support without requiring Premiere to be running", async () => {
|
||||
const tools = getHealthTools(bridgeOptions);
|
||||
const result = await tools.get_capabilities.handler();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.backends.cep.platforms).toEqual(["macOS", "Windows"]);
|
||||
expect(result.data.premiere.hostVerificationRequired).toBe(true);
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes per-tool operational metadata from the supplied catalog", async () => {
|
||||
const tools = getHealthTools(
|
||||
bridgeOptions,
|
||||
undefined,
|
||||
() => ({
|
||||
get_capabilities: { description: "Report capabilities." },
|
||||
ripple_delete: { description: "Ripple delete. Uses QE DOM." },
|
||||
}),
|
||||
);
|
||||
const result = await tools.get_capabilities.handler();
|
||||
expect(result.data.tools.total).toBe(2);
|
||||
expect(result.data.tools.tools).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "ripple_delete",
|
||||
status: "experimental",
|
||||
backend: "CEP/ExtendScript + QE",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounded read tools", () => {
|
||||
it("returns a filtered, paged get_capabilities catalog without changing the capability summary", async () => {
|
||||
const tools = getHealthTools(
|
||||
bridgeOptions,
|
||||
undefined,
|
||||
() => ({
|
||||
ping: { description: "Ping." },
|
||||
get_capabilities: { description: "Capabilities." },
|
||||
ripple_delete: { description: "Ripple delete. Uses QE DOM." },
|
||||
}),
|
||||
);
|
||||
const result = await tools.get_capabilities.handler({ tool_names: ["ripple_delete", "missing"], tool_limit: 1 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.tools.tools).toEqual([expect.objectContaining({ name: "ripple_delete" })]);
|
||||
expect(result.data.tools.pagination).toEqual({
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
returned: 1,
|
||||
totalMatching: 1,
|
||||
hasMore: false,
|
||||
nextOffset: null,
|
||||
});
|
||||
expect(result.data.backends.cep.status).toBe("production");
|
||||
});
|
||||
|
||||
it("omits requested metadata XML payloads before invoking the CEP bridge", async () => {
|
||||
const tools = getMetadataTools(bridgeOptions);
|
||||
await tools.get_metadata.handler({
|
||||
item_id: "source-1",
|
||||
include_project_metadata: false,
|
||||
include_xmp_metadata: false,
|
||||
});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).not.toContain("item.getProjectMetadata()");
|
||||
expect(script).not.toContain("item.getXMPMetadata()");
|
||||
expect(script).toContain("metadata.nodeId = item.nodeId");
|
||||
});
|
||||
|
||||
it("generates bounded overview and bin read scripts", async () => {
|
||||
const tools = getInspectionTools(bridgeOptions);
|
||||
await tools.get_full_project_overview.handler({
|
||||
include_bin_tree: false,
|
||||
sequence_offset: 5,
|
||||
sequence_limit: 10,
|
||||
});
|
||||
const overview = mockedSendCommand.mock.calls[0][0];
|
||||
expect(overview).toContain("var binTree = null");
|
||||
expect(overview).toContain("var sequenceOffset = 5");
|
||||
expect(overview).toContain("var sequenceLimit = 10");
|
||||
expect(overview).toContain("sequencePagination");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await tools.get_bin_contents.handler({
|
||||
bin_id: "Footage",
|
||||
recursive: false,
|
||||
offset: 10,
|
||||
limit: 25,
|
||||
max_depth: 0,
|
||||
});
|
||||
const bin = mockedSendCommand.mock.calls[0][0];
|
||||
expect(bin).toContain("var allContents = walkItems(target, false, 0)");
|
||||
expect(bin).toContain("allContents.slice(offset, offset + limit)");
|
||||
expect(bin).toContain("totalDirectItems");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace tools", () => {
|
||||
it("get_workspaces generates correct script", async () => {
|
||||
const tools = getWorkspaceTools(bridgeOptions);
|
||||
await (tools.get_workspaces.handler as any)({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("app.getWorkspaces()");
|
||||
expect(script).toContain("__result");
|
||||
});
|
||||
|
||||
it("set_workspace escapes the workspace name", async () => {
|
||||
const tools = getWorkspaceTools(bridgeOptions);
|
||||
await (tools.set_workspace.handler as any)({ name: 'My "Custom" Workspace' });
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('My \\"Custom\\" Workspace');
|
||||
expect(script).toContain("app.setWorkspace");
|
||||
});
|
||||
});
|
||||
|
||||
describe("playback tools", () => {
|
||||
it("play_timeline uses QE DOM", async () => {
|
||||
const tools = getPlaybackTools(bridgeOptions);
|
||||
await (tools.play_timeline.handler as any)({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("app.enableQE()");
|
||||
expect(script).toContain("qe.startPlayback()");
|
||||
expect(script).toContain("playbackRequested: true");
|
||||
expect(script).toContain("playbackVerified: false");
|
||||
expect(script).toContain("poll get_playhead_position");
|
||||
});
|
||||
|
||||
it("play_timeline has no speed parameter (QE startPlayback ignores it)", async () => {
|
||||
const tools = getPlaybackTools(bridgeOptions);
|
||||
expect(tools.play_timeline.parameters).toEqual({});
|
||||
});
|
||||
|
||||
it("writes and verifies the public TrackItem disabled property", async () => {
|
||||
const tools = getTimelineTools(bridgeOptions);
|
||||
await (tools.enable_disable_clip.handler as any)({ node_id: "clip-1", enabled: false });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("result.clip.disabled = wantDisabled");
|
||||
expect(script).toContain('var verified = __findClip("clip-1")');
|
||||
expect(script).toContain("!!verified.clip.disabled !== wantDisabled");
|
||||
expect(script).not.toContain("setDisabled(");
|
||||
});
|
||||
|
||||
it("does not claim legacy source-monitor or stop-playback requests are verified", async () => {
|
||||
const tools = getPlaybackTools(bridgeOptions);
|
||||
await (tools.stop_playback.handler as any)({});
|
||||
let script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("stopRequested: true");
|
||||
expect(script).toContain("playbackVerified: false");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await (tools.play_source_monitor.handler as any)({ speed: 1 });
|
||||
script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("playbackRequested: true");
|
||||
expect(script).toContain("poll get_source_monitor_position");
|
||||
});
|
||||
|
||||
it("stop_playback uses QE DOM", async () => {
|
||||
const tools = getPlaybackTools(bridgeOptions);
|
||||
await (tools.stop_playback.handler as any)({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("qe.stopPlayback()");
|
||||
});
|
||||
|
||||
it("get_source_monitor_position reads ticks", async () => {
|
||||
const tools = getPlaybackTools(bridgeOptions);
|
||||
await (tools.get_source_monitor_position.handler as any)({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("app.sourceMonitor.getPosition()");
|
||||
expect(script).toContain("__ticksToSeconds");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verified Premiere mutations", () => {
|
||||
it("uses settings fields and readback for set_sequence_settings", async () => {
|
||||
const tools = getSequenceTools(bridgeOptions);
|
||||
await (tools.set_sequence_settings.handler as any)({ sequence_id: "seq-1", width: 1920, height: 1080 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("settings.videoFrameWidth = 1920");
|
||||
expect(script).toContain("settings.videoFrameHeight = 1080");
|
||||
expect(script).toContain("var applied = seq.getSettings()");
|
||||
expect(script).toContain("Premiere did not apply the requested frame width");
|
||||
expect(script).toContain("verified: true");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await (tools.set_sequence_settings.handler as any)({ width: 1920 });
|
||||
expect(mockedSendCommand.mock.calls[0][0]).toContain("settings.videoFrameWidth = 1920");
|
||||
expect(mockedSendCommand.mock.calls[0][0]).not.toContain("settings.videoFrameHeight =");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await (tools.set_sequence_settings.handler as any)({ height: 1080 });
|
||||
expect(mockedSendCommand.mock.calls[0][0]).toContain("settings.videoFrameHeight = 1080");
|
||||
expect(mockedSendCommand.mock.calls[0][0]).not.toContain("settings.videoFrameWidth =");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await expect((tools.set_sequence_settings.handler as any)({ width: 0 })).resolves.toMatchObject({ success: false });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
|
||||
await expect((tools.set_sequence_settings.handler as any)({})).resolves.toMatchObject({ success: false });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails unsupported legacy AAF and multiple-undo requests before mutation", async () => {
|
||||
const aaf = await (getExportTools(bridgeOptions).export_aaf.handler as any)({ output_path: "/tmp/turnover.aaf" });
|
||||
const undoTools = getTrackTargetingTools(bridgeOptions);
|
||||
const undo = await (undoTools.multiple_undo.handler as any)({ count: 2 });
|
||||
const invalidUndo = await (undoTools.multiple_undo.handler as any)({ count: 0 });
|
||||
expect(aaf).toMatchObject({ success: false, error: expect.stringContaining("No export was attempted") });
|
||||
expect(undo).toMatchObject({ success: false, error: expect.stringContaining("No mutation was attempted") });
|
||||
expect(invalidUndo).toMatchObject({ success: false, error: expect.stringContaining("integer from 1 through 100") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finds pan on Channel Volume layouts and verifies the readback", async () => {
|
||||
const tools = getTrackTargetingTools(bridgeOptions);
|
||||
await (tools.set_clip_pan.handler as any)({ node_id: "audio-clip-1", pan: -50 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('result.trackType !== "audio"');
|
||||
expect(script).toContain("component.properties");
|
||||
expect(script).toContain('property.displayName === "Balance"');
|
||||
expect(script).toContain("panProperty.getValue()");
|
||||
expect(script).toContain("Premiere did not apply the requested pan");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await expect((tools.set_clip_pan.handler as any)({ node_id: "audio-clip-1", pan: 101 })).resolves.toMatchObject({ success: false });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports the documented ## zero-padded rename placeholder", async () => {
|
||||
const tools = getTrackTargetingTools(bridgeOptions);
|
||||
await (tools.batch_rename_clips.handler as any)({ pattern: "QA_Batch_##", track_type: "video", track_index: 0 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('.split("##").join(paddedSequenceNumber)');
|
||||
expect(script).toContain('while (paddedSequenceNumber.length < 2)');
|
||||
expect(script).toContain('"0" + paddedSequenceNumber');
|
||||
});
|
||||
|
||||
it("converts audio dB from Premiere's +15 dB normalized level and verifies the readback", async () => {
|
||||
const tools = getAudioTools(bridgeOptions);
|
||||
await (tools.adjust_audio_levels.handler as any)({ node_id: "clip-1", level_db: -6 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("requestedLevel");
|
||||
expect(script).toContain("levelProp.getValue()");
|
||||
expect(script).toContain("Premiere did not apply the requested audio level");
|
||||
expect(script).toContain("0.089125");
|
||||
expect(script).toContain("appliedDb");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await expect((tools.adjust_audio_levels.handler as any)({ node_id: "clip-1", level_db: 16 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("+15 dB") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preflights and verifies every batch effect target instead of swallowing failures", async () => {
|
||||
const tools = getClipboardTools(bridgeOptions);
|
||||
await (tools.batch_apply_effect.handler as any)({ effect_name: "Gaussian Blur", target: "selected" });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("function findQeClip(target)");
|
||||
expect(script).toContain("function countEffectComponents(clip)");
|
||||
expect(script).toContain("component count did not increase");
|
||||
expect(script).toContain("Batch effect application was only partially verified");
|
||||
expect(script).toContain("selectedIncompatible");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await expect((tools.batch_apply_effect.handler as any)({ effect_name: "Gaussian Blur", target: "track" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("track_type") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not report an empty QE audio-transition catalog as an available list", async () => {
|
||||
const tools = getTransitionsTools(bridgeOptions);
|
||||
await (tools.list_available_audio_transitions.handler as any)({});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("QE reported an empty audio-transition catalog");
|
||||
expect(script).toContain("no transition availability is claimed");
|
||||
expect(script).toContain('source: "qe.catalog"');
|
||||
});
|
||||
|
||||
it("uses Time objects and verifies audio keyframe values", async () => {
|
||||
const tools = getAudioTools(bridgeOptions);
|
||||
await (tools.add_audio_keyframes.handler as any)({
|
||||
node_id: "clip-1",
|
||||
keyframes: [{ time_seconds: 1.5, level_db: -12 }],
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("new Time()");
|
||||
expect(script).toContain("getValueAtTime(t)");
|
||||
expect(script).toContain("verificationErrors");
|
||||
});
|
||||
|
||||
it("verifies ripple delete and passes QE razor a sequence timecode", async () => {
|
||||
await (getAdvancedTools(bridgeOptions).ripple_delete.handler as any)({ node_id: "clip-1" });
|
||||
let script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("if (__findClip(deletedNodeId))");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await (getTimelineTools(bridgeOptions).split_clip.handler as any)({ time_seconds: 2 });
|
||||
script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("var expectedClipCount = clipCountBefore + eligibleBefore.length");
|
||||
expect(script).toContain("function __hasSegment");
|
||||
expect(script).toContain("var __razorTc");
|
||||
expect(script).toContain("track.razor(__razorTc)");
|
||||
expect(script).not.toContain("track.razor(cutTicks.toString())");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await (getTrackTargetingTools(bridgeOptions).razor_all_tracks.handler as any)({ time_seconds: 2 });
|
||||
script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("var __razorTc");
|
||||
expect(script).toContain("getVideoTrackAt(t).razor(__razorTc)");
|
||||
expect(script).toContain("getAudioTrackAt(t).razor(__razorTc)");
|
||||
});
|
||||
|
||||
it("targets the QE clip, preserves an unsupported speed boundary, and verifies track state", async () => {
|
||||
const tools = getTransitionsTools(bridgeOptions);
|
||||
await (tools.add_transition.handler as any)({
|
||||
transition_name: "Cross Dissolve",
|
||||
track_index: 0,
|
||||
cut_point_seconds: 2,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("var targetClip = incomingClip || outgoingClip");
|
||||
expect(script).toContain("__findQeClipByDomClip(qeTrack, targetClip)");
|
||||
expect(script).toContain('typeof qeClip.addTransition !== "function"');
|
||||
expect(script).toContain('qeClip.addTransition(transitionQE, targetHead, String(durationFrames), "0", 0.5, false, true)');
|
||||
expect(script).not.toContain("qeTrack.addTransition(");
|
||||
expect(script).toContain("transitionAtCut");
|
||||
expect(script).toContain("Math.abs(((transitionStart + transitionEnd) / 2) - cutTicks) <= frameTicks / 2");
|
||||
expect(script).toContain("DOM readback did not find it at the requested cut point");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await (tools.add_transition_to_clip.handler as any)({
|
||||
node_id: "clip-1", transition_name: "Cross Dissolve", position: "both",
|
||||
});
|
||||
const clipScript = mockedSendCommand.mock.calls[0][0];
|
||||
expect(clipScript).toContain('result.trackType !== "video"');
|
||||
expect(clipScript).toContain("__findQeClipByDomClip(qeTrack, result.clip)");
|
||||
expect(clipScript).toContain("var requestedCount = position === \"both\" ? 2 : 1");
|
||||
expect(clipScript).toContain('qeClip.addTransition(transitionQE, true, String(durationFrames), "0", 0.5, false, true)');
|
||||
expect(clipScript).toContain('qeClip.addTransition(transitionQE, false, String(durationFrames), "0", 0.5, false, true)');
|
||||
expect(clipScript).toContain("startVerified");
|
||||
expect(clipScript).toContain("endVerified");
|
||||
expect(clipScript).toContain("var verifiedMidpoint = (verifiedStart + verifiedEnd) / 2");
|
||||
expect(clipScript).toContain("the request was partially applied");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await (tools.batch_add_transitions.handler as any)({ transition_name: "Cross Dissolve" });
|
||||
const batchScript = mockedSendCommand.mock.calls[0][0];
|
||||
expect(batchScript).toContain("__findQeClipByDomClip(qeTrack, incomingClip)");
|
||||
expect(batchScript).toContain('qeClip.addTransition(transitionQE, true, String(durationFrames), "0", 0.5, false, true)');
|
||||
expect(batchScript).toContain("verifiedCount !== requestedCount");
|
||||
expect(batchScript).toContain("Math.abs(((readStart + readEnd) / 2) - expectedCut) <= frameTicks / 2");
|
||||
expect(batchScript).toContain("DOM readback did not find a transition at cut");
|
||||
});
|
||||
});
|
||||
|
||||
describe("discovery tools", () => {
|
||||
it("get_project_info generates correct script", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.get_project_info.handler as any)({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("app.project");
|
||||
expect(script).toContain("project.name");
|
||||
expect(script).toContain("project.path");
|
||||
expect(script).toContain("numSequences");
|
||||
expect(script).toContain("__result");
|
||||
});
|
||||
|
||||
it("list_project_items handles optional bin_path", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
|
||||
// Without bin_path
|
||||
await (tools.list_project_items.handler as any)({});
|
||||
let script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("var binPath = null");
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
// With bin_path
|
||||
await (tools.list_project_items.handler as any)({ bin_path: "Footage/Raw" });
|
||||
script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("Footage/Raw");
|
||||
});
|
||||
|
||||
it("list_project_items escapes bin_path", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.list_project_items.handler as any)({ bin_path: 'My "Folder"' });
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('My \\"Folder\\"');
|
||||
});
|
||||
|
||||
it("get_clip_properties escapes node_id", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.get_clip_properties.handler as any)({ node_id: "abc-123" });
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("abc-123");
|
||||
expect(script).toContain("__findClip");
|
||||
});
|
||||
|
||||
it("get_clip_at_position generates correct track lookup", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.get_clip_at_position.handler as any)({
|
||||
time_seconds: 5.5,
|
||||
track_index: 1,
|
||||
track_type: "video",
|
||||
});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("seq.videoTracks");
|
||||
expect(script).toContain("5.5");
|
||||
});
|
||||
|
||||
it("get_clip_at_position uses audio tracks when specified", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.get_clip_at_position.handler as any)({
|
||||
time_seconds: 2.0,
|
||||
track_index: 0,
|
||||
track_type: "audio",
|
||||
});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("seq.audioTracks");
|
||||
});
|
||||
|
||||
it("list_sequence_tracks defaults to active sequence", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.list_sequence_tracks.handler as any)({});
|
||||
|
||||
// Helpers are no longer inlined — the whole script is user code.
|
||||
const userCode = mockedSendCommand.mock.calls[0][0];
|
||||
expect(userCode).toContain("app.project.activeSequence");
|
||||
// Should NOT call __findSequence in user code when no sequence_id is provided
|
||||
expect(userCode).not.toContain('__findSequence("');
|
||||
});
|
||||
|
||||
it("list_sequence_tracks uses __findSequence when id provided", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.list_sequence_tracks.handler as any)({ sequence_id: "seq-1" });
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("__findSequence");
|
||||
expect(script).toContain("seq-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("caption tools", () => {
|
||||
it("create_caption_track generates correct script", async () => {
|
||||
const tools = getCaptionTools(bridgeOptions);
|
||||
const toolNames = Object.keys(tools);
|
||||
expect(toolNames).toContain("create_caption_track");
|
||||
|
||||
await (tools.create_caption_track.handler as any)({ item_id: "my-srt-file" });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("__result");
|
||||
expect(script).toContain("my-srt-file");
|
||||
expect(script).toContain("createCaptionTrack");
|
||||
expect(script).toContain("__captionTrackCount");
|
||||
expect(script).toContain("no caption track appeared in host readback");
|
||||
expect(script).toContain("accepted: true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("project-manager tools", () => {
|
||||
it("consolidate_and_transfer verifies a copied project in a new destination", async () => {
|
||||
const tools = getProjectManagerTools(bridgeOptions);
|
||||
expect(tools.consolidate_and_transfer).toBeDefined();
|
||||
expect(typeof tools.consolidate_and_transfer.handler).toBe("function");
|
||||
await (tools.consolidate_and_transfer.handler as any)({ destination_path: "/tmp/transfer" });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("destination_path must be a new, empty folder");
|
||||
expect(script).toContain('destination.getFiles("*.prproj")');
|
||||
expect(script).toContain("copiedProjectCount");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tool Handler Return Values", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("handlers return the result from sendCommand", async () => {
|
||||
const expected = { success: true, data: { version: "24.0" } };
|
||||
mockedSendCommand.mockResolvedValue(expected);
|
||||
|
||||
const tools = getHealthTools(bridgeOptions);
|
||||
const result = await (tools.ping.handler as any)({});
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it("handlers propagate sendCommand errors", async () => {
|
||||
mockedSendCommand.mockRejectedValue(new Error("Connection failed"));
|
||||
|
||||
const tools = getHealthTools(bridgeOptions);
|
||||
await expect((tools.ping.handler as any)({})).rejects.toThrow("Connection failed");
|
||||
});
|
||||
|
||||
it("handlers propagate failure results", async () => {
|
||||
const failResult = { success: false, error: "No active sequence" };
|
||||
mockedSendCommand.mockResolvedValue(failResult);
|
||||
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
const result = await (tools.get_active_sequence.handler as any)({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("No active sequence");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tool Naming Conventions", () => {
|
||||
it("all tool names use snake_case", () => {
|
||||
for (const mod of ALL_MODULES) {
|
||||
const tools = mod.getter(bridgeOptions);
|
||||
for (const name of Object.keys(tools)) {
|
||||
expect(name, `${mod.name}.${name} should be snake_case`).toMatch(
|
||||
/^[a-z][a-z0-9]*(_[a-z0-9]+)*$/
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("no duplicate tool names across modules", () => {
|
||||
const allNames: string[] = [];
|
||||
for (const mod of ALL_MODULES) {
|
||||
const tools = mod.getter(bridgeOptions);
|
||||
allNames.push(...Object.keys(tools));
|
||||
}
|
||||
const unique = new Set(allNames);
|
||||
expect(unique.size).toBe(allNames.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Script Generation Patterns", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedSendCommand.mockResolvedValue({ success: true, data: {} });
|
||||
});
|
||||
|
||||
it("all scripts contain __result or __error", async () => {
|
||||
for (const mod of ALL_MODULES) {
|
||||
const tools = mod.getter(bridgeOptions);
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
vi.clearAllMocks();
|
||||
try {
|
||||
// Call with minimal args — we only care about the generated script
|
||||
await tool.handler({});
|
||||
} catch {
|
||||
// Some handlers may throw if required args are missing, that's OK
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mockedSendCommand.mock.calls.length > 0) {
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
const hasResult = script.includes("__result") || script.includes("__error") || script.includes("__aeResult") || script.includes("__aeError");
|
||||
expect(hasResult, `${mod.name}.${name} script should use a host result helper`).toBe(true);
|
||||
}
|
||||
if (mockedSendRawCommand.mock.calls.length > 0) {
|
||||
// Raw commands (scripting module) may not follow the pattern
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("scripts are wrapped in IIFE with try/catch", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.get_project_info.handler as any)({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("(function() {");
|
||||
expect(script).toContain("} catch(e) {");
|
||||
expect(script).toContain("})();");
|
||||
});
|
||||
|
||||
it("scripts do not inline helpers; helpers source defines them", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.get_project_info.handler as any)({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
// Inlining ~14KB of helpers per command overflowed the ExtendScript stack
|
||||
// on long-lived engines; commands must stay lean.
|
||||
expect(script).not.toContain("function __jsonStringify(obj)");
|
||||
const helpers = getHelpersSource();
|
||||
expect(helpers).toContain("function __result(data)");
|
||||
expect(helpers).toContain("function __error(msg)");
|
||||
expect(helpers).toContain("TICKS_PER_SECOND");
|
||||
});
|
||||
});
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeTranscriptDeletionRanges,
|
||||
previewTranscriptEdit,
|
||||
transcriptRevision,
|
||||
} from "../../src/tools/transcript-edits.js";
|
||||
|
||||
describe("transcript edit range edge cases", () => {
|
||||
it.each([
|
||||
[null, "deletion 0 must be an object"],
|
||||
["not a range", "deletion 0 must be an object"],
|
||||
[{ start_seconds: -0.01, end_seconds: 1 }, "start_seconds must be a non-negative number"],
|
||||
[{ start_seconds: Number.NaN, end_seconds: 1 }, "start_seconds must be a non-negative number"],
|
||||
[{ start_seconds: 1, end_seconds: Number.POSITIVE_INFINITY }, "end_seconds must be greater than start_seconds"],
|
||||
[{ start_seconds: 1, end_seconds: 1 }, "end_seconds must be greater than start_seconds"],
|
||||
])("rejects invalid deletion input %j", (deletion, message) => {
|
||||
expect(() => normalizeTranscriptDeletionRanges([deletion])).toThrow(message);
|
||||
});
|
||||
|
||||
it("rounds normalized values and merges precisely adjacent ranges", () => {
|
||||
expect(normalizeTranscriptDeletionRanges([
|
||||
{ start_seconds: 2, end_seconds: 3.00000049 },
|
||||
{ start_seconds: 1.00000031, end_seconds: 2 },
|
||||
], 0.0000004)).toEqual([
|
||||
{ start_seconds: 1, end_seconds: 3.000001 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses end time as a stable secondary sort key before merging equal-start ranges", () => {
|
||||
expect(normalizeTranscriptDeletionRanges([
|
||||
{ start_seconds: 5.0000004, end_seconds: 8 },
|
||||
{ start_seconds: 5.0000003, end_seconds: 6 },
|
||||
])).toEqual([
|
||||
{ start_seconds: 5, end_seconds: 8 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transcript preview revision guard", () => {
|
||||
it("rejects empty transcript payloads before creating a revision", () => {
|
||||
expect(() => previewTranscriptEdit("", "", [{ start_seconds: 1, end_seconds: 2 }]))
|
||||
.toThrow("Premiere returned an empty transcript");
|
||||
expect(() => previewTranscriptEdit(null as unknown as string, "", [{ start_seconds: 1, end_seconds: 2 }]))
|
||||
.toThrow("Premiere returned an empty transcript");
|
||||
});
|
||||
|
||||
it("does not treat an empty requested revision as a valid transcript lock", () => {
|
||||
const json = '{"segments":[{"text":"approve"}]}';
|
||||
expect(transcriptRevision(json)).not.toBe("");
|
||||
expect(() => previewTranscriptEdit(json, "", [{ start_seconds: 1, end_seconds: 2 }]))
|
||||
.toThrow("Transcript revision does not match");
|
||||
});
|
||||
});
|
||||
Executable
+222
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
import {
|
||||
normalizeTranscriptDeletionRanges,
|
||||
planTranscriptRoughCut,
|
||||
previewTranscriptEdit,
|
||||
transcriptRevision,
|
||||
} from "../../src/tools/transcript-edits.js";
|
||||
import { getUxpTools } from "../../src/tools/uxp.js";
|
||||
|
||||
describe("transcript edit planning", () => {
|
||||
it("creates stable transcript revisions", () => {
|
||||
expect(transcriptRevision('{"segments":[]}')).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
expect(transcriptRevision('{"segments":[]}')).toBe(transcriptRevision('{"segments":[]}'));
|
||||
expect(transcriptRevision('{"segments":[1]}')).not.toBe(transcriptRevision('{"segments":[]}'));
|
||||
});
|
||||
|
||||
it("sorts, adds handles, and merges overlapping deletion ranges", () => {
|
||||
expect(normalizeTranscriptDeletionRanges([
|
||||
{ start_seconds: 5, end_seconds: 7 },
|
||||
{ start_seconds: 1, end_seconds: 3 },
|
||||
{ start_seconds: 2.9, end_seconds: 5.1 },
|
||||
], 0.1)).toEqual([{ start_seconds: 0.9, end_seconds: 7.1 }]);
|
||||
});
|
||||
|
||||
it("rejects invalid or excessive deletion ranges", () => {
|
||||
expect(() => normalizeTranscriptDeletionRanges([])).toThrow("at least one");
|
||||
expect(() => normalizeTranscriptDeletionRanges([{ start_seconds: 2, end_seconds: 1 }])).toThrow("greater than");
|
||||
expect(() => normalizeTranscriptDeletionRanges([{ start_seconds: 0, end_seconds: 1 }], 11)).toThrow("between 0 and 10");
|
||||
expect(() => normalizeTranscriptDeletionRanges(Array.from({ length: 101 }, (_, i) => ({ start_seconds: i, end_seconds: i + 0.5 })))).toThrow("limited to 100");
|
||||
});
|
||||
|
||||
it("revision-locks previews and returns a deterministic confirmation token", () => {
|
||||
const json = '{"segments":[{"text":"remove this"}]}';
|
||||
const revision = transcriptRevision(json);
|
||||
const first = previewTranscriptEdit(json, revision, [
|
||||
{ start_seconds: 4, end_seconds: 5 },
|
||||
{ start_seconds: 1, end_seconds: 2.5 },
|
||||
]);
|
||||
const second = previewTranscriptEdit(json, revision, [
|
||||
{ start_seconds: 1, end_seconds: 2.5 },
|
||||
{ start_seconds: 4, end_seconds: 5 },
|
||||
]);
|
||||
expect(first).toMatchObject({ deletedSeconds: 2.5, applied: false });
|
||||
expect(first.confirmationToken).toBe(second.confirmationToken);
|
||||
expect(() => previewTranscriptEdit(json, transcriptRevision("different"), [{ start_seconds: 1, end_seconds: 2 }])).toThrow("does not match");
|
||||
});
|
||||
|
||||
it("maps transcript deletions to descending verified 1x timeline cut instructions", () => {
|
||||
const json = '{"segments":[]}';
|
||||
const preview = previewTranscriptEdit(json, transcriptRevision(json), [
|
||||
{ start_seconds: 2, end_seconds: 4 },
|
||||
{ start_seconds: 8, end_seconds: 12 },
|
||||
]);
|
||||
const plan = planTranscriptRoughCut(preview, [{
|
||||
placement_id: "clip-1",
|
||||
track_type: "video",
|
||||
track_index: 0,
|
||||
source_in_seconds: 0,
|
||||
source_out_seconds: 10,
|
||||
timeline_start_seconds: 20,
|
||||
timeline_end_seconds: 30,
|
||||
}]);
|
||||
expect(plan).toMatchObject({
|
||||
applied: false,
|
||||
requiresDuplicateSequence: true,
|
||||
requiresPostMutationRequery: true,
|
||||
ripple: true,
|
||||
unmappedDeletionRanges: [],
|
||||
});
|
||||
expect(plan.steps.map((step) => step.timeline_range)).toEqual([
|
||||
{ start_seconds: 28, end_seconds: 30 },
|
||||
{ start_seconds: 22, end_seconds: 24 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects retimed placements instead of guessing source-to-timeline mapping", () => {
|
||||
const json = '{"segments":[]}';
|
||||
const preview = previewTranscriptEdit(json, transcriptRevision(json), [{ start_seconds: 1, end_seconds: 2 }]);
|
||||
expect(() => planTranscriptRoughCut(preview, [{
|
||||
placement_id: "retimed",
|
||||
track_type: "video",
|
||||
track_index: 0,
|
||||
source_in_seconds: 0,
|
||||
source_out_seconds: 10,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 5,
|
||||
}])).toThrow("retimed");
|
||||
});
|
||||
|
||||
it("fails closed for malformed placements and reports unmapped non-ripple ranges", () => {
|
||||
const json = '{"segments":[]}';
|
||||
const preview = previewTranscriptEdit(json, transcriptRevision(json), [{ start_seconds: 20, end_seconds: 21 }]);
|
||||
const valid = {
|
||||
placement_id: "clip-1", track_type: "audio", track_index: 0,
|
||||
source_in_seconds: 0, source_out_seconds: 10,
|
||||
timeline_start_seconds: 0, timeline_end_seconds: 10,
|
||||
} as const;
|
||||
|
||||
expect(() => planTranscriptRoughCut(preview, [])).toThrow("at least one");
|
||||
expect(() => planTranscriptRoughCut(preview, Array(257).fill(valid))).toThrow("limited to 256");
|
||||
expect(() => planTranscriptRoughCut(preview, [null])).toThrow("must be an object");
|
||||
expect(() => planTranscriptRoughCut(preview, [{ ...valid, placement_id: "" }])).toThrow("1-512");
|
||||
expect(() => planTranscriptRoughCut(preview, [{ ...valid, track_type: "caption" }])).toThrow("video or audio");
|
||||
expect(() => planTranscriptRoughCut(preview, [{ ...valid, track_index: -1 }])).toThrow("non-negative integer");
|
||||
expect(() => planTranscriptRoughCut(preview, [{ ...valid, source_out_seconds: Number.NaN }])).toThrow("finite number");
|
||||
expect(() => planTranscriptRoughCut(preview, [{ ...valid, source_out_seconds: 0 }])).toThrow("increasing");
|
||||
|
||||
const plan = planTranscriptRoughCut(preview, [valid], false);
|
||||
expect(plan).toMatchObject({ ripple: false, steps: [], unmappedDeletionRanges: preview.deletionRanges });
|
||||
});
|
||||
});
|
||||
|
||||
describe("transcript UXP MCP tools", () => {
|
||||
it("exports native transcript JSON with a stable revision", async () => {
|
||||
const json = '{"segments":[]}';
|
||||
const request = vi.fn().mockResolvedValue({ projectGuid: "project-1", projectItemId: "clip-1", projectItemName: "Interview", json });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const result = await getUxpTools(bridge).get_clip_transcript_uxp.handler({ project_item_id: "clip-1" });
|
||||
expect(request).toHaveBeenCalledWith("transcript.export", { projectItemId: "clip-1" });
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: { result: { projectGuid: "project-1", projectItemId: "clip-1", json, transcriptRevision: transcriptRevision(json) } },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a guarded transcript replacement to the documented UXP command", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ committed: true, verified: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const result = await getUxpTools(bridge).import_transcript_uxp.handler({
|
||||
project_item_id: "clip-1",
|
||||
project_guid: "project-1",
|
||||
expected_transcript_revision: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
replacement_transcript_json: '{"segments":[]}',
|
||||
confirm_destructive: true,
|
||||
operation_id: "transcript-replace-1",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("transcript.import", {
|
||||
projectItemId: "clip-1",
|
||||
expectedProjectGuid: "project-1",
|
||||
expectedTranscriptRevision: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
json: '{"segments":[]}',
|
||||
confirmDestructive: true,
|
||||
operationId: "transcript-replace-1",
|
||||
});
|
||||
expect(result).toMatchObject({ success: true, data: { result: { committed: true, verified: true } } });
|
||||
});
|
||||
|
||||
it("fails closed before the bridge when destructive confirmation or replay identity is missing", async () => {
|
||||
const request = vi.fn();
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpTools(bridge).import_transcript_uxp;
|
||||
await expect(tool.handler({
|
||||
project_item_id: "clip-1", project_guid: "project-1", expected_transcript_revision: null,
|
||||
replacement_transcript_json: '{"segments":[]}', confirm_destructive: false, operation_id: "transcript-replace-2",
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("confirm_destructive") });
|
||||
await expect(tool.handler({
|
||||
project_item_id: "clip-1", project_guid: "project-1", expected_transcript_revision: null,
|
||||
replacement_transcript_json: '{"segments":[]}', confirm_destructive: true, operation_id: "",
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("operation_id") });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps bounded search arguments to the native transcript command", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ matches: [] });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
await getUxpTools(bridge).search_clip_transcript_uxp.handler({
|
||||
project_item_name: "Interview",
|
||||
query: "launch date",
|
||||
case_sensitive: true,
|
||||
max_results: 10,
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("transcript.search", {
|
||||
projectItemName: "Interview",
|
||||
query: "launch date",
|
||||
caseSensitive: true,
|
||||
maxResults: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("re-exports the transcript before producing a revision-locked preview", async () => {
|
||||
const json = '{"segments":[{"text":"cut this"}]}';
|
||||
const request = vi.fn().mockResolvedValue({ projectItemId: "clip-1", projectItemName: "Interview", json });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const result = await getUxpTools(bridge).preview_transcript_edit_uxp.handler({
|
||||
project_item_id: "clip-1",
|
||||
transcript_revision: transcriptRevision(json),
|
||||
deletions: [{ start_seconds: 2, end_seconds: 4 }],
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("transcript.export", { projectItemId: "clip-1" });
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: { result: { projectItemId: "clip-1", deletedSeconds: 2, applied: false } },
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a transcript rough-cut plan without mutating Premiere", async () => {
|
||||
const json = '{"segments":[{"text":"cut this"}]}';
|
||||
const request = vi.fn().mockResolvedValue({ projectItemId: "clip-1", projectItemName: "Interview", json });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const result = await getUxpTools(bridge).plan_transcript_rough_cut_uxp.handler({
|
||||
project_item_id: "clip-1",
|
||||
transcript_revision: transcriptRevision(json),
|
||||
deletions: [{ start_seconds: 2, end_seconds: 4 }],
|
||||
placements: [{
|
||||
placement_id: "timeline-clip-1",
|
||||
track_type: "video",
|
||||
track_index: 0,
|
||||
source_in_seconds: 0,
|
||||
source_out_seconds: 10,
|
||||
timeline_start_seconds: 20,
|
||||
timeline_end_seconds: 30,
|
||||
}],
|
||||
});
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(request).toHaveBeenCalledWith("transcript.export", { projectItemId: "clip-1" });
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: { result: { applied: false, requiresDuplicateSequence: true, steps: [{ timeline_range: { start_seconds: 22, end_seconds: 24 } }] } },
|
||||
});
|
||||
});
|
||||
});
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
import { getUxpTools } from "../../src/tools/uxp.js";
|
||||
|
||||
const ADVANCED_WORKFLOW_TOOLS = [
|
||||
"inspect_project_selection_uxp",
|
||||
"inspect_project_tree_uxp",
|
||||
"manage_markers_uxp",
|
||||
"apply_beat_markers_uxp",
|
||||
"create_silence_cut_source_stringout_uxp",
|
||||
"organize_project_items_uxp",
|
||||
"manage_sequence_settings_uxp",
|
||||
"manage_sequence_display_format_uxp",
|
||||
"import_project_media_uxp",
|
||||
"automate_effect_parameters_uxp",
|
||||
"transform_track_item_uxp",
|
||||
"edit_timeline_uxp",
|
||||
"manage_sequences_uxp",
|
||||
"encode_media_uxp",
|
||||
] as const;
|
||||
|
||||
describe("advanced stable UXP workflow MCP catalog", () => {
|
||||
it("publishes fourteen advanced tools with closed, bounded schemas", () => {
|
||||
const bridge = { request: vi.fn(), getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge) as Record<string, { parameters: Record<string, unknown> }>;
|
||||
|
||||
expect(Object.keys(tools)).toEqual(expect.arrayContaining(ADVANCED_WORKFLOW_TOOLS));
|
||||
expect(ADVANCED_WORKFLOW_TOOLS).toHaveLength(14);
|
||||
for (const name of ADVANCED_WORKFLOW_TOOLS) {
|
||||
const parameters = tools[name].parameters;
|
||||
expect(parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
});
|
||||
if (name !== "inspect_project_tree_uxp") {
|
||||
expect(parameters).toMatchObject({
|
||||
required: expect.arrayContaining([name === "apply_beat_markers_uxp" ? "beat_times_seconds" : name === "create_silence_cut_source_stringout_uxp" ? "source_project_item_id" : "action"]),
|
||||
});
|
||||
}
|
||||
}
|
||||
expect(tools.inspect_project_selection_uxp.parameters).toMatchObject({
|
||||
properties: { action: { enum: ["views", "selection"] }, view_id: { maxLength: 128 } },
|
||||
});
|
||||
expect(tools.inspect_project_tree_uxp.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
properties: { max_items: { minimum: 1, maximum: 512 }, max_depth: { minimum: 0, maximum: 16 } },
|
||||
});
|
||||
expect(tools.manage_markers_uxp.parameters).toMatchObject({
|
||||
properties: {
|
||||
action: { enum: ["inspect", "add", "update", "remove", "remove_many"] },
|
||||
marker_snapshots: {
|
||||
minItems: 1, maxItems: 128,
|
||||
items: { required: ["marker_guid", "expected_name", "expected_start_seconds", "expected_duration_seconds"] },
|
||||
},
|
||||
confirm_destructive: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
expect(tools.manage_sequence_display_format_uxp.parameters).toMatchObject({
|
||||
properties: {
|
||||
action: { enum: ["inspect", "update"] },
|
||||
expected_sequence_guid: { maxLength: 128 },
|
||||
expected_display_formats: { required: ["audio_display_format", "video_display_format"] },
|
||||
updates: { additionalProperties: false },
|
||||
},
|
||||
});
|
||||
expect(tools.import_project_media_uxp.parameters).toMatchObject({
|
||||
required: ["action", "confirm_non_undoable"],
|
||||
properties: { paths: { maxItems: 100 }, confirm_non_undoable: { type: "boolean" } },
|
||||
});
|
||||
expect(tools.automate_effect_parameters_uxp.parameters).toMatchObject({
|
||||
properties: {
|
||||
action: { enum: ["inspect", "inspect_point_value", "inspect_point_displacement", "set_point_value", "inspect_color_value", "set_color_value", "inspect_keyframe", "set_value", "add_keyframe", "remove_keyframe", "remove_keyframe_range", "set_interpolation", "inspect_time_varying", "set_time_varying"] },
|
||||
point: { required: ["x", "y"], additionalProperties: false },
|
||||
expected_point_snapshot: { required: ["project_id", "sequence_id", "media_type", "track_index", "clip_index", "component_index", "component_id", "param_index", "param_name", "time_varying", "point"], additionalProperties: false },
|
||||
color: { required: ["red", "green", "blue", "alpha"], additionalProperties: false },
|
||||
expected_color_snapshot: { required: ["project_id", "sequence_id", "media_type", "track_index", "clip_index", "component_index", "component_id", "param_index", "param_name", "time_varying", "color"], additionalProperties: false },
|
||||
expected_sequence_id: { maxLength: 128 }, expected_time_varying: { type: "boolean" },
|
||||
expected_keyframe_times_seconds: { maxItems: 256, uniqueItems: true },
|
||||
keyframe_direction: { enum: ["at", "next", "previous", "nearest"] },
|
||||
time_varying: { type: "boolean" }, confirm_disable_time_varying: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
expect(tools.encode_media_uxp.parameters).toMatchObject({
|
||||
properties: {
|
||||
action: { enum: ["preflight", "jobs", "wait", "sequence", "project_item", "file"] },
|
||||
job_id: { pattern: expect.any(String) },
|
||||
timeout_ms: { maximum: 60000 },
|
||||
output_file: { maxLength: 4096 },
|
||||
confirm_external_write: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps guarded parameter keyframe and animation-mode reads to their exact UXP commands", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
const target = {
|
||||
media_type: "video", track_index: 0, clip_index: 1, component_index: 2, param_index: 3,
|
||||
expected_component_id: "ADBE Opacity", expected_param_name: "Opacity",
|
||||
};
|
||||
|
||||
await tools.automate_effect_parameters_uxp.handler({ action: "inspect_keyframe", ...target, time_seconds: 4, keyframe_direction: "next" });
|
||||
await tools.automate_effect_parameters_uxp.handler({ action: "inspect_keyframe", ...target, time_seconds: 4, end_seconds: 6, keyframe_direction: "nearest" });
|
||||
await tools.automate_effect_parameters_uxp.handler({ action: "inspect_time_varying", ...target, time_seconds: 4 });
|
||||
await tools.automate_effect_parameters_uxp.handler({
|
||||
action: "set_time_varying", ...target, expected_sequence_id: "sequence-1", expected_time_varying: true,
|
||||
expected_keyframe_times_seconds: [1, 2], time_varying: false, confirm_disable_time_varying: true,
|
||||
operation_id: "parameter-animation-op",
|
||||
});
|
||||
|
||||
expect(request.mock.calls).toEqual([
|
||||
["parameters.keyframe.inspect", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Opacity", expectedParamName: "Opacity", timeSeconds: 4, direction: "next",
|
||||
}],
|
||||
["parameters.keyframe.inspect", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Opacity", expectedParamName: "Opacity", timeSeconds: 4, direction: "nearest", endSeconds: 6,
|
||||
}],
|
||||
["parameters.timeVarying.inspect", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Opacity", expectedParamName: "Opacity",
|
||||
}],
|
||||
["parameters.timeVarying.set", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedSequenceId: "sequence-1", expectedComponentId: "ADBE Opacity", expectedParamName: "Opacity",
|
||||
expectedTimeVarying: true, expectedKeyframeTimesSeconds: [1, 2], timeVarying: false,
|
||||
confirmDisableTimeVarying: true, operationId: "parameter-animation-op",
|
||||
}],
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps guarded static PointF inspection and update actions to the exact UXP commands", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
const target = {
|
||||
media_type: "video", track_index: 0, clip_index: 1, component_index: 2, param_index: 3,
|
||||
expected_component_id: "ADBE Motion", expected_param_name: "Position",
|
||||
};
|
||||
const expectedPointSnapshot = {
|
||||
project_id: "project-1", sequence_id: "sequence-1", media_type: "video", track_index: 0, clip_index: 1,
|
||||
component_index: 2, component_id: "ADBE Motion", param_index: 3, param_name: "Position", time_varying: false,
|
||||
point: { x: 960, y: 540 },
|
||||
};
|
||||
|
||||
await tools.automate_effect_parameters_uxp.handler({ action: "inspect_point_value", ...target });
|
||||
await tools.automate_effect_parameters_uxp.handler({
|
||||
action: "set_point_value", ...target, expected_point_snapshot: expectedPointSnapshot,
|
||||
point: { x: 1100, y: 600 }, confirm_set_point: true, operation_id: "point-tool-op",
|
||||
});
|
||||
|
||||
expect(request.mock.calls).toEqual([
|
||||
["parameters.point.inspect", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Motion", expectedParamName: "Position",
|
||||
}],
|
||||
["parameters.point.set", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Motion", expectedParamName: "Position", point: { x: 1100, y: 600 },
|
||||
expectedSnapshot: {
|
||||
projectId: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0, clipIndex: 1,
|
||||
componentIndex: 2, componentId: "ADBE Motion", paramIndex: 3, paramName: "Position", timeVarying: false,
|
||||
point: { x: 960, y: 540 },
|
||||
},
|
||||
confirmSetPoint: true, operationId: "point-tool-op",
|
||||
}],
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps bounded native PointF displacement inspection to its exact UXP command", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ straightLineDistance: 5 });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
await tools.automate_effect_parameters_uxp.handler({
|
||||
action: "inspect_point_displacement", media_type: "video", track_index: 0, clip_index: 1,
|
||||
component_index: 2, param_index: 3, expected_component_id: "ADBE Motion", expected_param_name: "Position",
|
||||
time_seconds: 2, end_seconds: 5,
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("parameters.point.displacement.inspect", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Motion", expectedParamName: "Position", startSeconds: 2, endSeconds: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps guarded static Color inspection and update actions to the exact UXP commands", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
const target = {
|
||||
media_type: "video", track_index: 0, clip_index: 1, component_index: 2, param_index: 3,
|
||||
expected_component_id: "ADBE Lumetri", expected_param_name: "Tint",
|
||||
};
|
||||
const expectedColorSnapshot = {
|
||||
project_id: "project-1", sequence_id: "sequence-1", media_type: "video", track_index: 0, clip_index: 1,
|
||||
component_index: 2, component_id: "ADBE Lumetri", param_index: 3, param_name: "Tint", time_varying: false,
|
||||
color: { red: 0.1, green: 0.2, blue: 0.3, alpha: 1 },
|
||||
};
|
||||
|
||||
await tools.automate_effect_parameters_uxp.handler({ action: "inspect_color_value", ...target });
|
||||
await tools.automate_effect_parameters_uxp.handler({
|
||||
action: "set_color_value", ...target, expected_color_snapshot: expectedColorSnapshot,
|
||||
color: { red: 0.9, green: 0.8, blue: 0.7, alpha: 0.6 }, confirm_set_color: true, operation_id: "color-tool-op",
|
||||
});
|
||||
|
||||
expect(request.mock.calls).toEqual([
|
||||
["parameters.color.inspect", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Lumetri", expectedParamName: "Tint",
|
||||
}],
|
||||
["parameters.color.set", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Lumetri", expectedParamName: "Tint",
|
||||
color: { red: 0.9, green: 0.8, blue: 0.7, alpha: 0.6 },
|
||||
expectedSnapshot: {
|
||||
projectId: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0, clipIndex: 1,
|
||||
componentIndex: 2, componentId: "ADBE Lumetri", paramIndex: 3, paramName: "Tint", timeVarying: false,
|
||||
color: { red: 0.1, green: 0.2, blue: 0.3, alpha: 1 },
|
||||
},
|
||||
confirmSetColor: true, operationId: "color-tool-op",
|
||||
}],
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps every consolidated tool to its exact camel-case UXP command contract", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
|
||||
await tools.inspect_project_selection_uxp.handler({ action: "selection", view_id: "view-1" });
|
||||
await tools.inspect_project_tree_uxp.handler({ max_items: 12, max_depth: 3 });
|
||||
await tools.manage_markers_uxp.handler({
|
||||
action: "update", owner_type: "project_item", project_item_id: "clip-1",
|
||||
marker_guid: "marker-1", expected_name: "Old", name: "New", color_index: 3,
|
||||
operation_id: "marker-op",
|
||||
});
|
||||
await tools.manage_markers_uxp.handler({
|
||||
action: "remove_many", owner_type: "sequence", sequence_id: "sequence-1", marker_guid: "single-marker", expected_name: "Ignored", confirm_destructive: true,
|
||||
marker_snapshots: [{ marker_guid: "marker-1", expected_name: "Old", expected_start_seconds: 1, expected_duration_seconds: 0 }],
|
||||
operation_id: "marker-batch-op",
|
||||
});
|
||||
await tools.apply_beat_markers_uxp.handler({
|
||||
beat_times_seconds: [0.5, 1, 1.5], sequence_id: "sequence-1", offset_seconds: 2,
|
||||
name_prefix: "Downbeat", comments: "Detected beat grid", operation_id: "beat-grid-op",
|
||||
});
|
||||
await tools.create_silence_cut_source_stringout_uxp.handler({
|
||||
source_project_item_id: "clip-1", sequence_name: "Interview Tight", duration_seconds: 10,
|
||||
frame_rate: 30, silence_ranges: [{ start_seconds: 2, end_seconds: 4 }],
|
||||
keep_handle_frames: 0, confirm_non_undoable: true, operation_id: "silence-op",
|
||||
});
|
||||
await tools.organize_project_items_uxp.handler({
|
||||
action: "create_smart_bin", parent_bin_id: "bin-1", name: "Selects",
|
||||
search_query: "rating:5", operation_id: "bin-op",
|
||||
});
|
||||
await tools.manage_sequence_settings_uxp.handler({
|
||||
action: "update", sequence_id: "sequence-1",
|
||||
updates: { maximum_bit_depth: true, video_frame_rate: 24, video_width: 1920 },
|
||||
operation_id: "settings-op",
|
||||
});
|
||||
await tools.manage_sequence_display_format_uxp.handler({
|
||||
action: "update", sequence_id: "sequence-1", expected_sequence_guid: "sequence-1",
|
||||
expected_display_formats: { audio_display_format: 1, video_display_format: 20 },
|
||||
updates: { audio_display_format: 2, video_display_format: 26 }, operation_id: "display-format-op",
|
||||
});
|
||||
await tools.import_project_media_uxp.handler({
|
||||
action: "ae_comps", aep_path: "D:/Approved/graphics.aep",
|
||||
comp_names: ["Lower Third"], target_bin_id: "bin-1",
|
||||
confirm_non_undoable: true, operation_id: "import-op",
|
||||
});
|
||||
await tools.automate_effect_parameters_uxp.handler({
|
||||
action: "add_keyframe", media_type: "video", track_index: 0, clip_index: 1,
|
||||
component_index: 2, param_index: 3, expected_component_id: "ADBE Opacity",
|
||||
expected_param_name: "Opacity", value: 75, time_seconds: 2.5,
|
||||
operation_id: "keyframe-op",
|
||||
});
|
||||
await tools.transform_track_item_uxp.handler({
|
||||
action: "update", media_type: "audio", track_index: 1, clip_index: 2,
|
||||
expected_start_seconds: 10, expected_end_seconds: 20, move_by_seconds: 1.5,
|
||||
disabled: false, operation_id: "track-op",
|
||||
});
|
||||
await tools.edit_timeline_uxp.handler({
|
||||
action: "insert_mogrt_path", file_path: "D:/Approved/title.mogrt",
|
||||
time_seconds: 4, video_track_index: 2, audio_track_index: 0,
|
||||
confirm_non_undoable: true, operation_id: "timeline-op",
|
||||
});
|
||||
await tools.manage_sequences_uxp.handler({
|
||||
action: "create_from_media", name: "Assembly", project_item_ids: ["clip-1", "clip-2"],
|
||||
target_bin_id: "bin-1", confirm_non_undoable: true, operation_id: "sequence-op",
|
||||
});
|
||||
await tools.encode_media_uxp.handler({
|
||||
action: "file", file_path: "D:/Approved/input.mov", output_file: "D:/Approved/output.mp4",
|
||||
preset_file: "D:/Approved/h264.epr", in_seconds: 1, out_seconds: 8, work_area: 0,
|
||||
remove_upon_completion: true, start_queue_immediately: false,
|
||||
confirm_external_write: true, operation_id: "encode-op",
|
||||
});
|
||||
await tools.encode_media_uxp.handler({
|
||||
action: "wait", job_id: "encode-op", timeout_ms: 5000,
|
||||
});
|
||||
await tools.encode_media_uxp.handler({ action: "jobs", limit: 3 });
|
||||
await tools.encode_media_uxp.handler({ action: "wait", job_id: "encode-op" });
|
||||
|
||||
expect(request.mock.calls).toEqual([
|
||||
["projectSelection.inspect", { viewId: "view-1" }],
|
||||
["projectTree.inspect", { maxItems: 12, maxDepth: 3 }],
|
||||
["markers.update", {
|
||||
ownerType: "projectItem", projectItemId: "clip-1", markerGuid: "marker-1",
|
||||
expectedName: "Old", name: "New", colorIndex: 3, operationId: "marker-op",
|
||||
}],
|
||||
["markers.removeMany", {
|
||||
ownerType: "sequence", sequenceId: "sequence-1",
|
||||
markerSnapshots: [{ markerGuid: "marker-1", expectedName: "Old", expectedStartSeconds: 1, expectedDurationSeconds: 0 }],
|
||||
confirmDestructive: true, operationId: "marker-batch-op",
|
||||
}],
|
||||
["markers.addBeatGrid", {
|
||||
beatTimesSeconds: [0.5, 1, 1.5], sequenceId: "sequence-1", offsetSeconds: 2,
|
||||
namePrefix: "Downbeat", comments: "Detected beat grid", operationId: "beat-grid-op",
|
||||
}],
|
||||
["silence.deriveSequence", {
|
||||
sourceProjectItemId: "clip-1", name: "Interview Tight",
|
||||
keepRanges: [
|
||||
{ startFrame: 0, endFrame: 60, startSeconds: 0, endSeconds: 2 },
|
||||
{ startFrame: 120, endFrame: 300, startSeconds: 4, endSeconds: 10 },
|
||||
],
|
||||
confirmNonUndoable: true, operationId: "silence-op",
|
||||
}],
|
||||
["bins.createSmart", {
|
||||
parentBinId: "bin-1", name: "Selects", searchQuery: "rating:5", operationId: "bin-op",
|
||||
}],
|
||||
["sequenceSettings.update", {
|
||||
sequenceId: "sequence-1",
|
||||
updates: { maximumBitDepth: true, videoFrameRate: 24, videoWidth: 1920 },
|
||||
operationId: "settings-op",
|
||||
}],
|
||||
["sequence.displayFormat.update", {
|
||||
sequenceId: "sequence-1", expectedSequenceGuid: "sequence-1",
|
||||
expectedDisplayFormats: { audioDisplayFormat: 1, videoDisplayFormat: 20 },
|
||||
updates: { audioDisplayFormat: 2, videoDisplayFormat: 26 }, operationId: "display-format-op",
|
||||
}],
|
||||
["project.import", {
|
||||
mode: "aeComps", aepPath: "D:/Approved/graphics.aep", compNames: ["Lower Third"],
|
||||
targetBinId: "bin-1", confirmNonUndoable: true, operationId: "import-op",
|
||||
}],
|
||||
["parameters.keyframeAdd", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Opacity", expectedParamName: "Opacity", timeSeconds: 2.5,
|
||||
value: 75, operationId: "keyframe-op",
|
||||
}],
|
||||
["trackItem.update", {
|
||||
mediaType: "audio", trackIndex: 1, clipIndex: 2, expectedStartSeconds: 10,
|
||||
expectedEndSeconds: 20, moveBySeconds: 1.5, disabled: false, operationId: "track-op",
|
||||
}],
|
||||
["timeline.mogrtPath", {
|
||||
timeSeconds: 4, videoTrackIndex: 2, audioTrackIndex: 0,
|
||||
filePath: "D:/Approved/title.mogrt", confirmNonUndoable: true,
|
||||
operationId: "timeline-op",
|
||||
}],
|
||||
["sequences.createFromMedia", {
|
||||
name: "Assembly", projectItemIds: ["clip-1", "clip-2"], targetBinId: "bin-1",
|
||||
confirmNonUndoable: true, operationId: "sequence-op",
|
||||
}],
|
||||
["encoder.file", {
|
||||
filePath: "D:/Approved/input.mov", outputFile: "D:/Approved/output.mp4",
|
||||
presetFile: "D:/Approved/h264.epr", inSeconds: 1, outSeconds: 8, workArea: 0,
|
||||
removeUponCompletion: true, startQueueImmediately: false,
|
||||
confirmExternalWrite: true, operationId: "encode-op",
|
||||
}],
|
||||
["encoder.wait", { jobId: "encode-op", timeoutMs: 5000 }, { minimumTimeoutMs: 10000 }],
|
||||
["encoder.jobs", { limit: 3 }],
|
||||
["encoder.wait", { jobId: "encode-op" }, { minimumTimeoutMs: 5000 }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unknown public actions without contacting Premiere", async () => {
|
||||
const request = vi.fn();
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
const results = await Promise.all([
|
||||
tools.inspect_project_selection_uxp.handler({ action: "destroy_everything" }),
|
||||
tools.manage_markers_uxp.handler({ action: "destroy_everything" }),
|
||||
tools.organize_project_items_uxp.handler({ action: "destroy_everything" }),
|
||||
tools.transform_track_item_uxp.handler({ action: "destroy_everything" }),
|
||||
]);
|
||||
|
||||
expect(results).toEqual(Array.from({ length: 4 }, () => ({
|
||||
success: false,
|
||||
error: "Unsupported workflow action: destroy_everything",
|
||||
})));
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unreviewed or duplicate marker batch removal before contacting Premiere", async () => {
|
||||
const request = vi.fn();
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
const snapshot = { marker_guid: "marker-1", expected_name: "Beat", expected_start_seconds: 0, expected_duration_seconds: 0 };
|
||||
|
||||
await expect(tools.manage_markers_uxp.handler({ action: "remove_many", marker_snapshots: [snapshot] })).resolves.toMatchObject({
|
||||
success: false, error: expect.stringContaining("confirm_destructive=true"),
|
||||
});
|
||||
await expect(tools.manage_markers_uxp.handler({
|
||||
action: "remove_many", confirm_destructive: true, marker_snapshots: [snapshot, snapshot],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("duplicate marker_guid") });
|
||||
await expect(tools.manage_markers_uxp.handler({
|
||||
action: "remove_many", confirm_destructive: true, marker_snapshots: [null],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("marker_snapshots[0] must be an object") });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes Error and non-Error bridge rejections", async () => {
|
||||
const request = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("Premiere unavailable"))
|
||||
.mockRejectedValueOnce("transport closed");
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
|
||||
await expect(tools.inspect_project_selection_uxp.handler({ action: "views" })).resolves.toEqual({
|
||||
success: false,
|
||||
error: "Premiere unavailable",
|
||||
});
|
||||
await expect(tools.manage_sequence_settings_uxp.handler({ action: "get" })).resolves.toEqual({
|
||||
success: false,
|
||||
error: "transport closed",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing actions in mapped dispatchers before bridge access", async () => {
|
||||
const request = vi.fn();
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
|
||||
const results = await Promise.all([
|
||||
tools.import_project_media_uxp.handler({ confirm_non_undoable: true }),
|
||||
tools.automate_effect_parameters_uxp.handler({}),
|
||||
tools.edit_timeline_uxp.handler({}),
|
||||
tools.manage_sequences_uxp.handler({}),
|
||||
tools.encode_media_uxp.handler({}),
|
||||
]);
|
||||
|
||||
expect(results).toEqual(Array.from({ length: 5 }, () => ({
|
||||
success: false,
|
||||
error: "Unsupported workflow action: undefined",
|
||||
})));
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpCloneWorkflowTools } from "../../src/tools/uxp-clone-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
const source = { project_item_id: "source-1", start_seconds: 0, end_seconds: 10, in_seconds: 20, out_seconds: 30, duration_seconds: 10, speed: 1, reversed: false };
|
||||
|
||||
describe("public guarded append-only track-item duplicate MCP tool", () => {
|
||||
it("uses a closed complete source snapshot schema and translates snake_case apply arguments", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const tool = getUxpCloneWorkflowTools({ request } as unknown as UxpWebSocketBridge).duplicate_track_item_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
additionalProperties: false, required: ["action", "media_type", "track_index", "clip_index"],
|
||||
properties: { action: { enum: ["inspect", "apply"] }, expected_snapshot: { additionalProperties: false, required: ["project_guid", "sequence_id", "media_type", "track_index", "clip_index", "track_item_count", "source"] } },
|
||||
});
|
||||
await tool.handler({
|
||||
action: "apply", media_type: "video", track_index: 0, clip_index: 0, confirm_duplicate: true, operation_id: "clone-tool-1",
|
||||
expected_snapshot: { project_guid: "project-1", sequence_id: "sequence-1", media_type: "video", track_index: 0, clip_index: 0, track_item_count: 1, source },
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("trackItem.clone", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 0, confirmDuplicate: true, operationId: "clone-tool-1",
|
||||
expectedSnapshot: {
|
||||
projectGuid: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0, clipIndex: 0, trackItemCount: 1,
|
||||
source: { projectItemId: "source-1", startSeconds: 0, endSeconds: 10, inSeconds: 20, outSeconds: 30, durationSeconds: 10, speed: 1, reversed: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not silently drop unknown fields from a reviewed source snapshot", async () => {
|
||||
const request = vi.fn();
|
||||
const tool = getUxpCloneWorkflowTools({ request } as unknown as UxpWebSocketBridge).duplicate_track_item_uxp;
|
||||
await expect(tool.handler({
|
||||
action: "apply", media_type: "video", track_index: 0, clip_index: 0,
|
||||
expected_snapshot: { project_guid: "project-1", sequence_id: "sequence-1", media_type: "video", track_index: 0, clip_index: 0, track_item_count: 1, source: { ...source, unexpected: true } },
|
||||
})).rejects.toThrow("expected_snapshot.source has an unknown field: unexpected");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpDialogueWorkflowTools, validateDerivedDialoguePlan } from "../../src/tools/uxp-dialogue-workflows.js";
|
||||
|
||||
const json = "{\"segments\":[]}", revision = "sha256:cf84482b9efdd9291a36643471c6e09c79a69623f87a7b61265b660e54e69eaf";
|
||||
describe("UXP dialogue workflow tools", () => {
|
||||
it("previews then applies the exact revision-bound plan", async () => {
|
||||
const request = vi.fn(async (command: string) => command === "transcript.export" ? { json, projectGuid: "project" } : { outcome: "committed_unverified" });
|
||||
const tools = getUxpDialogueWorkflowTools({ request } as any);
|
||||
const preview = await tools.preview_derived_dialogue_sequence_uxp.handler({ project_guid: "project", mode: "talking_head", sequence_name: "Reviewed", segments: [{ id: "s1", source_project_item_id: "clip", transcript_revision: revision, source_start_seconds: 0, source_end_seconds: 1 }] });
|
||||
expect(preview.success).toBe(true);
|
||||
const data = preview.data as any;
|
||||
const applied = await tools.apply_derived_dialogue_sequence_uxp.handler({ plan: data.plan, confirmation_token: data.confirmation_token, operation_id: "op-1" });
|
||||
expect(applied.success).toBe(true);
|
||||
expect(request).toHaveBeenLastCalledWith("dialogue.deriveSequence", { plan: data.plan, operationId: "op-1" });
|
||||
});
|
||||
it("validates podcast plans and rejects unsafe variants", () => {
|
||||
const segment = { id: "s", source_project_item_id: "camera", transcript_revision: revision, source_start_seconds: 1, source_end_seconds: 2, master_audio_start_seconds: 3, master_audio_end_seconds: 4 };
|
||||
const plan = { schema_version: 1, project_guid: "project", mode: "podcast", sequence_name: "Podcast", master_audio_project_item_id: "audio", segments: [segment], output_duration_seconds: 1, original_sources_unchanged: true, render_verified: false };
|
||||
expect(validateDerivedDialoguePlan(plan).mode).toBe("podcast");
|
||||
const invalid = [
|
||||
null, { ...plan, extra: true }, { ...plan, schema_version: 2 }, { ...plan, mode: "other" }, { ...plan, segments: [] },
|
||||
{ ...plan, segments: [null] }, { ...plan, segments: [{ ...segment, extra: true }] }, { ...plan, segments: [segment, segment] },
|
||||
{ ...plan, segments: [{ ...segment, source_end_seconds: 1 }] }, { ...plan, segments: [{ ...segment, transcript_revision: "bad" }] },
|
||||
{ ...plan, segments: [{ ...segment, master_audio_end_seconds: 5 }] }, { ...plan, master_audio_project_item_id: undefined },
|
||||
{ ...plan, output_duration_seconds: 2 }, { ...plan, original_sources_unchanged: false },
|
||||
{ ...plan, target_bin_id: "" }, { ...plan, segments: [{ ...segment, speaker_label: "" }] },
|
||||
{ ...plan, mode: "talking_head", segments: [{ ...segment, master_audio_start_seconds: undefined, master_audio_end_seconds: undefined }] },
|
||||
];
|
||||
for (const value of invalid) expect(() => validateDerivedDialoguePlan(value)).toThrow();
|
||||
expect(() => validateDerivedDialoguePlan({ ...plan, mode: "talking_head", master_audio_project_item_id: undefined, segments: [{ ...segment, master_audio_start_seconds: undefined, master_audio_end_seconds: undefined }, { ...segment, id: "two", source_project_item_id: "other", master_audio_start_seconds: undefined, master_audio_end_seconds: undefined }] })).toThrow(/same source/);
|
||||
});
|
||||
it("fails closed for stale or unavailable transcripts and bad confirmations", async () => {
|
||||
const base = { project_guid: "project", mode: "talking_head", sequence_name: "Reviewed", segments: [{ id: "s1", source_project_item_id: "clip", transcript_revision: revision, source_start_seconds: 0, source_end_seconds: 1 }] };
|
||||
for (const response of [{ json, projectGuid: "other" }, { projectGuid: "project" }, { json: "changed", projectGuid: "project" }]) {
|
||||
const tools = getUxpDialogueWorkflowTools({ request: vi.fn().mockResolvedValue(response) } as any);
|
||||
expect((await tools.preview_derived_dialogue_sequence_uxp.handler(base)).success).toBe(false);
|
||||
}
|
||||
const tools = getUxpDialogueWorkflowTools({ request: vi.fn().mockResolvedValue({ json, projectGuid: "project" }) } as any);
|
||||
const preview = await tools.preview_derived_dialogue_sequence_uxp.handler(base) as any;
|
||||
expect((await tools.apply_derived_dialogue_sequence_uxp.handler({ plan: preview.data.plan, confirmation_token: `sha256:${"0".repeat(64)}`, operation_id: "op" })).success).toBe(false);
|
||||
expect((await tools.apply_derived_dialogue_sequence_uxp.handler({ plan: preview.data.plan, confirmation_token: preview.data.confirmation_token, operation_id: "bad space" })).success).toBe(false);
|
||||
});
|
||||
it("normalizes non-Error bridge failures", async () => {
|
||||
const tools = getUxpDialogueWorkflowTools({ request: vi.fn().mockRejectedValue("offline") } as any);
|
||||
const result = await tools.preview_derived_dialogue_sequence_uxp.handler({ project_guid: "project", mode: "talking_head", sequence_name: "Reviewed", segments: [{ id: "s1", source_project_item_id: "clip", transcript_revision: revision, source_start_seconds: 0, source_end_seconds: 1 }] });
|
||||
expect(result).toEqual({ success: false, error: "offline" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpEffectParameterCatalogWorkflowTools } from "../../src/tools/uxp-effect-parameter-catalog-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
describe("public documented UXP effect-parameter catalog tool", () => {
|
||||
it("publishes a closed bounded schema and maps only parameter-catalog coordinates", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ parameterCount: 1 });
|
||||
const tool = getUxpEffectParameterCatalogWorkflowTools({ request } as unknown as UxpWebSocketBridge)
|
||||
.inspect_effect_parameter_catalog_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false,
|
||||
required: ["media_type", "track_index", "clip_index", "component_index"],
|
||||
properties: {
|
||||
media_type: { type: "string", enum: ["video", "audio"] },
|
||||
track_index: { type: "integer", minimum: 0, maximum: 511 },
|
||||
clip_index: { type: "integer", minimum: 0, maximum: 511 },
|
||||
component_index: { type: "integer", minimum: 0, maximum: 511 },
|
||||
expected_sequence_guid: { minLength: 1, maxLength: 512 },
|
||||
expected_component_id: { minLength: 1, maxLength: 512 },
|
||||
},
|
||||
});
|
||||
expect(tool.description).toContain("never raw parameter values");
|
||||
expect(tool.operationalCapability).toMatchObject({
|
||||
backend: "UXP", minimumPremiereVersion: "25.6", hostVerificationRequired: true,
|
||||
});
|
||||
|
||||
await tool.handler({
|
||||
media_type: "audio", track_index: 3, clip_index: 2, component_index: 1,
|
||||
expected_sequence_guid: "sequence-1", expected_component_id: "ADBE Volume",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("parameters.catalog.inspect", {
|
||||
mediaType: "audio", trackIndex: 3, clipIndex: 2, componentIndex: 1,
|
||||
expectedSequenceGuid: "sequence-1", expectedComponentId: "ADBE Volume",
|
||||
});
|
||||
});
|
||||
});
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getUxpTools } from "../../src/tools/uxp.js";
|
||||
import { getUxpWorkflowTools } from "../../src/tools/uxp-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
type Schema = { type?: string; enum?: unknown[]; properties?: Record<string, Schema>; required?: string[]; items?: Schema };
|
||||
type Tool = { parameters: Schema; handler: (args: Record<string, unknown>) => Promise<unknown> };
|
||||
|
||||
const values: Record<string, unknown> = {
|
||||
name: "Coverage Name", query: "coverage", preset_path: "/tmp/preset.sqpreset",
|
||||
confirm_non_undoable: true, confirm_destructive: true, operation_id: "coverage-operation",
|
||||
output_file_path: "/tmp/output.otio", project_item_id: "item-1", track_index: 0,
|
||||
start_seconds: 1, end_seconds: 2, transcript_revision: `sha256:${"a".repeat(64)}`,
|
||||
project_guid: "project-1", expected_transcript_revision: `sha256:${"a".repeat(64)}`,
|
||||
replacement_transcript_json: '{"segments":[]}',
|
||||
sequence_name: "Coverage Stringout", duration_seconds: 10, frame_rate: 30,
|
||||
silence_ranges: [{ start_seconds: 2, end_seconds: 4 }],
|
||||
placements: [{
|
||||
placement_id: "coverage-placement", track_type: "video", track_index: 0,
|
||||
source_in_seconds: 0, source_out_seconds: 10,
|
||||
timeline_start_seconds: 20, timeline_end_seconds: 30,
|
||||
}],
|
||||
};
|
||||
|
||||
function valueFor(schema: Schema, field: string, all: boolean): unknown {
|
||||
if (field in values) return values[field];
|
||||
if (schema.enum?.length) return all ? schema.enum.at(-1) : schema.enum[0];
|
||||
if (schema.type === "boolean") return all;
|
||||
if (schema.type === "number" || schema.type === "integer") return 1;
|
||||
if (schema.type === "array") return [valueFor(schema.items ?? { type: "string" }, field, all)];
|
||||
if (schema.type === "object") return argsFor(schema, all);
|
||||
return `coverage-${field}`;
|
||||
}
|
||||
|
||||
function argsFor(schema: Schema, all: boolean) {
|
||||
const result: Record<string, unknown> = {};
|
||||
const required = new Set(schema.required ?? []);
|
||||
for (const [field, property] of Object.entries(schema.properties ?? {})) {
|
||||
if (all || required.has(field)) result[field] = valueFor(property, field, all);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("UXP tool handler coverage", () => {
|
||||
const request = vi.fn();
|
||||
const getState = vi.fn(() => ({ connected: true, authenticated: true }));
|
||||
const bridge = { request, getState } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge) as Record<string, Tool>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
request.mockResolvedValue({
|
||||
json: JSON.stringify({ words: [], segments: [] }),
|
||||
projectItemId: "item-1",
|
||||
projectItemName: "Coverage",
|
||||
});
|
||||
});
|
||||
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
it.each([["required", false], ["all", true]])(`${name} handles %s arguments`, async (_label, all) => {
|
||||
const args = argsFor(tool.parameters, all);
|
||||
if (name === "manage_timeline_selection_uxp" && all) args.action = "replace";
|
||||
const result = await tool.handler(args);
|
||||
expect(result).toBeDefined();
|
||||
if (name === "get_uxp_capabilities") expect(getState).toHaveBeenCalled();
|
||||
else if (!["preview_derived_dialogue_sequence_uxp", "apply_derived_dialogue_sequence_uxp"].includes(name)) expect(request).toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
it("normalizes Error and non-Error bridge rejections", async () => {
|
||||
request.mockRejectedValueOnce(new Error("offline"));
|
||||
await expect(tools.get_uxp_state.handler({})).resolves.toEqual({ success: false, error: "offline" });
|
||||
request.mockRejectedValueOnce("disconnected");
|
||||
await expect(tools.get_uxp_state.handler({})).resolves.toEqual({ success: false, error: "disconnected" });
|
||||
});
|
||||
|
||||
it("rejects an empty exported transcript", async () => {
|
||||
request.mockResolvedValueOnce({ json: "" });
|
||||
await expect(tools.get_clip_transcript_uxp.handler({ project_item_id: "item-1" }))
|
||||
.resolves.toEqual({ success: false, error: "Premiere returned an empty transcript" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("UXP workflow handler branch coverage", () => {
|
||||
const request = vi.fn();
|
||||
const bridge = { request } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpWorkflowTools(bridge) as Record<string, Tool>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
request.mockResolvedValue({ covered: true });
|
||||
});
|
||||
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
it.each([["required", false], ["all", true]])(`${name} handles %s arguments`, async (_label, all) => {
|
||||
await expect(tool.handler(argsFor(tool.parameters, all))).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
for (const [field, property] of Object.entries(tool.parameters.properties ?? {})) {
|
||||
for (const enumValue of property.enum ?? []) {
|
||||
it(`${name} handles ${field}=${String(enumValue)}`, async () => {
|
||||
const args = argsFor(tool.parameters, true);
|
||||
args[field] = enumValue;
|
||||
await expect(tool.handler(args)).resolves.toBeDefined();
|
||||
});
|
||||
}
|
||||
if (property.type === "boolean") {
|
||||
it(`${name} handles ${field}=false`, async () => {
|
||||
const args = argsFor(tool.parameters, true);
|
||||
args[field] = false;
|
||||
await expect(tool.handler(args)).resolves.toBeDefined();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (tool.parameters.properties?.action?.enum) {
|
||||
it(`${name} rejects an unsupported action`, async () => {
|
||||
const args = argsFor(tool.parameters, true);
|
||||
args.action = "unsupported-action";
|
||||
await expect(tool.handler(args)).resolves.toMatchObject({ success: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("normalizes workflow bridge failures", async () => {
|
||||
request.mockRejectedValueOnce(new Error("workflow offline"));
|
||||
await expect(tools.manage_clip_effects_uxp.handler({ action: "catalog" }))
|
||||
.resolves.toEqual({ success: false, error: "workflow offline" });
|
||||
request.mockRejectedValueOnce("disconnected");
|
||||
await expect(tools.manage_clip_effects_uxp.handler({ action: "catalog" }))
|
||||
.resolves.toEqual({ success: false, error: "disconnected" });
|
||||
});
|
||||
});
|
||||
Executable
+567
@@ -0,0 +1,567 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
import { getUxpNextWorkflowTools } from "../../src/tools/uxp-next-workflows.js";
|
||||
|
||||
describe("next-wave UXP MCP tools", () => {
|
||||
it("publishes a closed and bounded event receipt schema", () => {
|
||||
const bridge = { request: vi.fn(), getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).inspect_premiere_events_uxp;
|
||||
expect(tool.description).toContain("timeline.snap.*");
|
||||
expect(tool.description).toContain("operation.clip.extend.reached");
|
||||
expect(tool.description).toContain("raw event payloads are never returned");
|
||||
expect(tool.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["list", "wait"] },
|
||||
after_revision: { maximum: Number.MAX_SAFE_INTEGER },
|
||||
categories: { maxItems: 32 },
|
||||
event_names: { maxItems: 32 },
|
||||
limit: { maximum: 256 },
|
||||
timeout_ms: { maximum: 60000 },
|
||||
},
|
||||
});
|
||||
const readiness = getUxpNextWorkflowTools(bridge).wait_for_host_readiness_uxp;
|
||||
expect(readiness.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["snapshot", "analysis", "operation"] },
|
||||
operation_type: { enum: ["import", "export", "effect_drop", "generative_extend"] },
|
||||
after_revision: { maximum: Number.MAX_SAFE_INTEGER },
|
||||
timeout_ms: { maximum: 60000 },
|
||||
poll_max_ms: { maximum: 5000 },
|
||||
},
|
||||
});
|
||||
const projects = getUxpNextWorkflowTools(bridge).manage_project_sessions_uxp;
|
||||
expect(projects.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["list", "validate", "create", "open", "save", "save_as", "branch_copies", "close"] },
|
||||
paths: { maxItems: 16, uniqueItems: true },
|
||||
confirm_external_write: { type: "boolean" },
|
||||
confirm_discard_unsaved: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
const growing = getUxpNextWorkflowTools(bridge).manage_growing_media_uxp;
|
||||
expect(growing.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["status", "pause", "resume"] },
|
||||
lease_ms: { minimum: 1000, maximum: 600000 },
|
||||
confirm_pause: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
const checkpoints = getUxpNextWorkflowTools(bridge).manage_workflow_checkpoints_uxp;
|
||||
expect(checkpoints.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action", "name"],
|
||||
properties: {
|
||||
action: { enum: ["has", "get", "set", "clear"] },
|
||||
owner: { enum: ["project", "sequence"] },
|
||||
value_type: { enum: ["string", "int", "float", "bool"] },
|
||||
persistence: { enum: ["session", "persistent"] },
|
||||
},
|
||||
});
|
||||
const media = getUxpNextWorkflowTools(bridge).maintain_media_health_uxp;
|
||||
expect(media.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["inspect", "refresh", "set_offline", "find_by_media_path"] },
|
||||
project_item_ids: { maxItems: 64, uniqueItems: true },
|
||||
confirm_set_offline: { type: "boolean" },
|
||||
include_paths: { type: "boolean" },
|
||||
include_media_timing: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
const sourceMediaTiming = getUxpNextWorkflowTools(bridge).manage_source_media_timing_uxp;
|
||||
expect(sourceMediaTiming.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action", "project_item_id"],
|
||||
properties: {
|
||||
action: { enum: ["inspect", "set_start"] },
|
||||
project_item_id: { maxLength: 512 },
|
||||
expected_timing: {
|
||||
additionalProperties: false,
|
||||
required: ["start_seconds", "duration_seconds"],
|
||||
},
|
||||
start_seconds: { maximum: 86400000 },
|
||||
confirm_set_start: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
const sourceMediaOverrides = getUxpNextWorkflowTools(bridge).manage_source_media_overrides_uxp;
|
||||
expect(sourceMediaOverrides.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action", "project_item_id"],
|
||||
properties: {
|
||||
action: { enum: ["inspect", "update"] },
|
||||
project_item_id: { maxLength: 512 },
|
||||
expected_overrides: {
|
||||
additionalProperties: false,
|
||||
required: ["project_guid", "frame_rate", "pixel_aspect_ratio"],
|
||||
},
|
||||
frame_rate: { minimum: 1, maximum: 240 },
|
||||
pixel_aspect_ratio: {
|
||||
additionalProperties: false,
|
||||
required: ["numerator", "denominator"],
|
||||
},
|
||||
confirm_media_interpretation: { type: "boolean" },
|
||||
operation_id: { pattern: "^[A-Za-z0-9._:-]{1,128}$" },
|
||||
},
|
||||
});
|
||||
const tracks = getUxpNextWorkflowTools(bridge).manage_track_state_uxp;
|
||||
expect(tracks.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["inspect", "set_mute"] },
|
||||
media_type: { enum: ["all", "video", "audio", "caption"] },
|
||||
track_indices: { maxItems: 64, uniqueItems: true },
|
||||
muted: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
const source = getUxpNextWorkflowTools(bridge).manage_source_clip_uxp;
|
||||
expect(source.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action", "items"],
|
||||
properties: {
|
||||
action: { enum: ["inspect", "update"] },
|
||||
items: {
|
||||
maxItems: 64,
|
||||
items: {
|
||||
additionalProperties: false,
|
||||
required: ["project_item_id"],
|
||||
properties: {
|
||||
media_type: { enum: ["video", "audio"] },
|
||||
scale_to_frame: { enum: [true] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps snake-case event queries to the exact bridge commands", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ events: [] });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).inspect_premiere_events_uxp;
|
||||
await tool.handler({
|
||||
action: "wait", after_revision: 7, categories: ["encoder"],
|
||||
event_names: ["encoder.complete"], limit: 4, timeout_ms: 5000,
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("events.wait", {
|
||||
afterRevision: 7,
|
||||
categories: ["encoder"],
|
||||
eventNames: ["encoder.complete"],
|
||||
limit: 4,
|
||||
timeoutMs: 5000,
|
||||
}, { minimumTimeoutMs: 10000 });
|
||||
|
||||
const readiness = getUxpNextWorkflowTools(bridge).wait_for_host_readiness_uxp;
|
||||
await readiness.handler({
|
||||
action: "analysis", sequence_id: "sequence-1", expected_sequence_id: "sequence-1",
|
||||
timeout_ms: 10000, poll_min_ms: 100, poll_max_ms: 1000,
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("readiness.analysis.wait", {
|
||||
sequenceId: "sequence-1", expectedSequenceId: "sequence-1",
|
||||
timeoutMs: 10000, pollMinMs: 100, pollMaxMs: 1000,
|
||||
}, { minimumTimeoutMs: 15000 });
|
||||
|
||||
await readiness.handler({
|
||||
action: "operation", operation_type: "effect_drop", after_revision: 9, timeout_ms: 5000,
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("readiness.operation.wait", {
|
||||
operationType: "effectDrop", afterRevision: 9, timeoutMs: 5000,
|
||||
}, { minimumTimeoutMs: 10000 });
|
||||
|
||||
const projects = getUxpNextWorkflowTools(bridge).manage_project_sessions_uxp;
|
||||
await projects.handler({
|
||||
action: "branch_copies", project_id: "project-1", expected_path: "C:/work/source.prproj",
|
||||
paths: ["C:/work/a.prproj", "C:/work/b.prproj"], confirm_external_write: true,
|
||||
confirm_overwrite: false, operation_id: "branches-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.branchCopies", {
|
||||
projectId: "project-1", expectedPath: "C:/work/source.prproj", operationId: "branches-1",
|
||||
paths: ["C:/work/a.prproj", "C:/work/b.prproj"],
|
||||
confirmExternalWrite: true, confirmOverwrite: false,
|
||||
});
|
||||
|
||||
const growing = getUxpNextWorkflowTools(bridge).manage_growing_media_uxp;
|
||||
await growing.handler({
|
||||
action: "pause", project_id: "project-1", expected_path: "C:/work/source.prproj",
|
||||
lease_ms: 30000, confirm_pause: true, operation_id: "pause-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("growing.pause", {
|
||||
projectId: "project-1", expectedPath: "C:/work/source.prproj",
|
||||
leaseMs: 30000, confirmPause: true, operationId: "pause-1",
|
||||
});
|
||||
|
||||
const checkpoints = getUxpNextWorkflowTools(bridge).manage_workflow_checkpoints_uxp;
|
||||
await checkpoints.handler({
|
||||
action: "set", owner: "sequence", sequence_id: "sequence-1", expected_owner_id: "sequence-1",
|
||||
name: "render.pass", value_type: "int", value: 3, persistence: "persistent", operation_id: "checkpoint-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("checkpoint.set", {
|
||||
owner: "sequence", sequenceId: "sequence-1", expectedOwnerId: "sequence-1",
|
||||
name: "render.pass", valueType: "int", value: 3, persistence: "persistent", operationId: "checkpoint-1",
|
||||
});
|
||||
|
||||
const media = getUxpNextWorkflowTools(bridge).maintain_media_health_uxp;
|
||||
await media.handler({
|
||||
action: "set_offline", project_item_ids: ["clip-1", "clip-2"],
|
||||
expected_offline: false, confirm_set_offline: true, operation_id: "offline-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("media.health.setOffline", {
|
||||
projectItemIds: ["clip-1", "clip-2"], expectedOffline: false,
|
||||
confirmSetOffline: true, operationId: "offline-1",
|
||||
});
|
||||
|
||||
const sourceMediaTiming = getUxpNextWorkflowTools(bridge).manage_source_media_timing_uxp;
|
||||
await sourceMediaTiming.handler({ action: "set_start", project_item_id: "clip-1", start_seconds: 12,
|
||||
expected_timing: { start_seconds: 10, duration_seconds: 60 }, confirm_set_start: true, operation_id: "source-time-1" });
|
||||
expect(request).toHaveBeenLastCalledWith("source.mediaTiming.setStart", {
|
||||
projectItemId: "clip-1", expectedTiming: { startSeconds: 10, durationSeconds: 60 },
|
||||
startSeconds: 12, confirmSetStart: true, operationId: "source-time-1",
|
||||
});
|
||||
|
||||
const sourceMediaOverrides = getUxpNextWorkflowTools(bridge).manage_source_media_overrides_uxp;
|
||||
await sourceMediaOverrides.handler({
|
||||
action: "update", project_item_id: "clip-1",
|
||||
expected_overrides: { project_guid: "project-1", frame_rate: 23.976, pixel_aspect_ratio: 1 },
|
||||
frame_rate: 25, pixel_aspect_ratio: { numerator: 4, denominator: 3 },
|
||||
confirm_media_interpretation: true, operation_id: "source-override-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("source.mediaOverrides.update", {
|
||||
projectItemId: "clip-1",
|
||||
expectedOverrides: { projectGuid: "project-1", frameRate: 23.976, pixelAspectRatio: 1 },
|
||||
frameRate: 25, pixelAspectRatio: { numerator: 4, denominator: 3 },
|
||||
confirmMediaInterpretation: true, operationId: "source-override-1",
|
||||
});
|
||||
|
||||
const tracks = getUxpNextWorkflowTools(bridge).manage_track_state_uxp;
|
||||
await tracks.handler({
|
||||
action: "set_mute", sequence_id: "sequence-1", expected_sequence_id: "sequence-1",
|
||||
media_type: "caption", track_indices: [0], muted: true, expected_muted: false, operation_id: "mute-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("track.state.set", {
|
||||
sequenceId: "sequence-1", expectedSequenceId: "sequence-1",
|
||||
mediaType: "caption", trackIndices: [0], muted: true, expectedMuted: false, operationId: "mute-1",
|
||||
});
|
||||
|
||||
const source = getUxpNextWorkflowTools(bridge).manage_source_clip_uxp;
|
||||
await source.handler({
|
||||
action: "update",
|
||||
items: [{
|
||||
project_item_id: "clip-1", media_type: "video", expected_in_seconds: 1,
|
||||
in_seconds: 2, out_seconds: 8, scale_to_frame: true,
|
||||
}],
|
||||
operation_id: "source-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("source.clip.update", {
|
||||
items: [{
|
||||
projectItemId: "clip-1", mediaType: "video", expectedInSeconds: 1,
|
||||
inSeconds: 2, outSeconds: 8, scaleToFrame: true,
|
||||
}],
|
||||
operationId: "source-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("covers bounded event and readiness fallbacks without hiding bridge errors", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpNextWorkflowTools(bridge);
|
||||
|
||||
await tools.inspect_premiere_events_uxp.handler({ action: "list" });
|
||||
expect(request).toHaveBeenLastCalledWith("events.list", {});
|
||||
await tools.inspect_premiere_events_uxp.handler({
|
||||
action: "list",
|
||||
after_revision: 0,
|
||||
categories: [],
|
||||
event_names: [],
|
||||
limit: 1,
|
||||
timeout_ms: 10,
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("events.list", {
|
||||
afterRevision: 0,
|
||||
categories: [],
|
||||
eventNames: [],
|
||||
limit: 1,
|
||||
});
|
||||
await expect(tools.inspect_premiere_events_uxp.handler({ action: "unsupported" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported event action: unsupported" });
|
||||
|
||||
request.mockRejectedValueOnce(new Error("bridge unavailable"));
|
||||
await expect(tools.inspect_premiere_events_uxp.handler({ action: "wait" }))
|
||||
.resolves.toEqual({ success: false, error: "bridge unavailable" });
|
||||
request.mockRejectedValueOnce("plain failure");
|
||||
await expect(tools.inspect_premiere_events_uxp.handler({ action: "wait", timeout_ms: 0 }))
|
||||
.resolves.toEqual({ success: false, error: "plain failure" });
|
||||
|
||||
await tools.wait_for_host_readiness_uxp.handler({ action: "snapshot" });
|
||||
expect(request).toHaveBeenLastCalledWith("readiness.snapshot", {});
|
||||
await tools.wait_for_host_readiness_uxp.handler({ action: "snapshot", sequence_id: "sequence-1" });
|
||||
expect(request).toHaveBeenLastCalledWith("readiness.snapshot", { sequenceId: "sequence-1" });
|
||||
await tools.wait_for_host_readiness_uxp.handler({ action: "analysis" });
|
||||
expect(request).toHaveBeenLastCalledWith(
|
||||
"readiness.analysis.wait", {}, { minimumTimeoutMs: 35000 },
|
||||
);
|
||||
await tools.wait_for_host_readiness_uxp.handler({ action: "operation" });
|
||||
expect(request).toHaveBeenLastCalledWith(
|
||||
"readiness.operation.wait", {}, { minimumTimeoutMs: 35000 },
|
||||
);
|
||||
await expect(tools.wait_for_host_readiness_uxp.handler({ action: "unsupported" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported readiness action: unsupported" });
|
||||
});
|
||||
|
||||
it("covers every project-session route and its optional argument mappings", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).manage_project_sessions_uxp;
|
||||
|
||||
await tool.handler({ action: "list" });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.list", {});
|
||||
await tool.handler({ action: "list", include_paths: false });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.list", { includePaths: false });
|
||||
await tool.handler({ action: "validate", path: "C:/work/source.prproj" });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.validate", { path: "C:/work/source.prproj" });
|
||||
|
||||
await tool.handler({ action: "create", path: "C:/work/new.prproj" });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.create", { path: "C:/work/new.prproj" });
|
||||
await tool.handler({
|
||||
action: "create", path: "C:/work/new.prproj", confirm_external_write: true,
|
||||
confirm_overwrite: false, operation_id: "create-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.create", {
|
||||
path: "C:/work/new.prproj", confirmExternalWrite: true,
|
||||
confirmOverwrite: false, operationId: "create-1",
|
||||
});
|
||||
|
||||
await tool.handler({ action: "open", path: "C:/work/open.prproj" });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.open", { path: "C:/work/open.prproj" });
|
||||
await tool.handler({
|
||||
action: "open", path: "C:/work/open.prproj", show_dialogs: false,
|
||||
add_to_mru: true, operation_id: "open-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.open", {
|
||||
path: "C:/work/open.prproj", showDialogs: false, addToMru: true, operationId: "open-1",
|
||||
});
|
||||
|
||||
await tool.handler({ action: "save" });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.save", {});
|
||||
await tool.handler({ action: "save_as", path: "C:/work/copy.prproj" });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.saveAs", { path: "C:/work/copy.prproj" });
|
||||
await tool.handler({
|
||||
action: "save_as", project_id: "project-1", expected_path: "C:/work/source.prproj",
|
||||
path: "C:/work/copy.prproj", confirm_external_write: true,
|
||||
confirm_overwrite: true, operation_id: "save-as-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.saveAs", {
|
||||
projectId: "project-1", expectedPath: "C:/work/source.prproj", operationId: "save-as-1",
|
||||
path: "C:/work/copy.prproj", confirmExternalWrite: true, confirmOverwrite: true,
|
||||
});
|
||||
|
||||
await tool.handler({ action: "branch_copies", paths: ["C:/work/branch.prproj"] });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.branchCopies", {
|
||||
paths: ["C:/work/branch.prproj"],
|
||||
});
|
||||
await tool.handler({ action: "close" });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.close", {});
|
||||
await tool.handler({
|
||||
action: "close", project_id: "project-1", expected_path: "C:/work/source.prproj",
|
||||
save_before_close: false, confirm_close: true, confirm_discard_unsaved: true,
|
||||
operation_id: "close-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.close", {
|
||||
projectId: "project-1", expectedPath: "C:/work/source.prproj", operationId: "close-1",
|
||||
saveBeforeClose: false, confirmClose: true, confirmDiscardUnsaved: true,
|
||||
});
|
||||
await expect(tool.handler({ action: "unsupported" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported project-session action: unsupported" });
|
||||
});
|
||||
|
||||
it("covers growing-media status, lease defaults, resume targeting, and rejection", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).manage_growing_media_uxp;
|
||||
|
||||
await tool.handler({ action: "status" });
|
||||
expect(request).toHaveBeenLastCalledWith("growing.status", {});
|
||||
await tool.handler({ action: "pause" });
|
||||
expect(request).toHaveBeenLastCalledWith("growing.pause", {});
|
||||
await tool.handler({ action: "resume" });
|
||||
expect(request).toHaveBeenLastCalledWith("growing.resume", {});
|
||||
await tool.handler({ action: "resume", project_id: "project-1", operation_id: "resume-1" });
|
||||
expect(request).toHaveBeenLastCalledWith("growing.resume", {
|
||||
projectId: "project-1", operationId: "resume-1",
|
||||
});
|
||||
await expect(tool.handler({ action: "unsupported" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported growing-media action: unsupported" });
|
||||
});
|
||||
|
||||
it("covers checkpoint reads, typed writes, clears, and optional owner targeting", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).manage_workflow_checkpoints_uxp;
|
||||
|
||||
await tool.handler({ action: "has", name: "render.pass" });
|
||||
expect(request).toHaveBeenLastCalledWith("checkpoint.has", { name: "render.pass" });
|
||||
await tool.handler({ action: "get", name: "render.pass" });
|
||||
expect(request).toHaveBeenLastCalledWith("checkpoint.get", { name: "render.pass" });
|
||||
await tool.handler({
|
||||
action: "get", owner: "project", sequence_id: "sequence-1",
|
||||
expected_owner_id: "project-1", name: "render.pass", value_type: "int",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("checkpoint.get", {
|
||||
owner: "project", sequenceId: "sequence-1", expectedOwnerId: "project-1",
|
||||
name: "render.pass", valueType: "int",
|
||||
});
|
||||
|
||||
await tool.handler({ action: "set", name: "render.pass" });
|
||||
expect(request).toHaveBeenLastCalledWith("checkpoint.set", { name: "render.pass" });
|
||||
await tool.handler({ action: "clear", name: "render.pass" });
|
||||
expect(request).toHaveBeenLastCalledWith("checkpoint.clear", { name: "render.pass" });
|
||||
await tool.handler({ action: "clear", name: "render.pass", operation_id: "clear-1" });
|
||||
expect(request).toHaveBeenLastCalledWith("checkpoint.clear", {
|
||||
name: "render.pass", operationId: "clear-1",
|
||||
});
|
||||
await expect(tool.handler({ action: "unsupported", name: "render.pass" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported checkpoint action: unsupported" });
|
||||
});
|
||||
|
||||
it("covers every media-health route with redacted defaults and explicit options", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).maintain_media_health_uxp;
|
||||
|
||||
await tool.handler({ action: "inspect" });
|
||||
expect(request).toHaveBeenLastCalledWith("media.health.inspect", {});
|
||||
await tool.handler({ action: "inspect", project_item_ids: ["clip-1"], include_paths: false, include_media_timing: true });
|
||||
expect(request).toHaveBeenLastCalledWith("media.health.inspect", {
|
||||
projectItemIds: ["clip-1"], includePaths: false, includeMediaTiming: true,
|
||||
});
|
||||
await tool.handler({ action: "refresh" });
|
||||
expect(request).toHaveBeenLastCalledWith("media.health.refresh", {});
|
||||
await tool.handler({
|
||||
action: "refresh", project_item_ids: ["clip-1"], expected_offline: true,
|
||||
operation_id: "refresh-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("media.health.refresh", {
|
||||
projectItemIds: ["clip-1"], expectedOffline: true, operationId: "refresh-1",
|
||||
});
|
||||
await tool.handler({ action: "set_offline" });
|
||||
expect(request).toHaveBeenLastCalledWith("media.health.setOffline", {});
|
||||
await tool.handler({ action: "find_by_media_path" });
|
||||
expect(request).toHaveBeenLastCalledWith("media.health.findByPath", {});
|
||||
await tool.handler({
|
||||
action: "find_by_media_path", project_item_id: "clip-1", match_path: "C:/media/a.mov",
|
||||
ignore_subclips: true, include_paths: true,
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("media.health.findByPath", {
|
||||
projectItemId: "clip-1", matchPath: "C:/media/a.mov",
|
||||
ignoreSubclips: true, includePaths: true,
|
||||
});
|
||||
await expect(tool.handler({ action: "unsupported" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported media-health action: unsupported" });
|
||||
});
|
||||
|
||||
it("covers source-media timing inspection, guarded start mapping, and rejection", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).manage_source_media_timing_uxp;
|
||||
|
||||
await tool.handler({ action: "inspect", project_item_id: "clip-1" });
|
||||
expect(request).toHaveBeenLastCalledWith("source.mediaTiming.inspect", { projectItemId: "clip-1" });
|
||||
await tool.handler({ action: "set_start", project_item_id: "clip-1" });
|
||||
expect(request).toHaveBeenLastCalledWith("source.mediaTiming.setStart", { projectItemId: "clip-1" });
|
||||
await tool.handler({
|
||||
action: "set_start", project_item_id: "clip-1", start_seconds: 12,
|
||||
expected_timing: { start_seconds: 10, duration_seconds: 60 }, confirm_set_start: false,
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("source.mediaTiming.setStart", {
|
||||
projectItemId: "clip-1", expectedTiming: { startSeconds: 10, durationSeconds: 60 },
|
||||
startSeconds: 12, confirmSetStart: false,
|
||||
});
|
||||
await expect(tool.handler({ action: "unsupported", project_item_id: "clip-1" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported source-media timing action: unsupported" });
|
||||
});
|
||||
|
||||
it("covers source-media override inspection, exact snapshot mapping, and rejection", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).manage_source_media_overrides_uxp;
|
||||
|
||||
await tool.handler({ action: "inspect", project_item_id: "clip-1" });
|
||||
expect(request).toHaveBeenLastCalledWith("source.mediaOverrides.inspect", { projectItemId: "clip-1" });
|
||||
await tool.handler({ action: "update", project_item_id: "clip-1" });
|
||||
expect(request).toHaveBeenLastCalledWith("source.mediaOverrides.update", { projectItemId: "clip-1" });
|
||||
await tool.handler({
|
||||
action: "update", project_item_id: "clip-1",
|
||||
expected_overrides: { project_guid: "project-1", frame_rate: 23.976, pixel_aspect_ratio: 1 },
|
||||
pixel_aspect_ratio: { numerator: 4, denominator: 3 }, confirm_media_interpretation: false,
|
||||
operation_id: "source-override-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("source.mediaOverrides.update", {
|
||||
projectItemId: "clip-1",
|
||||
expectedOverrides: { projectGuid: "project-1", frameRate: 23.976, pixelAspectRatio: 1 },
|
||||
pixelAspectRatio: { numerator: 4, denominator: 3 },
|
||||
confirmMediaInterpretation: false, operationId: "source-override-1",
|
||||
});
|
||||
await expect(tool.handler({ action: "unsupported", project_item_id: "clip-1" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported source-media override action: unsupported" });
|
||||
});
|
||||
|
||||
it("covers track-state inspection, mute defaults, and unsupported actions", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).manage_track_state_uxp;
|
||||
|
||||
await tool.handler({ action: "inspect" });
|
||||
expect(request).toHaveBeenLastCalledWith("track.state.inspect", {});
|
||||
await tool.handler({
|
||||
action: "inspect", sequence_id: "sequence-1", expected_sequence_id: "sequence-1",
|
||||
media_type: "all", track_indices: [0, 1],
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("track.state.inspect", {
|
||||
sequenceId: "sequence-1", expectedSequenceId: "sequence-1",
|
||||
mediaType: "all", trackIndices: [0, 1],
|
||||
});
|
||||
await tool.handler({ action: "set_mute" });
|
||||
expect(request).toHaveBeenLastCalledWith("track.state.set", {});
|
||||
await expect(tool.handler({ action: "unsupported" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported track-state action: unsupported" });
|
||||
});
|
||||
|
||||
it("covers source-clip inspection, complete mappings, absent items, and rejection", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).manage_source_clip_uxp;
|
||||
|
||||
await tool.handler({ action: "inspect" });
|
||||
expect(request).toHaveBeenLastCalledWith("source.clip.inspect", { items: undefined });
|
||||
await tool.handler({ action: "inspect", items: [{}] });
|
||||
expect(request).toHaveBeenLastCalledWith("source.clip.inspect", { items: [{}] });
|
||||
await tool.handler({
|
||||
action: "inspect",
|
||||
items: [{
|
||||
project_item_id: "clip-1", media_type: "audio",
|
||||
expected_in_seconds: 1, expected_out_seconds: 10,
|
||||
in_seconds: 2, out_seconds: 9, clear_in_out: false, scale_to_frame: true,
|
||||
}],
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("source.clip.inspect", {
|
||||
items: [{
|
||||
projectItemId: "clip-1", mediaType: "audio",
|
||||
expectedInSeconds: 1, expectedOutSeconds: 10,
|
||||
inSeconds: 2, outSeconds: 9, clearInOut: false, scaleToFrame: true,
|
||||
}],
|
||||
});
|
||||
await tool.handler({ action: "update" });
|
||||
expect(request).toHaveBeenLastCalledWith("source.clip.update", { items: undefined });
|
||||
await expect(tool.handler({ action: "unsupported" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported source-clip action: unsupported" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpObjectMaskAuditWorkflowTools } from "../../src/tools/uxp-object-mask-audit-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
describe("public documented UXP object-mask audit tool", () => {
|
||||
it("publishes a bounded read-only schema and maps only explicit audit selectors", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ projectGuid: "project-1" });
|
||||
const tool = getUxpObjectMaskAuditWorkflowTools({ request } as unknown as UxpWebSocketBridge).audit_object_masks_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false,
|
||||
properties: {
|
||||
expected_project_guid: { minLength: 1, maxLength: 512 },
|
||||
sequence_ids: { minItems: 1, maxItems: 64, uniqueItems: true, items: { minLength: 1, maxLength: 512 } },
|
||||
},
|
||||
});
|
||||
expect(tool.description).toContain("not mask count");
|
||||
expect(tool.operationalCapability).toMatchObject({ backend: "UXP", minimumPremiereVersion: "26.3", hostVerificationRequired: true });
|
||||
|
||||
await tool.handler({ expected_project_guid: "project-1", sequence_ids: ["sequence-b", "sequence-a"] });
|
||||
await tool.handler({});
|
||||
expect(request).toHaveBeenNthCalledWith(1, "objectMask.audit", {
|
||||
expectedProjectGuid: "project-1", sequenceIds: ["sequence-b", "sequence-a"],
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "objectMask.audit", {});
|
||||
});
|
||||
});
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
import { getUxpTools } from "../../src/tools/uxp.js";
|
||||
|
||||
function createTools(result: unknown = { accepted: true }) {
|
||||
const request = vi.fn().mockResolvedValue(result);
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
return { request, tools: getUxpTools(bridge) };
|
||||
}
|
||||
|
||||
describe("UXP tool optional argument mappings", () => {
|
||||
it("keeps an explicitly empty AAF options object distinct from omitted options", async () => {
|
||||
const { request, tools } = createTools({ path: "/exports/mix.aaf" });
|
||||
|
||||
await expect(tools.export_aaf_uxp.handler({
|
||||
output_file_path: "/exports/mix.aaf",
|
||||
options: {},
|
||||
})).resolves.toEqual({
|
||||
success: true,
|
||||
data: { backend: "uxp", result: { path: "/exports/mix.aaf" } },
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("interchange.aaf.export", {
|
||||
outputFilePath: "/exports/mix.aaf",
|
||||
options: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps explicitly false AAF toggles and the optional operation id", async () => {
|
||||
const { request, tools } = createTools();
|
||||
|
||||
await tools.export_aaf_uxp.handler({
|
||||
output_file_path: "/exports/final.aaf",
|
||||
operation_id: "aaf-optional-1",
|
||||
options: {
|
||||
mixdown_video: false,
|
||||
explode_to_mono: false,
|
||||
sample_rate: 48_000,
|
||||
bits_per_sample: 24,
|
||||
embed_audio: false,
|
||||
audio_file_format: "wav",
|
||||
trim_sources: false,
|
||||
handle_frames: 0,
|
||||
video_mixdown_preset_path: "/presets/none.epr",
|
||||
render_audio_effects: false,
|
||||
interleave_without_effects: false,
|
||||
preserve_parent_folder: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenCalledWith("interchange.aaf.export", {
|
||||
outputFilePath: "/exports/final.aaf",
|
||||
operationId: "aaf-optional-1",
|
||||
options: {
|
||||
mixdownVideo: false,
|
||||
explodeToMono: false,
|
||||
sampleRate: 48_000,
|
||||
bitsPerSample: 24,
|
||||
embedAudio: false,
|
||||
audioFileFormat: "wav",
|
||||
trimSources: false,
|
||||
handleFrames: 0,
|
||||
videoMixdownPresetPath: "/presets/none.epr",
|
||||
renderAudioEffects: false,
|
||||
interleaveWithoutEffects: false,
|
||||
preserveParentFolder: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes a non-Error transcript export failure while mapping name selection", async () => {
|
||||
const { request, tools } = createTools();
|
||||
request.mockRejectedValueOnce("host disconnected");
|
||||
|
||||
await expect(tools.get_clip_transcript_uxp.handler({
|
||||
project_item_name: "Interview A",
|
||||
})).resolves.toEqual({ success: false, error: "host disconnected" });
|
||||
expect(request).toHaveBeenCalledWith("transcript.export", {
|
||||
projectItemName: "Interview A",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an invalid transcript before generating a preview token", async () => {
|
||||
const { request, tools } = createTools({ projectItemId: "clip-9" });
|
||||
|
||||
await expect(tools.preview_transcript_edit_uxp.handler({
|
||||
project_item_id: "clip-9",
|
||||
transcript_revision: `sha256:${"a".repeat(64)}`,
|
||||
deletions: [{ start_seconds: 1, end_seconds: 2 }],
|
||||
})).resolves.toEqual({
|
||||
success: false,
|
||||
error: "Premiere returned an empty transcript",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("transcript.export", { projectItemId: "clip-9" });
|
||||
});
|
||||
|
||||
it("normalizes a non-Error transcript preview export failure", async () => {
|
||||
const { request, tools } = createTools();
|
||||
request.mockRejectedValueOnce(null);
|
||||
|
||||
await expect(tools.preview_transcript_edit_uxp.handler({
|
||||
transcript_revision: `sha256:${"b".repeat(64)}`,
|
||||
deletions: [{ start_seconds: 1, end_seconds: 2 }],
|
||||
})).resolves.toEqual({ success: false, error: "null" });
|
||||
});
|
||||
|
||||
it("maps the explicit sequence marker scope to the wire protocol spelling", async () => {
|
||||
const { request, tools } = createTools();
|
||||
|
||||
await tools.list_markers_uxp.handler({ scope: "sequence" });
|
||||
expect(request).toHaveBeenCalledWith("marker.list", { scope: "sequence" });
|
||||
});
|
||||
|
||||
it("maps an explicit marker web-link read opt-in to the protocol", async () => {
|
||||
const { request, tools } = createTools();
|
||||
|
||||
await tools.list_markers_uxp.handler({ scope: "sequence", include_web_links: true });
|
||||
expect(request).toHaveBeenCalledWith("marker.list", { scope: "sequence", includeWebLinks: true });
|
||||
});
|
||||
|
||||
it("maps an explicit marker raw-color read opt-in to the protocol", async () => {
|
||||
const { request, tools } = createTools();
|
||||
|
||||
await tools.list_markers_uxp.handler({ scope: "sequence", include_color_values: true });
|
||||
expect(request).toHaveBeenCalledWith("marker.list", { scope: "sequence", includeColorValues: true });
|
||||
});
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpRippleDeleteWorkflowTools } from "../../src/tools/uxp-ripple-delete-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
const target = { project_item_id: "target-1", start_seconds: 0, end_seconds: 10, in_seconds: 20, out_seconds: 30, duration_seconds: 10, speed: 1, reversed: false };
|
||||
const following = { project_item_id: "following-1", start_seconds: 10, end_seconds: 20, in_seconds: 40, out_seconds: 50, duration_seconds: 10, speed: 1, reversed: false };
|
||||
|
||||
describe("public guarded contiguous track-item ripple-delete MCP tool", () => {
|
||||
it("uses closed target and successor snapshot schemas and translates snake_case apply arguments", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const tool = getUxpRippleDeleteWorkflowTools({ request } as unknown as UxpWebSocketBridge).ripple_delete_track_item_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
additionalProperties: false, required: ["action", "media_type", "track_index", "clip_index"],
|
||||
properties: { action: { enum: ["inspect", "apply"] }, expected_snapshot: { additionalProperties: false, required: ["project_guid", "sequence_id", "media_type", "track_index", "clip_index", "track_item_count", "target", "following"] } },
|
||||
});
|
||||
await tool.handler({
|
||||
action: "apply", media_type: "video", track_index: 0, clip_index: 0, confirm_ripple_delete: true, operation_id: "ripple-tool-1",
|
||||
expected_snapshot: { project_guid: "project-1", sequence_id: "sequence-1", media_type: "video", track_index: 0, clip_index: 0, track_item_count: 2, target, following },
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("trackItem.rippleDelete", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 0, confirmRippleDelete: true, operationId: "ripple-tool-1",
|
||||
expectedSnapshot: {
|
||||
projectGuid: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0, clipIndex: 0, trackItemCount: 2,
|
||||
target: { projectItemId: "target-1", startSeconds: 0, endSeconds: 10, inSeconds: 20, outSeconds: 30, durationSeconds: 10, speed: 1, reversed: false },
|
||||
following: { projectItemId: "following-1", startSeconds: 10, endSeconds: 20, inSeconds: 40, outSeconds: 50, durationSeconds: 10, speed: 1, reversed: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not silently drop unknown fields from a reviewed successor snapshot", async () => {
|
||||
const request = vi.fn();
|
||||
const tool = getUxpRippleDeleteWorkflowTools({ request } as unknown as UxpWebSocketBridge).ripple_delete_track_item_uxp;
|
||||
await expect(tool.handler({
|
||||
action: "apply", media_type: "video", track_index: 0, clip_index: 0,
|
||||
expected_snapshot: { project_guid: "project-1", sequence_id: "sequence-1", media_type: "video", track_index: 0, clip_index: 0, track_item_count: 2, target, following: { ...following, unexpected: true } },
|
||||
})).rejects.toThrow("expected_snapshot.following has an unknown field: unexpected");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpSequencePreviewFrameWorkflowTools } from "../../src/tools/uxp-sequence-preview-frame-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
const snapshot = {
|
||||
project_guid: "project-1", sequence_id: "sequence-1", preview_width: 640, preview_height: 360,
|
||||
};
|
||||
|
||||
describe("public guarded sequence preview-frame MCP tool", () => {
|
||||
it("uses a closed complete snapshot and translates the update arguments", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const tool = getUxpSequencePreviewFrameWorkflowTools({ request } as unknown as UxpWebSocketBridge).manage_sequence_preview_frame_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
additionalProperties: false, required: ["action", "sequence_id"],
|
||||
properties: { action: { enum: ["inspect", "update"] }, expected_snapshot: { additionalProperties: false, required: Object.keys(snapshot) } },
|
||||
});
|
||||
await tool.handler({
|
||||
action: "update", sequence_id: "sequence-1", preview_width: 1920, preview_height: 1080,
|
||||
expected_snapshot: snapshot, confirm_set_preview_frame: true, operation_id: "preview-frame-tool-1",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("sequence.previewFrame.update", {
|
||||
sequenceId: "sequence-1", previewWidth: 1920, previewHeight: 1080, confirmSetPreviewFrame: true, operationId: "preview-frame-tool-1",
|
||||
expectedSnapshot: { projectGuid: "project-1", sequenceId: "sequence-1", previewWidth: 640, previewHeight: 360 },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not silently drop unknown reviewed fields", async () => {
|
||||
const request = vi.fn();
|
||||
const tool = getUxpSequencePreviewFrameWorkflowTools({ request } as unknown as UxpWebSocketBridge).manage_sequence_preview_frame_uxp;
|
||||
await expect(tool.handler({
|
||||
action: "update", sequence_id: "sequence-1", preview_width: 1920, preview_height: 1080,
|
||||
expected_snapshot: { ...snapshot, unexpected: true }, confirm_set_preview_frame: true, operation_id: "preview-frame-invalid",
|
||||
})).rejects.toThrow("expected_snapshot has an unknown field: unexpected");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpSlideWorkflowTools } from "../../src/tools/uxp-slide-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
const item = { start_seconds: 0, end_seconds: 10, in_seconds: 0, out_seconds: 10, duration_seconds: 10, speed: 1, reversed: false };
|
||||
|
||||
describe("public guarded track-item slide MCP tool", () => {
|
||||
it("uses a closed complete three-item snapshot schema and translates snake_case apply arguments", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const tool = getUxpSlideWorkflowTools({ request } as unknown as UxpWebSocketBridge).slide_track_item_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
additionalProperties: false, required: ["action", "media_type", "track_index", "clip_index"],
|
||||
properties: { action: { enum: ["inspect", "apply"] }, slide_by_seconds: { minimum: -60, maximum: 60 }, expected_snapshot: { additionalProperties: false, required: ["project_guid", "sequence_id", "media_type", "track_index", "clip_index", "previous", "target", "following"] } },
|
||||
});
|
||||
await tool.handler({
|
||||
action: "apply", media_type: "video", track_index: 0, clip_index: 1, slide_by_seconds: 2, confirm_slide: true, operation_id: "slide-tool-1",
|
||||
expected_snapshot: { project_guid: "project-1", sequence_id: "sequence-1", media_type: "video", track_index: 0, clip_index: 1, previous: item, target: { ...item, start_seconds: 10, end_seconds: 20, in_seconds: 20, out_seconds: 30 }, following: { ...item, start_seconds: 20, end_seconds: 30, in_seconds: 30, out_seconds: 40 } },
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("trackItem.slide", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, slideBySeconds: 2, confirmSlide: true, operationId: "slide-tool-1",
|
||||
expectedSnapshot: {
|
||||
projectGuid: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0, clipIndex: 1,
|
||||
previous: { startSeconds: 0, endSeconds: 10, inSeconds: 0, outSeconds: 10, durationSeconds: 10, speed: 1, reversed: false },
|
||||
target: { startSeconds: 10, endSeconds: 20, inSeconds: 20, outSeconds: 30, durationSeconds: 10, speed: 1, reversed: false },
|
||||
following: { startSeconds: 20, endSeconds: 30, inSeconds: 30, outSeconds: 40, durationSeconds: 10, speed: 1, reversed: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not silently drop unknown fields from a reviewed neighbour snapshot", async () => {
|
||||
const request = vi.fn();
|
||||
const tool = getUxpSlideWorkflowTools({ request } as unknown as UxpWebSocketBridge).slide_track_item_uxp;
|
||||
await expect(tool.handler({
|
||||
action: "apply", media_type: "video", track_index: 0, clip_index: 1,
|
||||
expected_snapshot: { project_guid: "project-1", sequence_id: "sequence-1", media_type: "video", track_index: 0, clip_index: 1, previous: { ...item, unexpected: true }, target: item, following: item },
|
||||
})).rejects.toThrow("expected_snapshot.previous has an unknown field: unexpected");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpSlipWorkflowTools } from "../../src/tools/uxp-slip-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
describe("public guarded track-item slip MCP tool", () => {
|
||||
it("uses a closed complete snapshot schema and translates snake_case apply arguments", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpSlipWorkflowTools(bridge).slip_track_item_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action", "media_type", "track_index", "clip_index"],
|
||||
properties: {
|
||||
action: { enum: ["inspect", "apply"] },
|
||||
slip_by_seconds: { minimum: -60, maximum: 60 },
|
||||
expected_snapshot: {
|
||||
additionalProperties: false,
|
||||
required: ["project_guid", "sequence_id", "media_type", "track_index", "clip_index", "start_seconds", "end_seconds", "in_seconds", "out_seconds", "duration_seconds", "speed", "reversed"],
|
||||
},
|
||||
},
|
||||
});
|
||||
await tool.handler({
|
||||
action: "apply", media_type: "video", track_index: 0, clip_index: 1,
|
||||
expected_snapshot: {
|
||||
project_guid: "project-1", sequence_id: "sequence-1", media_type: "video", track_index: 0, clip_index: 1,
|
||||
start_seconds: 10, end_seconds: 20, in_seconds: 30, out_seconds: 40, duration_seconds: 10, speed: 1, reversed: false,
|
||||
},
|
||||
slip_by_seconds: -2, confirm_slip: true, operation_id: "slip-tool-1",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("trackItem.slip", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1,
|
||||
expectedSnapshot: {
|
||||
projectGuid: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0, clipIndex: 1,
|
||||
startSeconds: 10, endSeconds: 20, inSeconds: 30, outSeconds: 40, durationSeconds: 10, speed: 1, reversed: false,
|
||||
},
|
||||
slipBySeconds: -2, confirmSlip: true, operationId: "slip-tool-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not silently drop malformed or unknown reviewed snapshot fields", async () => {
|
||||
const request = vi.fn();
|
||||
const bridge = { request } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpSlipWorkflowTools(bridge).slip_track_item_uxp;
|
||||
await expect(tool.handler({
|
||||
action: "apply", media_type: "video", track_index: 0, clip_index: 0,
|
||||
expected_snapshot: { project_guid: "project-1", unexpected: true },
|
||||
})).rejects.toThrow("expected_snapshot has an unknown field: unexpected");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpSourceMediaProvenanceWorkflowTools } from "../../src/tools/uxp-source-media-provenance-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
describe("public bounded source-media provenance MCP tool", () => {
|
||||
it("requires an explicit path opt-in schema and translates only supplied flags", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ projectGuid: "project-1" });
|
||||
const tool = getUxpSourceMediaProvenanceWorkflowTools({
|
||||
request,
|
||||
} as unknown as UxpWebSocketBridge).inspect_source_media_provenance_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["project_item_id"],
|
||||
properties: {
|
||||
project_item_id: { minLength: 1, maxLength: 512 },
|
||||
include_media_file_path: { type: "boolean" },
|
||||
include_originating_project_path: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
await tool.handler({
|
||||
project_item_id: "clip-1", include_media_file_path: true,
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("source.provenance.inspect", {
|
||||
projectItemId: "clip-1", includeMediaFilePath: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an explicit false flag so the host can enforce the paired opt-in", async () => {
|
||||
const request = vi.fn().mockResolvedValue({});
|
||||
const tool = getUxpSourceMediaProvenanceWorkflowTools({
|
||||
request,
|
||||
} as unknown as UxpWebSocketBridge).inspect_source_media_provenance_uxp;
|
||||
await tool.handler({
|
||||
project_item_id: "clip-1", include_media_file_path: false, include_originating_project_path: true,
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("source.provenance.inspect", {
|
||||
projectItemId: "clip-1", includeMediaFilePath: false, includeOriginatingProjectPath: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpSourceProxyWorkflowTools } from "../../src/tools/uxp-source-proxy-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
describe("public bounded source-proxy MCP tool", () => {
|
||||
it("documents a read-only explicit target and translates only a supplied proxy-path opt-in", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ projectGuid: "project-1" });
|
||||
const tool = getUxpSourceProxyWorkflowTools({ request } as unknown as UxpWebSocketBridge).inspect_source_proxy_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["project_item_id"],
|
||||
properties: {
|
||||
project_item_id: { minLength: 1, maxLength: 512 },
|
||||
include_proxy_path: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
expect(tool.annotations).toEqual({ readOnlyHint: true, destructiveHint: false });
|
||||
await expect(tool.handler({ project_item_id: "clip-1" })).resolves.toMatchObject({ success: true });
|
||||
expect(request).toHaveBeenLastCalledWith("source.proxy.inspect", { projectItemId: "clip-1" });
|
||||
await expect(tool.handler({ project_item_id: "clip-1", include_proxy_path: true })).resolves.toMatchObject({ success: true });
|
||||
expect(request).toHaveBeenLastCalledWith("source.proxy.inspect", { projectItemId: "clip-1", includeProxyPath: true });
|
||||
expect(tool.description).toContain("does not enumerate folders");
|
||||
expect(tool.description).toContain("does not access the filesystem");
|
||||
expect(tool.description).toContain("licensed-host behavior");
|
||||
});
|
||||
|
||||
it("returns a truthful bridge error without a fallback backend", async () => {
|
||||
const tool = getUxpSourceProxyWorkflowTools({
|
||||
request: vi.fn().mockRejectedValue(new Error("source.proxy.inspect unavailable")),
|
||||
} as unknown as UxpWebSocketBridge).inspect_source_proxy_uxp;
|
||||
await expect(tool.handler({ project_item_id: "clip-1" })).resolves.toEqual({
|
||||
success: false, error: "source.proxy.inspect unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpTickTimeArithmeticWorkflowTools } from "../../src/tools/uxp-tick-time-arithmetic-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
describe("public native TickTime arithmetic MCP tool", () => {
|
||||
it("uses a closed read-only schema and preserves canonical tick inputs", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ result: { ticks: "3", seconds: 0 } });
|
||||
const tool = getUxpTickTimeArithmeticWorkflowTools({ request } as unknown as UxpWebSocketBridge).calculate_tick_time_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
additionalProperties: false, required: ["operation", "base_ticks"],
|
||||
properties: { operation: { enum: ["add", "subtract", "multiply", "divide"] }, base_ticks: { pattern: "^(?:0|-?[1-9][0-9]{0,17})$" } },
|
||||
});
|
||||
expect(tool.annotations).toEqual({ readOnlyHint: true, destructiveHint: false });
|
||||
await tool.handler({ operation: "add", base_ticks: "1", operand_ticks: "2" });
|
||||
await tool.handler({ operation: "multiply", base_ticks: "3", factor: 4 });
|
||||
expect(request).toHaveBeenNthCalledWith(1, "time.tickArithmetic.inspect", { operation: "add", baseTicks: "1", operandTicks: "2" });
|
||||
expect(request).toHaveBeenNthCalledWith(2, "time.tickArithmetic.inspect", { operation: "multiply", baseTicks: "3", factor: 4 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpTimelineSourceLabelWorkflowTools } from "../../src/tools/uxp-timeline-source-label-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
const snapshot = {
|
||||
project_guid: "project-1", sequence_id: "sequence-1", media_type: "video" as const, track_index: 0, clip_index: 0,
|
||||
track_item_count: 1, source_project_item_id: "source-1", source_color_label_index: 3, start_seconds: 12, end_seconds: 20,
|
||||
};
|
||||
|
||||
describe("public guarded timeline source-label MCP tool", () => {
|
||||
it("uses a closed complete source snapshot and translates update arguments", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const tool = getUxpTimelineSourceLabelWorkflowTools({ request } as unknown as UxpWebSocketBridge).manage_timeline_source_label_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
additionalProperties: false, required: ["action", "media_type", "track_index", "clip_index"],
|
||||
properties: { action: { enum: ["inspect", "update"] }, expected_snapshot: { additionalProperties: false, required: Object.keys(snapshot) } },
|
||||
});
|
||||
await tool.handler({
|
||||
action: "update", media_type: "video", track_index: 0, clip_index: 0, color_index: 9,
|
||||
expected_snapshot: snapshot, confirm_set_label: true, operation_id: "source-label-tool-1",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("timeline.sourceLabel.update", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 0, colorIndex: 9, confirmSetLabel: true, operationId: "source-label-tool-1",
|
||||
expectedSnapshot: {
|
||||
projectGuid: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0, clipIndex: 0,
|
||||
trackItemCount: 1, sourceProjectItemId: "source-1", sourceColorLabelIndex: 3, startSeconds: 12, endSeconds: 20,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not silently drop unknown reviewed source fields", async () => {
|
||||
const request = vi.fn();
|
||||
const tool = getUxpTimelineSourceLabelWorkflowTools({ request } as unknown as UxpWebSocketBridge).manage_timeline_source_label_uxp;
|
||||
await expect(tool.handler({
|
||||
action: "update", media_type: "video", track_index: 0, clip_index: 0, color_index: 9,
|
||||
expected_snapshot: { ...snapshot, unexpected: true }, confirm_set_label: true, operation_id: "source-label-tool-invalid",
|
||||
})).rejects.toThrow("expected_snapshot has an unknown field: unexpected");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Executable
+474
@@ -0,0 +1,474 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpTools } from "../../src/tools/uxp.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
import { createServer } from "../../src/server.js";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
|
||||
describe("UXP MCP tools", () => {
|
||||
it("maps MCP arguments to the supported frame export command", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ path: "/tmp/frame.png" });
|
||||
const bridge = {
|
||||
request,
|
||||
getState: vi.fn(),
|
||||
} as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
const result = await tools.export_frame_uxp.handler({
|
||||
output_directory: "/tmp",
|
||||
filename: "frame.png",
|
||||
seconds: 2.5,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("frame.export", {
|
||||
outputDirectory: "/tmp",
|
||||
filename: "frame.png",
|
||||
seconds: 2.5,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { backend: "uxp", result: { path: "/tmp/frame.png" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes connection discovery without requiring a host request", async () => {
|
||||
const state = { status: "listening", connected: false } as const;
|
||||
const bridge = {
|
||||
request: vi.fn(),
|
||||
getState: vi.fn(() => state),
|
||||
} as unknown as UxpWebSocketBridge;
|
||||
const result = await getUxpTools(bridge).get_uxp_capabilities.handler();
|
||||
expect(result).toEqual({ success: true, data: state });
|
||||
expect(bridge.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps verified project and interchange workflows to versioned UXP commands", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
await tools.create_sequence_with_preset_uxp.handler({
|
||||
name: "Delivery",
|
||||
preset_path: "/presets/hd.sqpreset",
|
||||
operation_id: "sequence-1",
|
||||
});
|
||||
await tools.export_interchange_uxp.handler({
|
||||
format: "otio",
|
||||
output_file_path: "/exports/edit.otio",
|
||||
operation_id: "export-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sequence.createPreset", {
|
||||
name: "Delivery", presetPath: "/presets/hd.sqpreset", operationId: "sequence-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "interchange.export", {
|
||||
format: "otio", outputFilePath: "/exports/edit.otio", operationId: "export-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps guarded empty sequence creation only after confirmation and with a replay key", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpTools(bridge).create_empty_sequence_uxp;
|
||||
await expect(tool.handler({
|
||||
name: "Empty Assembly", confirm_non_undoable: false, operation_id: "empty-1",
|
||||
})).resolves.toEqual({ success: false, error: "create_empty_sequence_uxp requires confirm_non_undoable: true" });
|
||||
await expect(tool.handler({
|
||||
name: "Empty Assembly", confirm_non_undoable: true, operation_id: "",
|
||||
})).resolves.toEqual({ success: false, error: "create_empty_sequence_uxp requires operation_id for safe replay" });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
await expect(tool.handler({
|
||||
name: "Empty Assembly", confirm_non_undoable: true, operation_id: "empty-1",
|
||||
})).resolves.toEqual({ success: true, data: { backend: "uxp", result: { outcome: "verified" } } });
|
||||
expect(request).toHaveBeenCalledWith("sequences.createEmpty", {
|
||||
name: "Empty Assembly", confirmNonUndoable: true, operationId: "empty-1",
|
||||
});
|
||||
expect(tool.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["name", "confirm_non_undoable", "operation_id"],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps native caption-track inspection to its documented UXP command", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ trackCount: 0, tracks: [] });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
await expect(getUxpTools(bridge).inspect_caption_tracks_uxp.handler()).resolves.toEqual({
|
||||
success: true,
|
||||
data: { backend: "uxp", result: { trackCount: 0, tracks: [] } },
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("captions.inspect", {});
|
||||
});
|
||||
|
||||
it("maps guarded native Project-panel insertion-bin inspection to its documented UXP command", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ projectGuid: "project-1" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpTools(bridge).inspect_project_insertion_bin_uxp;
|
||||
await expect(tool.handler()).resolves.toEqual({
|
||||
success: true,
|
||||
data: { backend: "uxp", result: { projectGuid: "project-1" } },
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("project.insertionBin.inspect", {});
|
||||
expect(tool.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps opt-in installed MOGRT-directory disclosure to its documented UXP command", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ pathDisclosure: "requested" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpTools(bridge).inspect_installed_mogrt_directory_uxp;
|
||||
await expect(tool.handler()).resolves.toEqual({
|
||||
success: true,
|
||||
data: { backend: "uxp", result: { pathDisclosure: "requested" } },
|
||||
});
|
||||
await tool.handler({ include_path: true });
|
||||
expect(request).toHaveBeenNthCalledWith(1, "graphics.mogrtPath.inspect", {});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "graphics.mogrtPath.inspect", { includePath: true });
|
||||
expect(tool.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: { include_path: { type: "boolean" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps guarded sequence-range inspection and updates to documented UXP commands", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
await tools.manage_sequence_range_uxp.handler({ action: "inspect" });
|
||||
await tools.manage_sequence_range_uxp.handler({
|
||||
action: "update",
|
||||
expected_sequence_guid: "sequence-1",
|
||||
expected_range: { in_seconds: 1, out_seconds: 10, zero_point_seconds: 3600, end_seconds: 120 },
|
||||
updates: { in_seconds: 2, zero_point_seconds: 7200 },
|
||||
operation_id: "range-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sequence.range.inspect", {});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "sequence.range.update", {
|
||||
expectedSequenceGuid: "sequence-1",
|
||||
expectedRange: { inSeconds: 1, outSeconds: 10, zeroPointSeconds: 3600, endSeconds: 120 },
|
||||
updates: { inSeconds: 2, zeroPointSeconds: 7200 },
|
||||
operationId: "range-1",
|
||||
});
|
||||
expect(tools.manage_sequence_range_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { type: "string", enum: ["inspect", "update"] },
|
||||
expected_sequence_guid: { type: "string" },
|
||||
expected_range: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["in_seconds", "out_seconds", "zero_point_seconds", "end_seconds"],
|
||||
},
|
||||
updates: { type: "object", additionalProperties: false },
|
||||
operation_id: { type: "string" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps guarded sequence-playhead inspection and settings to documented UXP commands", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
await tools.manage_sequence_playhead_uxp.handler({ action: "inspect" });
|
||||
await tools.manage_sequence_playhead_uxp.handler({
|
||||
action: "set",
|
||||
expected_sequence_guid: "sequence-1",
|
||||
expected_position_seconds: 3,
|
||||
position_seconds: 8,
|
||||
operation_id: "playhead-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sequence.playhead.inspect", {});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "sequence.playhead.set", {
|
||||
expectedSequenceGuid: "sequence-1",
|
||||
expectedPositionSeconds: 3,
|
||||
positionSeconds: 8,
|
||||
operationId: "playhead-1",
|
||||
});
|
||||
expect(tools.manage_sequence_playhead_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { type: "string", enum: ["inspect", "set"] },
|
||||
expected_sequence_guid: { type: "string" },
|
||||
expected_position_seconds: { type: "number", minimum: 0, maximum: 86400 },
|
||||
position_seconds: { type: "number", minimum: 0, maximum: 86400 },
|
||||
operation_id: { type: "string" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps bounded guarded app-preference inspection and direct writes to documented UXP commands", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
await tools.manage_app_preferences_uxp.handler({ action: "inspect" });
|
||||
await tools.manage_app_preferences_uxp.handler({
|
||||
action: "set",
|
||||
preference: "auto_peak_generation",
|
||||
expected_value: "0",
|
||||
value: "1",
|
||||
persistence: "persistent",
|
||||
confirm_preference_change: true,
|
||||
operation_id: "app-preference-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(1, "preferences.inspect", {});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "preferences.set", {
|
||||
preference: "auto_peak_generation",
|
||||
expectedValue: "0",
|
||||
value: "1",
|
||||
persistence: "persistent",
|
||||
confirmPreferenceChange: true,
|
||||
operationId: "app-preference-1",
|
||||
});
|
||||
expect(tools.manage_app_preferences_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { type: "string", enum: ["inspect", "set"] },
|
||||
preference: { enum: ["auto_peak_generation", "import_workspace", "show_quickstart_dialog"] },
|
||||
expected_value: { type: "string", maxLength: 1024 },
|
||||
value: { type: "string", maxLength: 1024 },
|
||||
persistence: { enum: ["persistent", "non_persistent"] },
|
||||
confirm_preference_change: { type: "boolean" },
|
||||
operation_id: { type: "string", minLength: 1, maxLength: 128 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects incomplete or unconfirmed app-preference writes before bridge access", async () => {
|
||||
const request = vi.fn();
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpTools(bridge).manage_app_preferences_uxp;
|
||||
await expect(tool.handler({ action: "set", preference: "import_workspace" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("requires preference") });
|
||||
await expect(tool.handler({
|
||||
action: "set", preference: "import_workspace", expected_value: "1", value: "0", persistence: "persistent", operation_id: "app-preference-unconfirmed",
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("confirm_preference_change") });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps bounded native sequence-timing inspection to its documented UXP command", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
await tools.inspect_sequence_timing_uxp.handler({});
|
||||
expect(request).toHaveBeenCalledWith("sequence.timing.inspect", {});
|
||||
expect(tools.inspect_sequence_timing_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps native frame alignment with a closed action-specific argument contract", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ verificationBoundary: "native_ticktime_value_readback" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
await tools.inspect_frame_alignment_uxp.handler({ action: "align", frame_rate: 30000 / 1001, seconds: 1.03 });
|
||||
await tools.inspect_frame_alignment_uxp.handler({ action: "frame", frame_rate: 24, frame_count: 42 });
|
||||
expect(request).toHaveBeenNthCalledWith(1, "time.frameAlignment.inspect", { action: "align", frameRate: 30000 / 1001, seconds: 1.03 });
|
||||
expect(request).toHaveBeenNthCalledWith(2, "time.frameAlignment.inspect", { action: "frame", frameRate: 24, frameCount: 42 });
|
||||
expect(tools.inspect_frame_alignment_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false, required: ["action", "frame_rate"],
|
||||
properties: {
|
||||
action: { enum: ["align", "frame"] },
|
||||
frame_rate: { minimum: 1, maximum: 240 },
|
||||
seconds: { minimum: 0, maximum: 86400 },
|
||||
frame_count: { minimum: 0, maximum: 20736000 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps non-active native sequence timing inspection to its guarded GUID command", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
await tools.inspect_sequence_timing_by_guid_uxp.handler({ sequence_guid: "sequence-2" });
|
||||
expect(request).toHaveBeenCalledWith("sequence.timingByGuid.inspect", { sequenceGuid: "sequence-2" });
|
||||
expect(tools.inspect_sequence_timing_by_guid_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false, required: ["sequence_guid"],
|
||||
properties: { sequence_guid: { minLength: 1, maxLength: 512 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps bounded native timeline-structure inspection and documents scoped track counts", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
await tools.inspect_sequence_structure_uxp.handler({
|
||||
sequence_id: "sequence-1", expected_sequence_id: "sequence-1", media_type: "video",
|
||||
track_indices: [1, 0], include_empty_tracks: true, include_source_project_items: true,
|
||||
include_source_project_item_classification: true, include_source_project_item_content_type: true,
|
||||
include_source_nested_sequence_identity: true, max_items: 8,
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("timeline.structure.inspect", {
|
||||
sequenceId: "sequence-1", expectedSequenceId: "sequence-1", mediaType: "video",
|
||||
trackIndices: [1, 0], includeEmptyTracks: true, includeSourceProjectItems: true,
|
||||
includeSourceProjectItemClassification: true, includeSourceProjectItemContentType: true,
|
||||
includeSourceNestedSequenceIdentity: true, maxItems: 8,
|
||||
});
|
||||
expect(tools.inspect_sequence_structure_uxp.description).toContain(
|
||||
"track_counts contains only the requested media types",
|
||||
);
|
||||
expect(tools.inspect_sequence_structure_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
sequence_id: { type: "string", minLength: 1, maxLength: 128 },
|
||||
expected_sequence_id: { type: "string", minLength: 1, maxLength: 128 },
|
||||
media_type: { type: "string", enum: ["all", "video", "audio"] },
|
||||
track_indices: { type: "array", minItems: 1, maxItems: 64, uniqueItems: true },
|
||||
include_empty_tracks: { type: "boolean" },
|
||||
include_source_project_items: { type: "boolean" },
|
||||
include_source_project_item_classification: { type: "boolean" },
|
||||
include_source_project_item_content_type: { type: "boolean" },
|
||||
include_source_nested_sequence_identity: { type: "boolean" },
|
||||
max_items: { type: "integer", minimum: 1, maximum: 512 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps guarded native transition inspection and mutation arguments to documented UXP commands", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "committed_unverified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
const expectedTarget = {
|
||||
sequence_guid: "sequence-1", video_track_index: 2, clip_index: 3, project_item_id: "project-item-1",
|
||||
start_seconds: 10, end_seconds: 20, position: "end" as const, transition_present: false,
|
||||
};
|
||||
await tools.lift_selection_uxp.handler({ expected_sequence_guid: "sequence-1", operation_id: "lift-1" });
|
||||
await tools.list_video_transitions_uxp.handler();
|
||||
await tools.inspect_video_transition_uxp.handler({ video_track_index: 2, clip_index: 3, position: "end" });
|
||||
await tools.add_video_transition_uxp.handler({
|
||||
video_track_index: 2, clip_index: 3, match_name: "CrossDissolve", position: "end",
|
||||
duration_seconds: 0.5, force_single_sided: true, transition_alignment: 1, expected_target: expectedTarget, operation_id: "transition-add-1",
|
||||
});
|
||||
await tools.remove_video_transition_uxp.handler({
|
||||
video_track_index: 2, clip_index: 3, position: "end", expected_target: { ...expectedTarget, transition_present: true }, operation_id: "transition-remove-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(1, "timeline.selection.lift", { expectedSequenceGuid: "sequence-1", operationId: "lift-1" });
|
||||
expect(request).toHaveBeenNthCalledWith(2, "transition.video.list", {});
|
||||
expect(request).toHaveBeenNthCalledWith(3, "transition.video.inspect", {
|
||||
videoTrackIndex: 2, clipIndex: 3, position: "end",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(4, "transition.video.add", {
|
||||
videoTrackIndex: 2, clipIndex: 3, matchName: "CrossDissolve", position: "end", durationSeconds: 0.5,
|
||||
forceSingleSided: true, transitionAlignment: 1,
|
||||
expectedTarget: {
|
||||
sequenceGuid: "sequence-1", videoTrackIndex: 2, clipIndex: 3, projectItemId: "project-item-1",
|
||||
startSeconds: 10, endSeconds: 20, position: "end", transitionPresent: false,
|
||||
},
|
||||
operationId: "transition-add-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(5, "transition.video.remove", {
|
||||
videoTrackIndex: 2, clipIndex: 3, position: "end", operationId: "transition-remove-1",
|
||||
expectedTarget: {
|
||||
sequenceGuid: "sequence-1", videoTrackIndex: 2, clipIndex: 3, projectItemId: "project-item-1",
|
||||
startSeconds: 10, endSeconds: 20, position: "end", transitionPresent: true,
|
||||
},
|
||||
});
|
||||
expect(tools.add_video_transition_uxp.parameters).toMatchObject({ required: expect.arrayContaining(["expected_target"]) });
|
||||
expect(tools.remove_video_transition_uxp.parameters).toMatchObject({ required: expect.arrayContaining(["expected_target"]) });
|
||||
});
|
||||
|
||||
it("returns transport errors through the normal tool envelope", async () => {
|
||||
const bridge = {
|
||||
request: vi.fn().mockRejectedValue(new Error("UXP bridge is not connected")),
|
||||
getState: vi.fn(),
|
||||
} as unknown as UxpWebSocketBridge;
|
||||
const result = await getUxpTools(bridge).get_uxp_state.handler();
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "UXP bridge is not connected",
|
||||
});
|
||||
});
|
||||
|
||||
it("registers UXP tools only when an adapter is supplied", async () => {
|
||||
const bridge = {
|
||||
request: vi.fn(),
|
||||
getState: vi.fn(() => ({ status: "listening", connected: false })),
|
||||
} as unknown as UxpWebSocketBridge;
|
||||
const server = createServer({}, { uxpBridge: bridge });
|
||||
const client = new Client({ name: "uxp-tool-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
try {
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools.map((tool) => tool.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"get_uxp_capabilities",
|
||||
"get_uxp_state",
|
||||
"inspect_project_uxp",
|
||||
"inspect_project_insertion_bin_uxp",
|
||||
"inspect_installed_mogrt_directory_uxp",
|
||||
"inspect_sequence_timing_uxp",
|
||||
"inspect_frame_alignment_uxp",
|
||||
"calculate_tick_time_uxp",
|
||||
"inspect_sequence_timing_by_guid_uxp",
|
||||
"inspect_caption_tracks_uxp",
|
||||
"manage_sequence_display_format_uxp",
|
||||
"manage_sequence_range_uxp",
|
||||
"manage_sequence_playhead_uxp",
|
||||
"manage_app_preferences_uxp",
|
||||
"save_project_uxp",
|
||||
"create_sequence_with_preset_uxp",
|
||||
"create_empty_sequence_uxp",
|
||||
"export_interchange_uxp",
|
||||
"get_transcript_languages_uxp",
|
||||
"get_clip_transcript_uxp",
|
||||
"search_clip_transcript_uxp",
|
||||
"preview_transcript_edit_uxp",
|
||||
"plan_transcript_rough_cut_uxp",
|
||||
"detect_object_masks_uxp",
|
||||
"audit_object_masks_uxp",
|
||||
"configure_encoder_uxp",
|
||||
"rename_track_uxp",
|
||||
"create_subclip_uxp",
|
||||
"list_markers_uxp",
|
||||
"set_source_monitor_position_uxp",
|
||||
"has_transcript_uxp",
|
||||
"export_aaf_uxp",
|
||||
"export_frame_uxp",
|
||||
"lift_selection_uxp",
|
||||
"list_video_transitions_uxp",
|
||||
"inspect_video_transition_uxp",
|
||||
"add_video_transition_uxp",
|
||||
"remove_video_transition_uxp",
|
||||
"inspect_project_panel_metadata_uxp",
|
||||
"slide_track_item_uxp",
|
||||
"duplicate_track_item_uxp",
|
||||
"ripple_delete_track_item_uxp",
|
||||
"manage_timeline_source_label_uxp",
|
||||
"apply_editorial_organization_plan",
|
||||
]),
|
||||
);
|
||||
// The default profile excludes two unsafe-script tools. The native
|
||||
// transcript workflow and documented Premiere 26.3 tools add nineteen. The
|
||||
// two stable workflow expansions, confirmed organization application, four
|
||||
// bounded native migration adapters, beat-grid marker application, and
|
||||
// guarded sequence-playhead control, native sequence-timing inspection, and
|
||||
// guarded sequence-display-format updates and guarded Project-panel
|
||||
// insertion-bin inspection, guarded empty-sequence creation, marker web-link
|
||||
// inspection, Project-panel metadata inspection and guarded replacement,
|
||||
// guarded app-preference control, source-media interpretation overrides,
|
||||
// direct track-item identity inspection, targeted sequence timing, guarded
|
||||
// three-item slides, append-only timeline duplication, guarded Project metadata
|
||||
// schema-field creation, guarded contiguous ripple deletes, and guarded
|
||||
// source-label updates, opt-in installed-MOGRT directory inspection, and
|
||||
// bounded Object Mask audits, native TickTime arithmetic, and guarded
|
||||
// explicit-sequence preview-frame updates plus unique serializable identity
|
||||
// inspection add fifty consolidated UXP tools;
|
||||
// connection verification and delivery conformance add two default-profile core tools.
|
||||
expect(tools.tools).toHaveLength(440);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpUniqueIdentityWorkflowTools } from "../../src/tools/uxp-unique-identity-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
describe("public documented UXP unique serializable identity tool", () => {
|
||||
it("publishes an exclusive bounded locator schema and maps only explicit selectors", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ projectGuid: "project-1" });
|
||||
const tool = getUxpUniqueIdentityWorkflowTools({ request } as unknown as UxpWebSocketBridge)
|
||||
.inspect_unique_object_identity_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
project_item_id: { minLength: 1, maxLength: 512 },
|
||||
sequence_guid: { minLength: 1, maxLength: 512 },
|
||||
expected_project_guid: { minLength: 1, maxLength: 512 },
|
||||
expected_unique_id: { minLength: 1, maxLength: 512 },
|
||||
},
|
||||
oneOf: [{ required: ["project_item_id"] }, { required: ["sequence_guid"] }],
|
||||
});
|
||||
expect(tool.description).toContain("does not expose paths");
|
||||
expect(tool.operationalCapability).toMatchObject({
|
||||
backend: "UXP", minimumPremiereVersion: "26.3", hostVerificationRequired: true,
|
||||
});
|
||||
|
||||
await tool.handler({ project_item_id: "item-1", expected_project_guid: "project-1", expected_unique_id: "unique-1" });
|
||||
await tool.handler({ sequence_guid: "sequence-1" });
|
||||
expect(request).toHaveBeenNthCalledWith(1, "object.uniqueIdentity.inspect", {
|
||||
projectItemId: "item-1", expectedProjectGuid: "project-1", expectedUniqueId: "unique-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "object.uniqueIdentity.inspect", { sequenceGuid: "sequence-1" });
|
||||
});
|
||||
});
|
||||
Executable
+368
@@ -0,0 +1,368 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpTools } from "../../src/tools/uxp.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
const WORKFLOW_TOOLS = [
|
||||
"manage_clip_effects_uxp",
|
||||
"inspect_track_item_identity_uxp",
|
||||
"batch_selected_clips_uxp",
|
||||
"manage_timeline_selection_uxp",
|
||||
"detect_scene_edits_uxp",
|
||||
"manage_proxy_ingest_uxp",
|
||||
"relink_offline_media_uxp",
|
||||
"manage_metadata_uxp",
|
||||
"inspect_project_panel_metadata_uxp",
|
||||
"manage_project_panel_metadata_uxp",
|
||||
"create_project_metadata_field_uxp",
|
||||
"manage_color_conformance_uxp",
|
||||
"audition_source_monitor_uxp",
|
||||
"preflight_production_storage_uxp",
|
||||
"inspect_premiere_environment_uxp",
|
||||
"get_uxp_workspace_access",
|
||||
] as const;
|
||||
|
||||
describe("stable UXP workflow MCP catalog", () => {
|
||||
it("publishes the researched workflow entrypoints with bounded schemas", () => {
|
||||
const bridge = { request: vi.fn(), getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge) as Record<string, { parameters: Record<string, unknown> }>;
|
||||
expect(Object.keys(tools)).toEqual(expect.arrayContaining(WORKFLOW_TOOLS));
|
||||
expect(tools.manage_clip_effects_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["catalog", "inspect", "add", "remove"] },
|
||||
effect_id: { maxLength: 256 },
|
||||
operation_id: { pattern: expect.any(String) },
|
||||
},
|
||||
});
|
||||
expect(tools.inspect_track_item_identity_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false,
|
||||
required: ["media_type", "track_index", "clip_index"],
|
||||
properties: { media_type: { enum: ["video", "audio"] }, expected_sequence_guid: { maxLength: 512 } },
|
||||
});
|
||||
expect(tools.manage_metadata_uxp.parameters).toMatchObject({
|
||||
properties: {
|
||||
project_metadata: { maxLength: 350000, description: expect.stringContaining("900,000-byte") },
|
||||
xmp_metadata: { maxLength: 350000, description: expect.stringContaining("900,000-byte") },
|
||||
updated_fields: { maxItems: 128 },
|
||||
},
|
||||
});
|
||||
expect(tools.inspect_project_panel_metadata_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["panel", "item_columns"] },
|
||||
project_item_id: { minLength: 1, maxLength: 512 },
|
||||
project_item_name: { minLength: 1, maxLength: 255 },
|
||||
},
|
||||
});
|
||||
expect(tools.manage_project_panel_metadata_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["inspect", "update"] },
|
||||
expected_project_guid: { minLength: 1, maxLength: 512 },
|
||||
expected_project_panel_metadata: { maxLength: 12288 },
|
||||
project_panel_metadata: { maxLength: 12288 },
|
||||
confirm_update: { type: "boolean" },
|
||||
operation_id: { pattern: expect.any(String) },
|
||||
},
|
||||
});
|
||||
expect(tools.create_project_metadata_field_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["inspect", "create"] },
|
||||
expected_project_guid: { minLength: 1, maxLength: 512 },
|
||||
expected_project_panel_metadata: { maxLength: 12288 },
|
||||
field_name: { minLength: 1, maxLength: 128, pattern: expect.any(String) },
|
||||
field_label: { minLength: 1, maxLength: 255 },
|
||||
schema_field_type: { enum: ["integer", "real", "text", "boolean"] },
|
||||
confirm_create: { type: "boolean" },
|
||||
operation_id: { pattern: expect.any(String) },
|
||||
},
|
||||
});
|
||||
expect(tools.relink_offline_media_uxp.parameters).toMatchObject({
|
||||
required: ["new_path", "confirm_non_undoable"],
|
||||
properties: { new_path: { maxLength: 4096 }, confirm_non_undoable: { type: "boolean" } },
|
||||
});
|
||||
expect(tools.manage_proxy_ingest_uxp.parameters).toMatchObject({
|
||||
properties: {
|
||||
project_item_id: { minLength: 1, maxLength: 512 },
|
||||
project_item_name: { minLength: 1, maxLength: 255 },
|
||||
},
|
||||
});
|
||||
expect(tools.manage_timeline_selection_uxp.parameters).toMatchObject({
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["inspect", "inspect_targets", "replace", "add", "remove", "clear"] },
|
||||
selection_targets: {
|
||||
minItems: 1, maxItems: 64,
|
||||
items: { required: ["media_type", "track_index", "clip_index"] },
|
||||
},
|
||||
expected_sequence_guid: { minLength: 1, maxLength: 512 },
|
||||
selection_items: {
|
||||
minItems: 1, maxItems: 64,
|
||||
items: {
|
||||
additionalProperties: false,
|
||||
required: [
|
||||
"media_type", "track_index", "clip_index", "expected_project_item_id",
|
||||
"expected_start_seconds", "expected_end_seconds",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps timeline selection inspection and guarded mutations", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
|
||||
await tools.manage_timeline_selection_uxp.handler({ action: "inspect" });
|
||||
await tools.manage_timeline_selection_uxp.handler({
|
||||
action: "inspect_targets",
|
||||
selection_targets: [{ media_type: "audio", track_index: 0, clip_index: 4 }],
|
||||
});
|
||||
await tools.manage_timeline_selection_uxp.handler({
|
||||
action: "replace", expected_sequence_guid: "sequence-1", operation_id: "selection-1",
|
||||
selection_items: [{
|
||||
media_type: "video", track_index: 1, clip_index: 2,
|
||||
expected_project_item_id: "clip-17", expected_start_seconds: 3.25, expected_end_seconds: 7.5,
|
||||
}],
|
||||
});
|
||||
await tools.manage_timeline_selection_uxp.handler({
|
||||
action: "clear", expected_sequence_guid: "sequence-1", operation_id: "selection-2",
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "selection.fingerprints.inspect", {});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "selection.targets.inspect", {
|
||||
items: [{ mediaType: "audio", trackIndex: 0, clipIndex: 4 }],
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(3, "selection.update", {
|
||||
mode: "replace", expectedSequenceGuid: "sequence-1", operationId: "selection-1",
|
||||
items: [{
|
||||
mediaType: "video", trackIndex: 1, clipIndex: 2,
|
||||
expectedProjectItemId: "clip-17", expectedStartSeconds: 3.25, expectedEndSeconds: 7.5,
|
||||
}],
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(4, "selection.update", {
|
||||
mode: "clear", expectedSequenceGuid: "sequence-1", operationId: "selection-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps bounded track-item identity inspection without granting an edit route", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
await getUxpTools(bridge).inspect_track_item_identity_uxp.handler({
|
||||
media_type: "audio", track_index: 2, clip_index: 4, expected_sequence_guid: "sequence-1",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("trackItem.identity.inspect", {
|
||||
mediaType: "audio", trackIndex: 2, clipIndex: 4, expectedSequenceGuid: "sequence-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects incomplete timeline selection mutations before bridge access", async () => {
|
||||
const request = vi.fn();
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
|
||||
await expect(tools.manage_timeline_selection_uxp.handler({ action: "clear" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("expected_sequence_guid") });
|
||||
await expect(tools.manage_timeline_selection_uxp.handler({ action: "inspect_targets" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("selection_targets") });
|
||||
await expect(tools.manage_timeline_selection_uxp.handler({
|
||||
action: "replace", expected_sequence_guid: "sequence-1",
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("selection_items") });
|
||||
await expect(tools.manage_timeline_selection_uxp.handler({
|
||||
action: "clear", expected_sequence_guid: "sequence-1", selection_items: [],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("omitted") });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects incomplete storage configuration and preserves explicit empty selectors", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
|
||||
await expect(tools.preflight_production_storage_uxp.handler({
|
||||
action: "configure_project", folder_types: ["capture"],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("destination") });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
|
||||
await tools.manage_metadata_uxp.handler({ action: "get", project_item_id: "" });
|
||||
expect(request).toHaveBeenCalledWith("metadata.get", { projectItemId: "" });
|
||||
});
|
||||
|
||||
it("maps consolidated public actions to exact capability-gated UXP commands", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
|
||||
await tools.manage_clip_effects_uxp.handler({
|
||||
action: "add", media_type: "video", track_index: 1, clip_index: 2,
|
||||
effect_id: "PR.Test", insertion_index: 3, operation_id: "effect-1",
|
||||
});
|
||||
await tools.batch_selected_clips_uxp.handler({
|
||||
action: "remove_effect", media_type: "audio", component_index: 4,
|
||||
expected_effect_id: "Dynamics", operation_id: "batch-1",
|
||||
});
|
||||
await tools.detect_scene_edits_uxp.handler({ mode: "create_markers", operation_id: "scene-1" });
|
||||
await tools.manage_proxy_ingest_uxp.handler({ action: "set_ingest", enabled: true, operation_id: "ingest-1" });
|
||||
await tools.relink_offline_media_uxp.handler({
|
||||
project_item_id: "clip-1", new_path: "D:/Approved/online.mov", expected_current_path: "D:/Approved/missing.mov",
|
||||
confirm_non_undoable: true, operation_id: "relink-1",
|
||||
});
|
||||
await tools.manage_metadata_uxp.handler({
|
||||
action: "update", project_item_id: "clip-1", project_metadata: "metadata", updated_fields: ["LogNote"], operation_id: "meta-1",
|
||||
});
|
||||
await tools.manage_project_panel_metadata_uxp.handler({
|
||||
action: "update", expected_project_guid: "project-1", expected_project_panel_metadata: "<before/>",
|
||||
project_panel_metadata: "<after/>", confirm_update: true, operation_id: "panel-1",
|
||||
});
|
||||
await tools.manage_color_conformance_uxp.handler({
|
||||
action: "update", project_item_id: "clip-1", frame_rate: 24, input_lut_id: "lut-guid", operation_id: "color-1",
|
||||
});
|
||||
await tools.audition_source_monitor_uxp.handler({ action: "open_file", file_path: "D:/Approved/take.mov", operation_id: "monitor-1" });
|
||||
await tools.preflight_production_storage_uxp.handler({
|
||||
action: "configure_project", folder_types: ["capture", "auto_save"], destination: "same_as_project", operation_id: "scratch-1",
|
||||
});
|
||||
await tools.inspect_premiere_environment_uxp.handler();
|
||||
await tools.get_uxp_workspace_access.handler();
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "effects.chain.add", {
|
||||
mediaType: "video", trackIndex: 1, clipIndex: 2, effectId: "PR.Test", insertionIndex: 3, operationId: "effect-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "effects.selection.remove", {
|
||||
mediaType: "audio", componentIndex: 4, expectedEffectId: "Dynamics", operationId: "batch-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(3, "sceneEdit.detect", { mode: "createMarkers", operationId: "scene-1" });
|
||||
expect(request).toHaveBeenNthCalledWith(4, "ingest.configure", { enabled: true, operationId: "ingest-1" });
|
||||
expect(request).toHaveBeenNthCalledWith(5, "media.relink", {
|
||||
projectItemId: "clip-1", newPath: "D:/Approved/online.mov", expectedCurrentPath: "D:/Approved/missing.mov",
|
||||
confirmNonUndoable: true, operationId: "relink-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(6, "metadata.update", {
|
||||
projectItemId: "clip-1", projectMetadata: "metadata", updatedFields: ["LogNote"], operationId: "meta-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(7, "metadata.projectPanel.update", {
|
||||
expectedProjectGuid: "project-1", expectedProjectPanelMetadata: "<before/>", projectPanelMetadata: "<after/>",
|
||||
confirmUpdate: true, operationId: "panel-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(8, "footage.conform", {
|
||||
projectItemId: "clip-1", frameRate: 24, inputLutId: "lut-guid", operationId: "color-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(9, "sourceMonitor.open", { filePath: "D:/Approved/take.mov", operationId: "monitor-1" });
|
||||
expect(request).toHaveBeenNthCalledWith(10, "scratch.configure", {
|
||||
folderTypes: ["capture", "autoSave"], destination: "sameAsProject", operationId: "scratch-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(11, "environment.inspect", {});
|
||||
expect(request).toHaveBeenNthCalledWith(12, "workspace.status", {});
|
||||
});
|
||||
|
||||
it("maps Project-panel metadata reads and guarded writes to separate authority routes", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpTools(bridge).inspect_project_panel_metadata_uxp;
|
||||
|
||||
await tool.handler({ action: "panel" });
|
||||
await tool.handler({ action: "item_columns", project_item_id: "clip-1" });
|
||||
await getUxpTools(bridge).manage_project_panel_metadata_uxp.handler({
|
||||
action: "inspect",
|
||||
});
|
||||
await getUxpTools(bridge).manage_project_panel_metadata_uxp.handler({
|
||||
action: "update", expected_project_guid: "project-1", expected_project_panel_metadata: "<before/>",
|
||||
project_panel_metadata: "<after/>", confirm_update: true, operation_id: "panel-1",
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "metadata.projectPanel.get", {});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "metadata.columns.get", { projectItemId: "clip-1" });
|
||||
expect(request).toHaveBeenNthCalledWith(3, "metadata.projectPanel.get", {});
|
||||
expect(request).toHaveBeenNthCalledWith(4, "metadata.projectPanel.update", {
|
||||
expectedProjectGuid: "project-1", expectedProjectPanelMetadata: "<before/>", projectPanelMetadata: "<after/>",
|
||||
confirmUpdate: true, operationId: "panel-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Project metadata schema inspection and creation to separate authority routes", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "committed_unverified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpTools(bridge).create_project_metadata_field_uxp;
|
||||
|
||||
await tool.handler({ action: "inspect" });
|
||||
await tool.handler({
|
||||
action: "create", expected_project_guid: "project-1", expected_project_panel_metadata: "<before/>",
|
||||
field_name: "McpReviewState", field_label: "MCP Review State", schema_field_type: "text",
|
||||
confirm_create: true, operation_id: "schema-1",
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "metadata.projectSchema.inspect", {});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "metadata.projectSchema.create", {
|
||||
expectedProjectGuid: "project-1", expectedProjectPanelMetadata: "<before/>",
|
||||
fieldName: "McpReviewState", fieldLabel: "MCP Review State", fieldType: "text",
|
||||
confirmCreate: true, operationId: "schema-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsupported dispatcher actions before bridge access", async () => {
|
||||
const request = vi.fn();
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
|
||||
const results = await Promise.all([
|
||||
tools.manage_clip_effects_uxp.handler({ action: "unsupported" }),
|
||||
tools.batch_selected_clips_uxp.handler({ action: "unsupported" }),
|
||||
tools.manage_timeline_selection_uxp.handler({ action: "unsupported" }),
|
||||
tools.manage_proxy_ingest_uxp.handler({ action: "unsupported" }),
|
||||
tools.manage_metadata_uxp.handler({ action: "unsupported" }),
|
||||
tools.inspect_project_panel_metadata_uxp.handler({ action: "unsupported" }),
|
||||
tools.manage_project_panel_metadata_uxp.handler({ action: "unsupported" }),
|
||||
tools.create_project_metadata_field_uxp.handler({ action: "unsupported" }),
|
||||
tools.manage_color_conformance_uxp.handler({ action: "unsupported" }),
|
||||
tools.audition_source_monitor_uxp.handler({ action: "unsupported" }),
|
||||
]);
|
||||
|
||||
expect(results).toEqual(Array.from({ length: 10 }, () => ({
|
||||
success: false,
|
||||
error: "Unsupported workflow action: unsupported",
|
||||
})));
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes bridge failures and forwards optional proxy controls", async () => {
|
||||
const request = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("host disconnected"))
|
||||
.mockResolvedValueOnce({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
|
||||
await expect(tools.manage_clip_effects_uxp.handler({ action: "catalog" })).resolves.toEqual({
|
||||
success: false,
|
||||
error: "host disconnected",
|
||||
});
|
||||
await tools.manage_proxy_ingest_uxp.handler({
|
||||
action: "attach_proxy",
|
||||
project_item_name: "Interview A",
|
||||
media_path: "D:/Approved/Interview A Proxy.mov",
|
||||
is_high_resolution: false,
|
||||
make_alternate_link_in_team_projects: true,
|
||||
replace_existing_proxy: true,
|
||||
confirm_non_undoable: true,
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(2, "proxy.attach", {
|
||||
projectItemName: "Interview A",
|
||||
mediaPath: "D:/Approved/Interview A Proxy.mov",
|
||||
isHiRes: false,
|
||||
makeAlternateLinkInTeamProjects: true,
|
||||
replaceExistingProxy: true,
|
||||
confirmNonUndoable: true,
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getWorkflowRecipeTools, validateWorkflowRecipe } from "../../src/tools/workflow-recipes.js";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
describe("workflow recipes", () => {
|
||||
it("expands only audited declarative routes", async () => {
|
||||
const tools = getWorkflowRecipeTools();
|
||||
const result = await tools.preview_workflow_recipe.handler({ recipe_id: "talking-head-cleanup", provided_inputs: ["source_project_item_id"] });
|
||||
expect(result.success).toBe(true);
|
||||
expect((result.data as any).applied).toBe(false);
|
||||
expect((result.data as any).execution_manifest[0].routes).toEqual(["verify_premiere_connection"]);
|
||||
});
|
||||
it("rejects arbitrary steps", () => expect(() => validateWorkflowRecipe({ schema_version: 1, id: "x", title: "X", description: "X", tags: [], required_inputs: [], steps: ["execute_extendscript"] })).toThrow(/unsupported/));
|
||||
it("loads a contained custom recipe and rejects conflicts", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "recipes-")), file = path.join(root, "recipes.json");
|
||||
const recipe = { schema_version: 1, id: "custom", title: "Custom", description: "Custom", tags: ["one"], required_inputs: [], steps: ["verify_connection"] };
|
||||
writeFileSync(file, JSON.stringify([recipe]));
|
||||
const tools = getWorkflowRecipeTools();
|
||||
expect((await tools.search_workflow_recipes.handler({ query: "custom", approved_workspace_path: root, recipe_file: file })).success).toBe(true);
|
||||
writeFileSync(file, JSON.stringify([{ ...recipe, id: "talking-head-cleanup" }]));
|
||||
expect((await tools.search_workflow_recipes.handler({ approved_workspace_path: root, recipe_file: file })).success).toBe(false);
|
||||
});
|
||||
it.each([null, [], { schema_version: 2 }, { schema_version: 1, id: "x", title: "X", description: "X", steps: [] }, { schema_version: 1, id: "x", title: "X", description: "X", tags: ["a", "a"], required_inputs: [], steps: ["verify_connection"] }])("rejects invalid recipe %#", (recipe) => expect(() => validateWorkflowRecipe(recipe)).toThrow());
|
||||
it("fails closed for malformed and escaped custom files", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "recipes-")), outside = mkdtempSync(path.join(tmpdir(), "recipes-out-"));
|
||||
const tools = getWorkflowRecipeTools();
|
||||
writeFileSync(path.join(outside, "r.json"), "[]");
|
||||
expect((await tools.search_workflow_recipes.handler({ approved_workspace_path: root, recipe_file: path.join(outside, "r.json") })).success).toBe(false);
|
||||
const file = path.join(root, "r.json"); writeFileSync(file, "not json");
|
||||
expect((await tools.search_workflow_recipes.handler({ approved_workspace_path: root, recipe_file: file })).success).toBe(false);
|
||||
writeFileSync(file, "{}"); expect((await tools.search_workflow_recipes.handler({ approved_workspace_path: root, recipe_file: file })).success).toBe(false);
|
||||
expect((await tools.search_workflow_recipes.handler({ approved_workspace_path: root })).success).toBe(false);
|
||||
expect((await tools.preview_workflow_recipe.handler({ recipe_id: "missing" })).success).toBe(false);
|
||||
expect((await tools.preview_workflow_recipe.handler({ recipe_id: "talking-head-cleanup", provided_inputs: [1] })).success).toBe(false);
|
||||
});
|
||||
it("reports ready built-ins and custom recipe sources", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "recipes-")), file = path.join(root, "r.json");
|
||||
writeFileSync(file, JSON.stringify([{ schema_version: 1, id: "custom-ready", title: "Custom", description: "Custom", tags: [], required_inputs: [], steps: ["verify_connection"] }]));
|
||||
const tools = getWorkflowRecipeTools();
|
||||
const custom = await tools.search_workflow_recipes.handler({ approved_workspace_path: root, recipe_file: file }) as any;
|
||||
expect(custom.data.recipes.find((item: any) => item.id === "custom-ready").source).toBe("workspace_file");
|
||||
const provided_inputs = ["source_project_item_id", "transcript_revision", "approved_segments", "sequence_name"];
|
||||
expect(((await tools.preview_workflow_recipe.handler({ recipe_id: "talking-head-cleanup", provided_inputs })) as any).data.ready).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user