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:
João Henrique
2026-09-08 09:59:31 -04:00
commit b541f502ba
1507 changed files with 387650 additions and 0 deletions
@@ -0,0 +1,108 @@
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
const ADOBE_26_3_COMMANDS = [
"track.rename",
"subclip.create",
"marker.list",
"sourceMonitor.position.set",
"interchange.aaf.export",
] as const;
function registryWithAdobe26_3Apis() {
const track = { createSetNameAction: vi.fn(() => ({ kind: "rename" })) };
const sequence = {
guid: "sequence-26-3",
getAudioTrackCount: vi.fn(async () => 1),
getAudioTrack: vi.fn(async () => track),
getVideoTrackCount: vi.fn(async () => 1),
getVideoTrack: vi.fn(async () => track),
getCaptionTrackCount: vi.fn(async () => 1),
getCaptionTrack: vi.fn(async () => track),
};
const project = {
guid: "project-26-3",
getActiveSequence: vi.fn(async () => sequence),
};
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) },
ClipProjectItem: { cast: vi.fn((item: unknown) => item) },
Markers: { getMarkers: vi.fn(async () => []) },
SourceMonitor: {
setPosition: vi.fn(async () => true),
getPosition: vi.fn(async () => ({ seconds: 0 })),
},
TickTime: { createWithSeconds: vi.fn((seconds: number) => ({ seconds })) },
Transcript: {
hasTranscript: vi.fn(() => false),
exportToJSON: vi.fn(async () => ""),
},
ProjectConverter: { exportAAF: vi.fn(async () => true) },
AAFExportOptions: vi.fn(() => ({})),
};
const workspace = {
status: vi.fn(() => ({ canonicalPathValidation: "available" })),
assertPathAllowed: vi.fn(async (path: string) => path),
};
return Commands.createCommandRegistry({ ppro, Protocol, workspace });
}
describe("Adobe Premiere 26.3 UXP capability catalog", () => {
it("reports every planned documented command as a supported 26.3 capability", async () => {
const registry = registryWithAdobe26_3Apis();
const capabilities = await registry.capabilities();
expect(Object.keys(capabilities.commands)).toEqual(
expect.arrayContaining(ADOBE_26_3_COMMANDS),
);
for (const command of ADOBE_26_3_COMMANDS) {
expect(capabilities.commands[command]).toMatchObject({
supported: true,
backend: "uxp",
documented: true,
minHostVersion: "26.3.0",
});
}
expect(capabilities.commands["track.rename"]).toMatchObject({
destructive: true,
undoable: true,
readOnly: false,
});
expect(capabilities.commands["subclip.create"]).toMatchObject({
destructive: true,
undoable: true,
readOnly: false,
});
expect(capabilities.commands["marker.list"]).toMatchObject({ readOnly: true });
});
it("does not infer 26.3 support from the version when the native APIs are absent", async () => {
const registry = Commands.createCommandRegistry({
ppro: {
Project: {
getActiveProject: vi.fn(async () => ({
getActiveSequence: vi.fn(async () => ({})),
})),
},
TickTime: { createWithSeconds: vi.fn((seconds: number) => ({ seconds })) },
},
fs: {},
Protocol,
});
const capabilities = await registry.capabilities();
for (const command of ADOBE_26_3_COMMANDS) {
expect(capabilities.commands[command]).toMatchObject({
supported: false,
backend: "uxp",
documented: true,
minHostVersion: "26.3.0",
reason: expect.any(String),
});
}
});
});
@@ -0,0 +1,150 @@
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
function adobe263Host() {
const audioTrack = { name: "A1", createSetNameAction: vi.fn((name: string) => ({ apply: () => { audioTrack.name = name; } })) };
const videoTrack = { name: "V1", createSetNameAction: vi.fn((name: string) => ({ apply: () => { videoTrack.name = name; } })) };
const captionTrack = { name: "Captions", createSetNameAction: vi.fn((name: string) => ({ apply: () => { captionTrack.name = name; } })) };
const marker = {
guid: { toString: () => "marker-guid-1" },
getName: vi.fn(() => "Review"), getComments: vi.fn(() => "Check this cut"), getType: vi.fn(() => "Comment"),
getColorIndex: vi.fn(() => 6), getStart: vi.fn(() => ({ seconds: 12.5 })), getDuration: vi.fn(() => ({ seconds: 1.25 })),
getColor: vi.fn(() => ({ red: 0.2, green: 0.4, blue: 0.8, alpha: 1 })),
getUrl: vi.fn(() => "https://review.example.test/cut?token=opaque"), getTarget: vi.fn(() => "frame-41"),
};
const source = {
id: "source-1", name: "Interview", isClip: true,
getId: vi.fn(() => "source-1"),
createSubClipAction: vi.fn((name: string) => ({ apply: () => {
rootItems.push({ id: "subclip-1", name, isClip: true, getId: () => "subclip-1" });
} })),
};
const rootItems: Array<Record<string, unknown>> = [source];
const root = { isFolder: true, getItems: vi.fn(async () => rootItems) };
const sequence = {
guid: "sequence-1",
getAudioTrackCount: vi.fn(async () => 1), getAudioTrack: vi.fn(async () => audioTrack),
getVideoTrackCount: vi.fn(async () => 1), getVideoTrack: vi.fn(async () => videoTrack),
getCaptionTrackCount: vi.fn(async () => 1), getCaptionTrack: vi.fn(async () => captionTrack),
};
const addAction = vi.fn((action: { apply?: () => void }) => { action.apply?.(); return true; });
const project = {
getActiveSequence: vi.fn(async () => sequence), getRootItem: vi.fn(async () => root),
lockedAccess: vi.fn((callback: () => void) => callback()),
executeTransaction: vi.fn((callback: (compound: { addAction: typeof addAction }) => void) => { callback({ addAction }); return true; }),
};
let sourcePosition = 0;
const aafOptionValues: Record<string, unknown> = {};
const aafOptions = {
setMixdownVideo: vi.fn((value: boolean) => { aafOptionValues.mixdownVideo = value; }),
setSampleRate: vi.fn((value: number) => { aafOptionValues.sampleRate = value; }),
setAudioFileFormat: vi.fn((value: number) => { aafOptionValues.audioFileFormat = value; }),
setVideoMixdownPresetPath: vi.fn((value: string) => { aafOptionValues.videoMixdownPresetPath = value; }),
};
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) },
TickTime: { createWithSeconds: vi.fn((seconds: number) => ({ seconds })) },
ClipProjectItem: { cast: vi.fn((item: { isClip?: boolean }) => { if (!item.isClip) throw new Error("not a clip"); return item; }) },
ProjectItem: { cast: vi.fn((item: unknown) => item) },
FolderItem: { cast: vi.fn((item: { isFolder?: boolean }) => { if (!item.isFolder) throw new Error("not a folder"); return item; }) },
Markers: { getMarkers: vi.fn(async () => ({ getMarkers: vi.fn(async () => [marker]) })) },
SourceMonitor: {
setPosition: vi.fn(async (position: { seconds: number }) => { sourcePosition = position.seconds; return true; }),
getPosition: vi.fn(async () => ({ seconds: sourcePosition })),
},
AAFExportOptions: vi.fn(() => aafOptions),
Constants: { AAFExportAudioFormat: { AIFF: 0, WAV: 1 } },
ProjectConverter: { exportAAF: vi.fn(async () => true) },
};
const workspace = {
status: vi.fn(() => ({ configured: true })),
assertPathAllowed: vi.fn((path: string) => path),
};
return {
registry: Commands.createCommandRegistry({ ppro, Protocol, workspace }), ppro, project, source, audioTrack, marker, aafOptionValues, workspace,
};
}
describe("Adobe Premiere Pro 26.3 UXP commands", () => {
it("uses locked undoable action transactions for track renames and subclip creation, then reads both back", async () => {
const value = adobe263Host();
await expect(value.registry.dispatch("track.rename", {
trackType: "audio", trackIndex: 0, name: "Dialogue", operationId: "track-1",
})).resolves.toMatchObject({ renamed: true, name: "Dialogue", outcome: "verified", operationId: "track-1" });
await expect(value.registry.dispatch("subclip.create", {
projectItemId: "source-1", name: "Interview Select", startSeconds: 3, endSeconds: 9,
})).resolves.toMatchObject({
created: true, outcome: "verified", subclip: { projectItemId: "subclip-1", name: "Interview Select" },
});
expect(value.project.lockedAccess).toHaveBeenCalledTimes(2);
expect(value.audioTrack.createSetNameAction).toHaveBeenCalledWith("Dialogue");
expect(value.source.createSubClipAction).toHaveBeenCalledWith(
"Interview Select", { seconds: 3 }, { seconds: 9 }, false, { takeVideo: true, takeAudio: true },
);
});
it("returns stable marker GUIDs, redacts optional values by default, and opts in to documented link and raw color readback", async () => {
const value = adobe263Host();
await expect(value.registry.dispatch("marker.list", {})).resolves.toMatchObject({
scope: "sequence", count: 1, markers: [{ guid: "marker-guid-1", startSeconds: 12.5, durationSeconds: 1.25 }],
});
expect(value.marker.getUrl).not.toHaveBeenCalled();
expect(value.marker.getTarget).not.toHaveBeenCalled();
expect(value.marker.getColor).not.toHaveBeenCalled();
await expect(value.registry.dispatch("marker.list", { includeWebLinks: true })).resolves.toMatchObject({
scope: "sequence", markers: [{ url: "https://review.example.test/cut?token=opaque", target: "frame-41" }],
});
expect(value.marker.getUrl).toHaveBeenCalledTimes(1);
expect(value.marker.getTarget).toHaveBeenCalledTimes(1);
await expect(value.registry.dispatch("marker.list", { includeColorValues: true })).resolves.toMatchObject({
scope: "sequence", markers: [{ color: { red: 0.2, green: 0.4, blue: 0.8, alpha: 1 } }],
});
expect(value.marker.getColor).toHaveBeenCalledTimes(1);
const partialHost = adobe263Host();
delete (partialHost.marker as { getUrl?: unknown }).getUrl;
delete (partialHost.marker as { getTarget?: unknown }).getTarget;
await expect(partialHost.registry.dispatch("marker.list", { includeWebLinks: true })).resolves.toMatchObject({
markers: [{ url: null, target: null }],
});
delete (partialHost.marker as { getColor?: unknown }).getColor;
await expect(partialHost.registry.dispatch("marker.list", { includeColorValues: true })).resolves.toMatchObject({
markers: [{ color: null }],
});
const malformedHost = adobe263Host();
malformedHost.marker.getColor.mockReturnValueOnce({ red: Number.NaN, green: 0.4, blue: 0.8, alpha: 1 });
await expect(malformedHost.registry.dispatch("marker.list", { includeColorValues: true }))
.rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
});
it("verifies Source Monitor positioning and applies bounded AAF options", async () => {
const value = adobe263Host();
await expect(value.registry.dispatch("sourceMonitor.position.set", { seconds: 4.5 })).resolves.toMatchObject({
positioned: true, seconds: 4.5, outcome: "verified",
});
await expect(value.registry.dispatch("interchange.aaf.export", {
outputFilePath: "/exports/edit.aaf",
options: { mixdownVideo: true, sampleRate: 48000, audioFileFormat: "wav", videoMixdownPresetPath: "/presets/prores.epr" },
})).resolves.toMatchObject({ exported: true, format: "aaf", outcome: "committed_unverified", outputVerified: false });
expect(value.aafOptionValues).toEqual({ mixdownVideo: true, sampleRate: 48000, audioFileFormat: 1, videoMixdownPresetPath: "/presets/prores.epr" });
expect(value.workspace.assertPathAllowed).toHaveBeenNthCalledWith(1, "/exports/edit.aaf", { label: "outputFilePath", kind: "file" });
expect(value.workspace.assertPathAllowed).toHaveBeenNthCalledWith(2, "/presets/prores.epr", { label: "videoMixdownPresetPath", kind: "file" });
});
it("rejects unsafe subclip ranges and unbounded AAF options before invoking the host", async () => {
const value = adobe263Host();
await expect(value.registry.dispatch("subclip.create", {
projectItemId: "source-1", name: "Invalid", startSeconds: 9, endSeconds: 3,
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("interchange.aaf.export", {
outputFilePath: "/exports/edit.aaf", options: { sampleRate: 12345 },
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("marker.list", { includeWebLinks: "true" })).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("marker.list", { includeColorValues: "true" })).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
});
});
File diff suppressed because it is too large Load Diff
+162
View File
@@ -0,0 +1,162 @@
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
type CloneHostOptions = { pauseFirstSnapshot?: boolean };
function cloneHost(options: CloneHostOptions = {}) {
const states: Array<{ projectItemId: string; start: number; end: number; inPoint: number; outPoint: number; speed: number; reversed: boolean }> = [
{ projectItemId: "source-1", start: 0, end: 10, inPoint: 20, outPoint: 30, speed: 1, reversed: false },
];
let releaseFirstSnapshot: (() => void) | null = null;
let firstSnapshotStarted: (() => void) | null = null;
const firstSnapshot = new Promise<void>((resolve) => { firstSnapshotStarted = resolve; });
const firstSnapshotRelease = new Promise<void>((resolve) => { releaseFirstSnapshot = resolve; });
let waitForFirstSnapshot = options.pauseFirstSnapshot === true;
function itemFor(state: typeof states[number]) {
return {
getProjectItem: vi.fn(async () => ({ getId: vi.fn(async () => state.projectItemId) })),
getStartTime: vi.fn(async () => ({ seconds: state.start })),
getEndTime: vi.fn(async () => ({ seconds: state.end })),
getInPoint: vi.fn(async () => {
if (waitForFirstSnapshot) {
waitForFirstSnapshot = false;
firstSnapshotStarted?.();
await firstSnapshotRelease;
}
return { seconds: state.inPoint };
}),
getOutPoint: vi.fn(async () => ({ seconds: state.outPoint })),
getDuration: vi.fn(async () => ({ seconds: state.end - state.start })),
getSpeed: vi.fn(async () => state.speed),
isSpeedReversed: vi.fn(async () => state.reversed),
createSetInPointAction: vi.fn((time: { seconds: number }) => ({ apply: () => { state.inPoint = time.seconds; } })),
createSetOutPointAction: vi.fn((time: { seconds: number }) => ({ apply: () => { state.outPoint = time.seconds; } })),
};
}
const items = states.map(itemFor);
const videoTrack = { getTrackItems: vi.fn(async () => items) };
const sequence = {
guid: "sequence-1",
getVideoTrackCount: vi.fn(async () => 1), getVideoTrack: vi.fn(async () => videoTrack),
getAudioTrackCount: vi.fn(async () => 0), getAudioTrack: vi.fn(async () => null),
};
const cloneAction = vi.fn((item: ReturnType<typeof itemFor>, offset: { seconds: number }, videoOffset: number, audioOffset: number, alignToVideo: boolean, isInsert: boolean) => ({
apply: () => {
const sourceIndex = items.indexOf(item);
const source = states[sourceIndex]!;
const copy = {
projectItemId: source.projectItemId, start: source.start + offset.seconds, end: source.end + offset.seconds,
inPoint: source.inPoint, outPoint: source.outPoint, speed: source.speed, reversed: source.reversed,
};
states.splice(sourceIndex + 1, 0, copy);
items.splice(sourceIndex + 1, 0, itemFor(copy));
},
}));
const addAction = vi.fn((action: { apply: () => void }) => { action.apply(); return true; });
const project = {
guid: "project-1", getActiveSequence: vi.fn(async () => sequence), lockedAccess: vi.fn((callback: () => void) => callback()),
executeTransaction: vi.fn((callback: (compound: { addAction: typeof addAction }) => void) => { callback({ addAction }); return true; }),
};
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) }, TickTime: { createWithSeconds: vi.fn((seconds: number) => ({ seconds })) },
SequenceEditor: { getEditor: vi.fn(() => ({ createCloneTrackItemAction: cloneAction })) }, Constants: { TrackItemType: { CLIP: 1 } },
};
return { states, items, cloneAction, project, firstSnapshot, releaseFirstSnapshot: () => releaseFirstSnapshot?.(), registry: Commands.createCommandRegistry({ ppro, Protocol }) };
}
const source = { projectItemId: "source-1", startSeconds: 0, endSeconds: 10, inSeconds: 20, outSeconds: 30, durationSeconds: 10, speed: 1, reversed: false };
const expectedSnapshot = { projectGuid: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0, clipIndex: 0, trackItemCount: 1, source };
const targetCoordinates = { mediaType: "video", trackIndex: 0, clipIndex: 0 };
describe("guarded documented UXP append-only track-item duplicate workflow", () => {
it("advertises bounded inspection and an idempotent undoable transaction command", async () => {
const value = cloneHost();
await expect(value.registry.capabilities()).resolves.toMatchObject({ commands: {
"trackItem.clone.inspect": { supported: true, readOnly: true, minHostVersion: "25.6.0" },
"trackItem.clone": { supported: true, destructive: true, undoable: true, idempotent: true, targetCapabilityProbe: "invocation" },
} });
await expect(value.registry.dispatch("trackItem.clone.inspect", targetCoordinates)).resolves.toEqual(expectedSnapshot);
expect(value.project.executeTransaction).not.toHaveBeenCalled();
});
it("appends one reviewed final item in one transaction and reads only the source plus deterministic new coordinate", async () => {
const value = cloneHost();
await expect(value.registry.dispatch("trackItem.clone", {
...targetCoordinates, expectedSnapshot, confirmDuplicate: true, operationId: "clone-apply-1",
})).resolves.toMatchObject({
operationId: "clone-apply-1", duplicated: true, outcome: "verified", before: expectedSnapshot,
after: {
projectGuid: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0,
sourceClipIndex: 0, duplicateClipIndex: 1, trackItemCount: 2, source,
duplicate: { ...source, startSeconds: 10, endSeconds: 20 },
},
verificationBoundary: "source_and_appended_track_item_readback",
});
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.project.executeTransaction.mock.calls[0]?.[1]).toBe("Duplicate timeline item after source");
expect(value.cloneAction).toHaveBeenCalledWith(value.items[0], { seconds: 10 }, 0, 0, false, true);
expect(value.states).toEqual([
{ projectItemId: "source-1", start: 0, end: 10, inPoint: 20, outPoint: 30, speed: 1, reversed: false },
{ projectItemId: "source-1", start: 10, end: 20, inPoint: 20, outPoint: 30, speed: 1, reversed: false },
]);
});
it("does not invoke unrelated earlier item getters during append preflight or readback", async () => {
const value = cloneHost();
const unrelatedGetter = vi.fn(async () => { throw new Error("unrelated item must not be read"); });
value.states.unshift({ projectItemId: "unrelated-1", start: 0, end: 5, inPoint: 0, outPoint: 5, speed: 1, reversed: false });
value.items.unshift({
getProjectItem: unrelatedGetter, getStartTime: unrelatedGetter, getEndTime: unrelatedGetter,
getInPoint: unrelatedGetter, getOutPoint: unrelatedGetter, getDuration: unrelatedGetter,
getSpeed: unrelatedGetter, isSpeedReversed: unrelatedGetter,
createSetInPointAction: unrelatedGetter, createSetOutPointAction: unrelatedGetter,
});
const target = { mediaType: "video", trackIndex: 0, clipIndex: 1 };
const expected = { ...expectedSnapshot, clipIndex: 1, trackItemCount: 2 };
await expect(value.registry.dispatch("trackItem.clone", {
...target, expectedSnapshot: expected, confirmDuplicate: true, operationId: "bounded-readback",
})).resolves.toMatchObject({ outcome: "verified", after: { trackItemCount: 3, duplicateClipIndex: 2 } });
expect(unrelatedGetter).not.toHaveBeenCalled();
});
it("rejects missing confirmation/replay, stale state, and non-final sources before action creation", async () => {
const value = cloneHost();
await expect(value.registry.dispatch("trackItem.clone", { ...targetCoordinates, expectedSnapshot, operationId: "missing-confirmation" })).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
await expect(value.registry.dispatch("trackItem.clone", { ...targetCoordinates, expectedSnapshot, confirmDuplicate: true })).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("trackItem.clone", {
...targetCoordinates, expectedSnapshot: { ...expectedSnapshot, source: { ...source, outSeconds: 31 } }, confirmDuplicate: true, operationId: "stale",
})).rejects.toMatchObject({ code: "UXP_STALE_TRACK_ITEM" });
const nonFinal = cloneHost();
nonFinal.states.push({ projectItemId: "later-1", start: 10, end: 20, inPoint: 0, outPoint: 10, speed: 1, reversed: false });
nonFinal.items.push({ ...nonFinal.items[0], getProjectItem: vi.fn(async () => ({ getId: vi.fn(async () => "later-1") })) });
await expect(nonFinal.registry.dispatch("trackItem.clone.inspect", targetCoordinates)).rejects.toMatchObject({ code: "UXP_TARGET_UNSUPPORTED" });
expect(value.project.executeTransaction).not.toHaveBeenCalled();
});
it("replays a completed operation ID without creating a second host transaction", async () => {
const value = cloneHost();
const args = { ...targetCoordinates, expectedSnapshot, confirmDuplicate: true, operationId: "replay-clone" };
await expect(value.registry.dispatch("trackItem.clone", args)).resolves.toMatchObject({ outcome: "verified" });
await expect(value.registry.dispatch("trackItem.clone", args)).resolves.toMatchObject({ outcome: "verified", replayed: true });
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.states).toHaveLength(2);
});
it("serializes different operation IDs so a stale reviewed final-item snapshot cannot create a second duplicate", async () => {
const value = cloneHost({ pauseFirstSnapshot: true });
const first = value.registry.dispatch("trackItem.clone", { ...targetCoordinates, expectedSnapshot, confirmDuplicate: true, operationId: "concurrent-first" });
await value.firstSnapshot;
const second = value.registry.dispatch("trackItem.clone", { ...targetCoordinates, expectedSnapshot, confirmDuplicate: true, operationId: "concurrent-second" });
const secondExpectation = expect(second).rejects.toMatchObject({ code: "UXP_STALE_TRACK_ITEM" });
value.releaseFirstSnapshot();
await expect(first).resolves.toMatchObject({ outcome: "verified" });
await secondExpectation;
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.states).toHaveLength(2);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const api = require("../../uxp-plugin/dialogue-workflows.cjs");
describe("UXP dialogue command module", () => {
it("registers a guarded non-undoable ordinary-sequence command", () => {
const definitions = api.createDialogueWorkflowDefinitions({ ppro: {} });
expect(definitions["dialogue.deriveSequence"]).toMatchObject({ destructive: true, undoable: false, idempotent: true, minHostVersion: "26.3.0" });
expect(definitions["dialogue.deriveSequence"].probe()).toBe(false);
});
it("creates linked subclips and an ordinary sequence with structural-only proof", async () => {
const children: any[] = [];
const parent = { name: "Media", getId: async () => "bin", getItems: async () => children };
const source = { name: "Source", getId: async () => "clip", getParentBin: async () => parent, isMulticamClip: async () => false,
createSubClipAction: (name: string) => ({ apply: () => children.push({ name, getId: async () => `sub-${children.length + 1}` }) }) };
const root = { getId: async () => "root", getItems: async () => [source] };
const createdSequences: any[] = [];
const project = { getGuid: async () => "project", getRootItem: async () => root, getSequences: async () => createdSequences,
lockedAccess: (fn: () => void) => fn(), executeTransaction: (fn: (compound: any) => void) => { fn({ addAction: (action: any) => { action.apply(); return true; } }); return true; },
createSequenceFromMedia: async (name: string) => { const sequence = { name, getGuid: async () => "sequence" }; createdSequences.push(sequence); return sequence; } };
const ppro = { Project: { getActiveProject: async () => project }, ClipProjectItem: { cast: (item: any) => item.createSubClipAction ? item : null }, FolderItem: { cast: (item: any) => item.getItems ? item : null },
TickTime: { createWithSeconds: (seconds: number) => ({ seconds }) }, SequenceEditor: { getEditor: () => ({ createInsertProjectItemAction: () => ({ apply() {} }) }) } };
const command = api.createDialogueWorkflowDefinitions({ ppro })["dialogue.deriveSequence"];
const result = await command.handler({ operationId: "op-1", plan: { schema_version: 1, project_guid: "project", mode: "talking_head", sequence_name: "Reviewed", segments: [
{ id: "one", source_project_item_id: "clip", transcript_revision: `sha256:${"a".repeat(64)}`, source_start_seconds: 0, source_end_seconds: 1 },
{ id: "two", source_project_item_id: "clip", transcript_revision: `sha256:${"a".repeat(64)}`, source_start_seconds: 2, source_end_seconds: 3 },
], output_duration_seconds: 2, original_sources_unchanged: true, render_verified: false } });
expect(result).toMatchObject({ outcome: "committed_unverified", partial: false, originalSourcesChanged: false, renderVerified: false });
expect(result.createdSubclips).toHaveLength(2);
expect(result.insertedProjectItemIds).toHaveLength(2);
});
});
@@ -0,0 +1,134 @@
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
type Options = {
changeParameterOnSecondSnapshot?: boolean;
parameterCount?: number;
};
function parameterCatalogHost(options: Options = {}) {
let parameterCalls = 0;
const parameters = Array.from({ length: options.parameterCount ?? 2 }, (_, index) => ({
displayName: index === 0 ? "Opacity" : "Blend Mode",
areKeyframesSupported: vi.fn(async () => index === 0),
isTimeVarying: vi.fn(() => index === 0),
getStartValue: vi.fn(),
getValueAtTime: vi.fn(),
}));
const component = {
getMatchName: vi.fn(async () => "ADBE Opacity"),
getDisplayName: vi.fn(async () => "Opacity"),
getParamCount: vi.fn(() => parameters.length),
getParam: vi.fn((index: number) => {
parameterCalls += 1;
if (options.changeParameterOnSecondSnapshot && parameterCalls > parameters.length && index === 0) {
return { ...parameters[index], displayName: "Changed Opacity" };
}
return parameters[index];
}),
};
const chain = {
getComponentCount: vi.fn(() => 1),
getComponentAtIndex: vi.fn(() => component),
};
const item = { getComponentChain: vi.fn(async () => chain) };
const videoTrack = { getTrackItems: vi.fn(async () => [item]) };
const sequence = {
guid: "sequence-1",
getVideoTrackCount: vi.fn(async () => 1),
getVideoTrack: vi.fn(async () => videoTrack),
getAudioTrackCount: vi.fn(async () => 0),
getAudioTrack: vi.fn(),
};
const project = { guid: "project-1", getActiveSequence: vi.fn(async () => sequence) };
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) },
Constants: { TrackItemType: { CLIP: 1 } },
};
return {
ppro, project, sequence, videoTrack, item, component, parameters,
registry: Commands.createCommandRegistry({ ppro, Protocol }),
};
}
describe("bounded documented UXP effect-parameter catalog", () => {
it("advertises a read-only target-probed command and returns a double-read bounded descriptor catalog", async () => {
const value = parameterCatalogHost();
await expect(value.registry.capabilities()).resolves.toMatchObject({ commands: {
"parameters.catalog.inspect": {
supported: true, readOnly: true, minHostVersion: "25.6.0", targetCapabilityProbe: "invocation",
},
} });
value.project.getActiveSequence.mockClear();
value.videoTrack.getTrackItems.mockClear();
await expect(value.registry.dispatch("parameters.catalog.inspect", {
mediaType: "video", trackIndex: 0, clipIndex: 0, componentIndex: 0,
expectedSequenceGuid: "sequence-1", expectedComponentId: "ADBE Opacity",
})).resolves.toEqual({
projectGuid: "project-1",
sequenceGuid: "sequence-1",
mediaType: "video",
trackIndex: 0,
clipIndex: 0,
componentIndex: 0,
component: { id: "ADBE Opacity", matchName: "ADBE Opacity", displayName: "Opacity" },
parameterCount: 2,
parameters: [
{ index: 0, displayName: "Opacity", keyframesSupported: true, timeVarying: true },
{ index: 1, displayName: "Blend Mode", keyframesSupported: false, timeVarying: false },
],
verificationBoundary: "bounded_effect_parameter_catalog_double_readback",
});
expect(value.project.getActiveSequence).toHaveBeenCalledTimes(2);
expect(value.videoTrack.getTrackItems).toHaveBeenCalledTimes(2);
expect(value.parameters[0].getStartValue).not.toHaveBeenCalled();
expect(value.parameters[0].getValueAtTime).not.toHaveBeenCalled();
});
it("rejects invalid, stale, and oversized targets before returning an ambiguous catalog", async () => {
const value = parameterCatalogHost();
await expect(value.registry.dispatch("parameters.catalog.inspect", {
mediaType: "video", trackIndex: 0, clipIndex: 0, componentIndex: 0, unexpected: true,
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("parameters.catalog.inspect", {
mediaType: "caption", trackIndex: 0, clipIndex: 0, componentIndex: 0,
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("parameters.catalog.inspect", {
mediaType: "video", trackIndex: 0, clipIndex: 0, componentIndex: 0, expectedSequenceGuid: "",
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("parameters.catalog.inspect", {
mediaType: "video", trackIndex: 1, clipIndex: 0, componentIndex: 0,
})).rejects.toMatchObject({ code: "UXP_TARGET_NOT_FOUND" });
await expect(value.registry.dispatch("parameters.catalog.inspect", {
mediaType: "video", trackIndex: 0, clipIndex: 0, componentIndex: 0, expectedSequenceGuid: "other-sequence",
})).rejects.toMatchObject({ code: "UXP_STALE_PARAMETER_CATALOG" });
await expect(value.registry.dispatch("parameters.catalog.inspect", {
mediaType: "video", trackIndex: 0, clipIndex: 0, componentIndex: 0, expectedComponentId: "other-component",
})).rejects.toMatchObject({ code: "UXP_STALE_PARAMETER_CATALOG" });
const tooMany = parameterCatalogHost({ parameterCount: 65 });
await expect(tooMany.registry.dispatch("parameters.catalog.inspect", {
mediaType: "video", trackIndex: 0, clipIndex: 0, componentIndex: 0,
})).rejects.toMatchObject({ code: "UXP_TARGET_TOO_LARGE" });
});
it("fails closed when a parameter descriptor changes during the complete double read", async () => {
const value = parameterCatalogHost({ changeParameterOnSecondSnapshot: true });
await expect(value.registry.dispatch("parameters.catalog.inspect", {
mediaType: "video", trackIndex: 0, clipIndex: 0, componentIndex: 0,
})).rejects.toMatchObject({ code: "UXP_STALE_PARAMETER_CATALOG" });
});
it("does not claim support when active-project access is absent", async () => {
const value = parameterCatalogHost();
delete (value.ppro as { Project?: unknown }).Project;
const registry = Commands.createCommandRegistry({ ppro: value.ppro, Protocol });
await expect(registry.capabilities()).resolves.toMatchObject({ commands: {
"parameters.catalog.inspect": { supported: false, reason: expect.any(String) },
} });
});
});
+210
View File
@@ -0,0 +1,210 @@
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Events = require("../../uxp-plugin/events.cjs");
describe("UXP host event journal", () => {
it("maps only documented stable SnapEvent constants to passive timeline receipts", () => {
const definitions = Events.createTimelineSnapEventDefinitions({
EVENT_SNAP_TO_KEYFRAME: "snap-keyframe",
EVENT_SNAP_TO_TRACKITEM: "snap-track-item",
EVENT_SNAP_TO_GUIDES: "snap-guides",
EVENT_SNAP_RAZOR_TO_PLAYHEAD: "snap-razor-playhead",
EVENT_SNAP_RAZOR_TO_MARKER: "snap-razor-marker",
EVENT_SNAP_PLAYHEAD_TO_TRACKITEM_EDGE: "snap-playhead-edge",
UNDOCUMENTED_EVENT: "must-not-register",
});
expect(definitions).toEqual([
{ category: "timeline", name: "timeline.snap.keyframe", eventName: "snap-keyframe", stateInvalidating: false, coalesceKey: null },
{ category: "timeline", name: "timeline.snap.trackItem", eventName: "snap-track-item", stateInvalidating: false, coalesceKey: null },
{ category: "timeline", name: "timeline.snap.guides", eventName: "snap-guides", stateInvalidating: false, coalesceKey: null },
{ category: "timeline", name: "timeline.snap.razor.playhead", eventName: "snap-razor-playhead", stateInvalidating: false, coalesceKey: null },
{ category: "timeline", name: "timeline.snap.razor.marker", eventName: "snap-razor-marker", stateInvalidating: false, coalesceKey: null },
{ category: "timeline", name: "timeline.snap.playhead.trackItemEdge", eventName: "snap-playhead-edge", stateInvalidating: false, coalesceKey: null },
]);
expect(Events.createTimelineSnapEventDefinitions({
EVENT_SNAP_TO_KEYFRAME: 1,
EVENT_SNAP_TO_TRACKITEM: "",
})).toEqual([]);
expect(Events.createTimelineSnapEventDefinitions(null)).toEqual([]);
});
it("filters timeline SnapEvent receipts without exposing host payload fields", () => {
const journal = Events.createEventJournal({ capacity: 16 });
journal.recordHostEvent({
category: "timeline",
name: "timeline.snap.keyframe",
detail: { state: 4, target: "private clip", path: "D:/private.prproj" },
});
expect(journal.list({ categories: ["timeline"], eventNames: ["timeline.snap.keyframe"] }))
.toMatchObject({ events: [{ category: "timeline", name: "timeline.snap.keyframe", detail: { state: 4 } }] });
});
it("maps only documented root operation-boundary events and coalesces drag-over receipts", () => {
const definitions = Events.createOperationBoundaryEventDefinitions({
EVENT_CLIP_EXTEND_REACHED: "clip-extend-reached",
EVENT_EFFECT_DRAG_OVER: "effect-drag-over",
EVENT_EXPORT_MEDIA_COMPLETE: "already-handled-completion",
});
expect(definitions).toEqual([
{ category: "operation", name: "operation.clip.extend.reached", eventName: "clip-extend-reached", stateInvalidating: false, coalesceKey: null },
{ category: "operation", name: "operation.effect.drag.over", eventName: "effect-drag-over", stateInvalidating: false, coalesceKey: "operation.effect.drag.over" },
]);
expect(Events.createOperationBoundaryEventDefinitions({
EVENT_CLIP_EXTEND_REACHED: 1,
EVENT_EFFECT_DRAG_OVER: "",
})).toEqual([]);
expect(Events.createOperationBoundaryEventDefinitions(null)).toEqual([]);
const journal = Events.createEventJournal({ capacity: 16 });
journal.recordHostEvent({
category: "operation", name: definitions[1].name, coalesceKey: definitions[1].coalesceKey,
detail: { state: 1, path: "D:/private.prproj" },
});
journal.recordHostEvent({
category: "operation", name: definitions[1].name, coalesceKey: definitions[1].coalesceKey,
detail: { state: 2, target: "private clip" },
});
expect(journal.list({ eventNames: ["operation.effect.drag.over"] })).toMatchObject({
latestRevision: 2,
events: [{ category: "operation", name: "operation.effect.drag.over", detail: { state: 2 }, coalesced: 1 }],
});
});
it("keeps a bounded revisioned history and reports overflow", () => {
let now = 1_700_000_000_000;
const journal = Events.createEventJournal({ capacity: 16, now: () => now++ });
for (let index = 0; index < 20; index += 1) {
journal.append({ category: "project", name: "project.dirty", detail: { state: index } });
}
expect(journal.status()).toMatchObject({ capacity: 16, size: 16, latestRevision: 20, oldestRevision: 5, dropped: 4 });
expect(journal.list({ afterRevision: 1, limit: 3 })).toMatchObject({
overflow: true,
dropped: 4,
events: [
{ revision: 5, category: "project", name: "project.dirty" },
{ revision: 6 },
{ revision: 7 },
],
});
});
it("coalesces consecutive noisy progress receipts without exposing raw fields", () => {
const journal = Events.createEventJournal({ capacity: 16 });
journal.append({
category: "encoder", name: "encoder.progress", coalesceKey: "encoder.progress",
detail: { progress: 0.1, path: "D:/secret.mov" },
});
journal.append({
category: "encoder", name: "encoder.progress", coalesceKey: "encoder.progress",
detail: { progress: 0.8, projectName: "Private" },
});
expect(journal.list({})).toMatchObject({
latestRevision: 2,
events: [{ revision: 2, detail: { progress: 0.8 }, coalesced: 1 }],
});
expect(journal.list({ afterRevision: 1 })).toMatchObject({
overflow: false,
events: [{ revision: 2, coalesced: 1 }],
});
});
it("returns immediately when a filtered cursor has fallen behind evicted history", async () => {
const journal = Events.createEventJournal({ capacity: 16 });
for (let index = 0; index < 20; index += 1) {
journal.append({ category: "project", name: "project.dirty", detail: { state: index } });
}
await expect(journal.wait({
afterRevision: 1,
categories: ["encoder"],
timeoutMs: 60_000,
})).resolves.toMatchObject({ overflow: true, timedOut: false, events: [] });
});
it("settles a pending filtered waiter as soon as its cursor history is evicted", async () => {
const journal = Events.createEventJournal({ capacity: 16 });
for (let index = 0; index < 16; index += 1) {
journal.append({ category: "project", name: "project.dirty", detail: { state: index } });
}
const pending = journal.wait({
afterRevision: 1,
categories: ["encoder"],
timeoutMs: 60_000,
});
journal.append({ category: "project", name: "project.dirty" });
journal.append({ category: "project", name: "project.dirty" });
await expect(pending).resolves.toMatchObject({ overflow: true, timedOut: false, events: [] });
});
it("waits for a matching receipt and times out cleanly", async () => {
vi.useFakeTimers();
try {
const journal = Events.createEventJournal({ capacity: 16 });
const matching = journal.wait({ afterRevision: 0, categories: ["operation"], timeoutMs: 1000 });
journal.append({ category: "project", name: "project.dirty" });
journal.append({ category: "operation", name: "operation.import.complete", detail: { state: 0 } });
await expect(matching).resolves.toMatchObject({
timedOut: false,
events: [{ category: "operation", name: "operation.import.complete", detail: { state: 0 } }],
});
const timeout = journal.wait({ afterRevision: 2, eventNames: ["encoder.complete"], timeoutMs: 500 });
await vi.advanceTimersByTimeAsync(500);
await expect(timeout).resolves.toMatchObject({ timedOut: true, events: [] });
} finally {
vi.useRealTimers();
}
});
it("resolves pending waiters when the journal closes", async () => {
const journal = Events.createEventJournal({ capacity: 16 });
const pending = journal.wait({ timeoutMs: 60000 });
journal.close();
await expect(pending).resolves.toMatchObject({ closed: true, events: [] });
});
it("attributes encoder events only while exactly one job is active", async () => {
const journal = Events.createEventJournal({ capacity: 16 });
const first = journal.beginEncodeJob({ kind: "sequence", operationId: "encode-one" });
journal.markEncodeAccepted(first.jobId);
journal.recordHostEvent({ category: "encoder", name: "encoder.queued" });
journal.recordHostEvent({ category: "encoder", name: "encoder.progress", detail: { progress: 0.5 } });
expect(journal.listEncodeJobs({ jobId: first.jobId })).toMatchObject({
correlation: "single-active-job-only",
jobs: [{ jobId: "encode-one", state: "rendering", progress: 0.5, terminal: false }],
});
const terminal = journal.waitForEncodeJob({ jobId: first.jobId, timeoutMs: 1000 });
journal.recordHostEvent({ category: "encoder", name: "encoder.complete" });
await expect(terminal).resolves.toMatchObject({
timedOut: false,
job: { state: "completed", terminal: true, terminalReason: "completed" },
});
});
it("does not guess job correlation when multiple encodes are active", () => {
const journal = Events.createEventJournal({ capacity: 16 });
journal.beginEncodeJob({ kind: "sequence", operationId: "encode-one" });
journal.beginEncodeJob({ kind: "file", operationId: "encode-two" });
const receipt = journal.recordHostEvent({ category: "encoder", name: "encoder.complete" });
expect(receipt).toMatchObject({ detail: { attributed: false } });
expect(journal.listEncodeJobs({})).toMatchObject({
unattributedEncoderEvents: 1,
jobs: expect.arrayContaining([
expect.objectContaining({ jobId: "encode-one", terminal: false }),
expect.objectContaining({ jobId: "encode-two", terminal: false }),
]),
});
});
});
@@ -0,0 +1,72 @@
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const HybridBenchmark = require("../../uxp-plugin/hybrid-benchmark.cjs");
describe("UXP hybrid benchmark harness", () => {
it("keeps the production panel free of native addon permissions and declarations", () => {
const manifest = JSON.parse(readFileSync(join(process.cwd(), "uxp-plugin", "manifest.json"), "utf8"));
expect(manifest.manifestVersion).toBe(5);
expect(manifest).not.toHaveProperty("addon");
expect(manifest.requiredPermissions).not.toHaveProperty("enableAddon");
});
it("runs identical bounded JavaScript and injected-addon workloads", () => {
let clock = 0;
const result = HybridBenchmark.run({
sampleCount: 5,
warmupCount: 1,
iterations: 1,
inputLength: 1024,
seed: 7,
now: vi.fn(() => clock++),
readMemory: vi.fn(() => 1024),
requireAddon: true,
loadAddon: vi.fn(() => ({
runBenchmarkKernel: (values: Float64Array, iterations: number) =>
HybridBenchmark.weightedEnergy(values, iterations),
})),
});
expect(result).toMatchObject({
schemaVersion: 1,
workloadId: "weighted-energy-v1",
addon: { name: "premiere-mcp-benchmark.uxpaddon", loaded: true, error: null },
javascript: { sampleCount: 5, p50Ms: 1, p95Ms: 1, checksum: expect.any(Number) },
native: { sampleCount: 5, p50Ms: 1, p95Ms: 1, checksum: expect.any(Number) },
checksumMatch: true,
promotionEligible: false,
});
});
it("reports an unavailable addon without turning a local JavaScript run into promotion evidence", () => {
let clock = 0;
const result = HybridBenchmark.run({
sampleCount: 5,
warmupCount: 0,
iterations: 1,
inputLength: 1024,
now: () => clock++,
loadAddon: () => { throw new Error("not installed"); },
});
expect(result).toMatchObject({
addon: { loaded: false, error: "not installed" },
native: null,
checksumMatch: null,
promotionEligible: false,
});
});
it("rejects an addon that does not implement the benchmark adapter contract", () => {
expect(() => HybridBenchmark.run({
sampleCount: 5,
warmupCount: 0,
iterations: 1,
inputLength: 1024,
loadAddon: () => ({}),
})).toThrow("runBenchmarkKernel");
});
});
+679
View File
@@ -0,0 +1,679 @@
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Events = require("../../uxp-plugin/events.cjs");
const NextWorkflows = require("../../uxp-plugin/next-workflows.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
function registry() {
const events = Events.createEventJournal({ capacity: 16 });
const ppro = { Project: { getActiveProject: vi.fn(async () => null) } };
return { events, registry: Commands.createCommandRegistry({ ppro, Protocol, events }) };
}
describe("next-wave UXP event workflows", () => {
it("advertises event listing only when a journal is present", async () => {
await expect(registry().registry.capabilities()).resolves.toMatchObject({
commands: {
"events.list": { supported: true, readOnly: true, minHostVersion: "25.6.0" },
"events.wait": { supported: true, readOnly: true, minHostVersion: "25.6.0" },
},
});
const withoutEvents = Commands.createCommandRegistry({
ppro: { Project: { getActiveProject: vi.fn(async () => null) } }, Protocol,
});
await expect(withoutEvents.capabilities()).resolves.toMatchObject({
commands: { "events.list": { supported: false } },
});
});
it("lists and filters safe host receipts", async () => {
const value = registry();
value.events.append({ category: "project", name: "project.dirty" });
value.events.append({ category: "encoder", name: "encoder.complete" });
await expect(value.registry.dispatch("events.list", {
afterRevision: 0, categories: ["encoder"], limit: 10,
})).resolves.toMatchObject({
latestRevision: 2,
events: [{ revision: 2, category: "encoder", name: "encoder.complete" }],
});
});
it("rejects unbounded and unexpected event-query arguments", async () => {
const value = registry();
await expect(value.registry.dispatch("events.wait", { timeoutMs: 60001 })).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("events.list", { rawPayload: true })).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
});
it("waits adaptively for sequence video-effect analysis readback", async () => {
let clock = 0;
const sequence = {
guid: "sequence-1",
isDoneAnalyzingForVideoEffects: vi.fn()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true),
};
const project = {
getActiveSequence: vi.fn(async () => sequence),
getSequences: vi.fn(async () => [sequence]),
};
const definitions = NextWorkflows.createNextWorkflowDefinitions({
ppro: { Project: { getActiveProject: vi.fn(async () => project) } },
events: Events.createEventJournal({ capacity: 16 }),
now: () => clock,
sleep: vi.fn(async (milliseconds: number) => { clock += milliseconds; }),
});
await expect(definitions["readiness.analysis.wait"].handler({
sequenceId: "sequence-1", expectedSequenceId: "sequence-1",
timeoutMs: 1000, pollMinMs: 100, pollMaxMs: 500,
})).resolves.toMatchObject({
ready: true, timedOut: false, sequenceId: "sequence-1", checks: 2,
elapsedMs: 100, verificationBoundary: "sequence_analysis_readback",
});
});
it("requires a pre-dispatch revision and classifies operation completion state", async () => {
const events = Events.createEventJournal({ capacity: 16 });
const definitions = NextWorkflows.createNextWorkflowDefinitions({
ppro: {
Project: { getActiveProject: vi.fn(async () => null) },
Constants: { OperationCompleteState: { SUCCESS: 0, CANCELLED: 1, FAILED: 2 } },
},
events,
});
await expect(definitions["readiness.operation.wait"].handler({
operationType: "import", timeoutMs: 0,
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
const before = events.status().latestRevision;
events.recordHostEvent({
category: "operation", name: "operation.import.complete", detail: { state: 0 },
});
await expect(definitions["readiness.operation.wait"].handler({
operationType: "import", afterRevision: before, timeoutMs: 0,
})).resolves.toMatchObject({
ready: true, timedOut: false, outcome: "completed",
receipt: { name: "operation.import.complete", detail: { state: 0 } },
verificationBoundary: "operation_terminal_event_only",
});
});
it("deduplicates project views, redacts paths by default, and verifies Save As retargeting", async () => {
const project = {
guid: "project-1", name: "Edit", path: "C:/work/source.prproj",
saveAs: vi.fn(async (path: string) => { project.path = path; return true; }),
};
const ppro = {
ProjectUtils: {
getProjectViewIds: vi.fn(async () => ["view-1", "view-2"]),
getProjectFromViewId: vi.fn(async () => project),
},
Project: {
getActiveProject: vi.fn(async () => project),
getProject: vi.fn(() => project),
open: vi.fn(), createProject: vi.fn(), isProject: vi.fn(() => false),
},
Guid: { fromString: vi.fn((value: string) => value) },
};
const definitions = NextWorkflows.createNextWorkflowDefinitions({
ppro,
events: Events.createEventJournal({ capacity: 16 }),
workspace: { assertPathAllowed: vi.fn(async (path: string) => path) },
});
await expect(definitions["project.sessions.list"].handler({})).resolves.toEqual({
count: 1,
activeProjectId: "project-1",
projects: [{ projectId: "project-1", name: "Edit", hasPath: true }],
pathDisclosure: "redacted",
});
await expect(definitions["project.sessions.saveAs"].handler({
path: "C:/work/branch.prproj",
})).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
await expect(definitions["project.sessions.saveAs"].handler({
path: "C:/work/branch.prproj", expectedPath: "C:/work/source.prproj",
confirmExternalWrite: true,
})).resolves.toMatchObject({
action: "saved_as", projectId: "project-1", path: "C:/work/branch.prproj",
outcome: "verified", verificationBoundary: "project_path_readback",
});
});
it("bounds growing-media pauses with a persisted lease and resumes on disposal", async () => {
const values = new Map<string, string>();
const storage = {
getItem: vi.fn((key: string) => values.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => values.set(key, value)),
removeItem: vi.fn((key: string) => values.delete(key)),
};
const project = {
guid: "project-1", path: "C:/work/source.prproj",
pauseGrowing: vi.fn(async () => true),
};
const timer = { callback: null as null | (() => void) };
const runtime = NextWorkflows.createNextWorkflowRuntime({
ppro: {
Project: {
getActiveProject: vi.fn(async () => project),
getProject: vi.fn(() => project),
},
Guid: { fromString: vi.fn((value: string) => value) },
},
storage,
now: () => 1000,
setTimer: vi.fn((callback: () => void) => { timer.callback = callback; return 7; }),
clearTimer: vi.fn(),
});
await expect(runtime.definitions["growing.pause"].handler({ leaseMs: 600000 })).rejects.toMatchObject({
code: "UXP_CONFIRMATION_REQUIRED",
});
await expect(runtime.definitions["growing.pause"].handler({
leaseMs: 5000, confirmPause: true,
})).resolves.toMatchObject({
paused: true, projectId: "project-1", leaseMs: 5000,
outcome: "committed_unverified", verificationBoundary: "project_pauseGrowing_host_return_only",
});
expect(storage.setItem).toHaveBeenCalled();
expect(timer.callback).toBeTypeOf("function");
expect(runtime.definitions["growing.status"].handler({})).toMatchObject({
pausedByThisPanel: true, projectId: "project-1", verificationBoundary: "panel_local_lease_only",
});
await expect(runtime.dispose()).resolves.toMatchObject({ resumed: true, reason: "panel_or_bridge_disconnect" });
expect(project.pauseGrowing).toHaveBeenNthCalledWith(1, true);
expect(project.pauseGrowing).toHaveBeenNthCalledWith(2, false);
expect(storage.removeItem).toHaveBeenCalled();
});
it("writes namespaced checkpoints in one transaction and verifies typed readback", async () => {
const values = new Map<string, unknown>();
const properties = {
hasValue: vi.fn((key: string) => values.has(key)),
getValue: vi.fn((key: string) => values.get(key)),
getValueAsInt: vi.fn((key: string) => values.get(key)),
getValueAsFloat: vi.fn((key: string) => values.get(key)),
getValueAsBool: vi.fn((key: string) => values.get(key)),
createSetValueAction: vi.fn((key: string, value: unknown) => ({ apply: () => values.set(key, value) })),
createClearValueAction: vi.fn((key: string) => ({ apply: () => values.delete(key) })),
};
const project = {
guid: "project-1",
getActiveSequence: vi.fn(async () => null),
lockedAccess: vi.fn((callback: () => void) => callback()),
executeTransaction: vi.fn((callback: (compound: { addAction: (action: { apply: () => void }) => boolean }) => void) => {
callback({ addAction: (action) => { action.apply(); return true; } });
return true;
}),
};
const definitions = NextWorkflows.createNextWorkflowDefinitions({
ppro: {
Project: { getActiveProject: vi.fn(async () => project) },
Properties: {
getProperties: vi.fn(async () => properties),
PROPERTY_PERSISTENT: 1,
PROPERTY_NON_PERSISTENT: 2,
},
},
});
await expect(definitions["checkpoint.set"].handler({
owner: "project", expectedOwnerId: "project-1", name: "render.pass",
valueType: "int", value: 3, persistence: "persistent",
})).resolves.toMatchObject({
owner: "project", ownerId: "project-1", name: "render.pass",
keyNamespace: "premiereMcp.", exists: true, valueType: "int", value: 3,
persistence: "persistent", outcome: "verified", verificationBoundary: "typed_property_readback",
});
expect(properties.createSetValueAction).toHaveBeenCalledWith("premiereMcp.render.pass", 3, 1);
await expect(definitions["checkpoint.get"].handler({
name: "render.pass", valueType: "int",
})).resolves.toMatchObject({ exists: true, value: 3 });
await expect(definitions["checkpoint.clear"].handler({ name: "render.pass" })).resolves.toMatchObject({
cleared: true, exists: false, verificationBoundary: "property_absence_readback",
});
expect(project.executeTransaction).toHaveBeenCalledTimes(2);
});
it("redacts media paths by default and verifies grouped offline actions", async () => {
let offline = false;
const clip = {
name: "Camera A",
getId: vi.fn(async () => "clip-1"),
isOffline: vi.fn(async () => offline),
canChangeMediaPath: vi.fn(async () => true),
canProxy: vi.fn(async () => true),
hasProxy: vi.fn(async () => true),
isMergedClip: vi.fn(async () => false),
isMulticamClip: vi.fn(async () => false),
getMediaFilePath: vi.fn(async () => "C:/private/camera.mov"),
getProxyPath: vi.fn(async () => "C:/private/proxy.mov"),
getOriginatingProjectPath: vi.fn(async () => "C:/private/source.prproj"),
getMedia: vi.fn(async () => ({ start: { seconds: 2 }, duration: { seconds: 5 } })),
createSetOfflineAction: vi.fn(() => ({ apply: () => { offline = true; } })),
};
const project = {
guid: "project-1",
lockedAccess: vi.fn((callback: () => void) => callback()),
executeTransaction: vi.fn((callback: (compound: { addAction: (action: { apply: () => void }) => boolean }) => void) => {
callback({ addAction: (action) => { action.apply(); return true; } });
return true;
}),
};
const definitions = NextWorkflows.createNextWorkflowDefinitions({
ppro: {
Project: { getActiveProject: vi.fn(async () => project) },
ProjectUtils: { getSelection: vi.fn(async () => ({ getItems: vi.fn(async () => [clip]) })) },
ClipProjectItem: { cast: vi.fn((item: unknown) => item) },
},
});
const inspected = await definitions["media.health.inspect"].handler({});
expect(inspected).toMatchObject({
count: 1, pathDisclosure: "redacted",
items: [{ projectItemId: "clip-1", offline: false, hasProxy: true }],
});
expect(inspected.items[0]).not.toHaveProperty("mediaPath");
expect(inspected.items[0]).not.toHaveProperty("mediaTiming");
expect(clip.getMedia).not.toHaveBeenCalled();
await expect(definitions["media.health.setOffline"].handler({
expectedOffline: false,
})).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
await expect(definitions["media.health.setOffline"].handler({
expectedOffline: false, confirmSetOffline: true,
})).resolves.toMatchObject({
updated: 1, items: [{ projectItemId: "clip-1", offline: true }],
outcome: "verified", verificationBoundary: "offline_state_readback",
});
});
it("reads opt-in media timing through stable properties, including beta promise-shape compatibility", async () => {
let media: Record<string, unknown> = {
start: { seconds: 1.25 },
duration: { seconds: 9.5 },
};
const clip = {
name: "Camera A",
getId: vi.fn(async () => "clip-1"),
isOffline: vi.fn(async () => false),
canChangeMediaPath: vi.fn(async () => true),
canProxy: vi.fn(async () => true),
hasProxy: vi.fn(async () => false),
isMergedClip: vi.fn(async () => false),
isMulticamClip: vi.fn(async () => false),
getMedia: vi.fn(async () => media),
};
const project = { guid: "project-1" };
const definitions = NextWorkflows.createNextWorkflowDefinitions({
ppro: {
Project: { getActiveProject: vi.fn(async () => project) },
ProjectUtils: { getSelection: vi.fn(async () => ({ getItems: vi.fn(async () => [clip]) })) },
ClipProjectItem: { cast: vi.fn((item: unknown) => item) },
},
});
await expect(definitions["media.health.inspect"].handler({ includeMediaTiming: true })).resolves.toMatchObject({
items: [{
mediaTiming: {
available: true,
startSeconds: 1.25,
durationSeconds: 9.5,
startAccessor: "start",
durationAccessor: "duration",
},
}],
});
const betaPropertyMedia = {
getStart: vi.fn(async () => ({ seconds: 99 })),
getDuration: vi.fn(async () => ({ seconds: 99 })),
start: Promise.resolve({ seconds: 3 }),
duration: Promise.resolve({ seconds: 12 }),
};
media = betaPropertyMedia;
await expect(definitions["media.health.inspect"].handler({ includeMediaTiming: true })).resolves.toMatchObject({
items: [{
mediaTiming: {
available: true,
startSeconds: 3,
durationSeconds: 12,
startAccessor: "start",
durationAccessor: "duration",
},
}],
});
expect(betaPropertyMedia.getStart).not.toHaveBeenCalled();
expect(betaPropertyMedia.getDuration).not.toHaveBeenCalled();
const failedPropertyMedia = {
getStart: vi.fn(async () => ({ seconds: 99 })),
getDuration: vi.fn(async () => ({ seconds: 99 })),
get start() { throw new Error("start unavailable"); },
duration: { seconds: 7 },
};
media = failedPropertyMedia;
await expect(definitions["media.health.inspect"].handler({ includeMediaTiming: true })).resolves.toMatchObject({
items: [{
mediaTiming: {
available: false,
startSeconds: null,
durationSeconds: 7,
startAccessor: "start",
durationAccessor: "duration",
},
}],
});
expect(failedPropertyMedia.getStart).not.toHaveBeenCalled();
expect(failedPropertyMedia.getDuration).not.toHaveBeenCalled();
media = { start: { seconds: -1 }, duration: { seconds: 86400001 } };
await expect(definitions["media.health.inspect"].handler({ includeMediaTiming: true })).resolves.toMatchObject({
items: [{
mediaTiming: {
available: false,
startSeconds: null,
durationSeconds: null,
startAccessor: "start",
durationAccessor: "duration",
},
}],
});
await expect(definitions["media.health.inspect"].handler({ includeMediaTiming: "yes" })).rejects.toMatchObject({
code: "UXP_INVALID_ARGUMENT",
});
});
it("guards, serializes, replays, and reads back a source-media start-time action", async () => {
let startSeconds = 10, durationSeconds = 60;
const media = {
get start() { return { seconds: startSeconds }; },
get duration() { return { seconds: durationSeconds }; },
createSetStartAction: vi.fn((time: { seconds: number }) => ({
apply: () => {
startSeconds = time.seconds;
if (time.seconds === 16) durationSeconds = 59;
},
})),
};
const clip = {
getId: vi.fn(async () => "clip-1"),
getMedia: vi.fn(async () => media),
};
const root = { getItems: vi.fn(async () => [clip]) };
const project = {
guid: "project-1",
getRootItem: vi.fn(async () => root),
lockedAccess: vi.fn((callback: () => void) => callback()),
executeTransaction: vi.fn((callback: (compound: { addAction: (action: { apply: () => void }) => boolean }) => void) => {
callback({ addAction: (action) => { action.apply(); return true; } });
return true;
}),
};
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) },
ProjectItem: { cast: vi.fn((item: unknown) => item) },
ClipProjectItem: { cast: vi.fn((item: unknown) => item) },
FolderItem: { cast: vi.fn(() => null) },
TickTime: { createWithSeconds: vi.fn((seconds: number) => ({ seconds })) },
};
const registry = Commands.createCommandRegistry({ ppro, Protocol });
await expect(registry.dispatch("source.mediaTiming.inspect", { projectItemId: "clip-1" })).resolves.toEqual({
projectItemId: "clip-1", startSeconds: 10, durationSeconds: 60,
verificationBoundary: "source_media_timing_readback",
});
await expect(registry.dispatch("source.mediaTiming.setStart", {
projectItemId: "clip-1", expectedTiming: { startSeconds: 10, durationSeconds: 60 }, startSeconds: 12,
})).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
expect(media.createSetStartAction).not.toHaveBeenCalled();
await expect(registry.dispatch("source.mediaTiming.setStart", {
projectItemId: "clip-1", expectedTiming: { startSeconds: 10 }, startSeconds: 12, confirmSetStart: true,
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
expect(media.createSetStartAction).not.toHaveBeenCalled();
const firstArgs = {
projectItemId: "clip-1", expectedTiming: { startSeconds: 10, durationSeconds: 60 },
startSeconds: 12, confirmSetStart: true, operationId: "source-start-1",
};
await expect(registry.dispatch("source.mediaTiming.setStart", firstArgs)).resolves.toMatchObject({
updated: true, projectItemId: "clip-1", outcome: "verified", operationId: "source-start-1",
before: { startSeconds: 10, durationSeconds: 60 }, after: { startSeconds: 12, durationSeconds: 60 },
});
expect(project.executeTransaction).toHaveBeenCalledWith(expect.any(Function), "Set source media start time");
await expect(registry.dispatch("source.mediaTiming.setStart", firstArgs)).resolves.toMatchObject({
replayed: true, operationId: "source-start-1",
});
expect(media.createSetStartAction).toHaveBeenCalledTimes(1);
const expectedTiming = { startSeconds: 12, durationSeconds: 60 };
const concurrentFirst = registry.dispatch("source.mediaTiming.setStart", {
projectItemId: "clip-1", expectedTiming, startSeconds: 15, confirmSetStart: true, operationId: "source-start-2",
});
const concurrentSecond = registry.dispatch("source.mediaTiming.setStart", {
projectItemId: "clip-1", expectedTiming, startSeconds: 18, confirmSetStart: true, operationId: "source-start-3",
});
await expect(concurrentFirst).resolves.toMatchObject({
updated: true, after: { startSeconds: 15, durationSeconds: 60 }, operationId: "source-start-2",
});
await expect(concurrentSecond).rejects.toMatchObject({ code: "UXP_STALE_TARGET" });
expect(media.createSetStartAction).toHaveBeenCalledTimes(2);
expect(project.executeTransaction).toHaveBeenCalledTimes(2);
expect(startSeconds).toBe(15);
await expect(registry.dispatch("source.mediaTiming.setStart", {
projectItemId: "clip-1", expectedTiming: { startSeconds: 12, durationSeconds: 60 },
startSeconds: 19, confirmSetStart: true,
})).rejects.toMatchObject({ code: "UXP_STALE_TARGET" });
expect(media.createSetStartAction).toHaveBeenCalledTimes(2);
await expect(registry.dispatch("source.mediaTiming.setStart", {
projectItemId: "clip-1", expectedTiming: { startSeconds: 15, durationSeconds: 60 },
startSeconds: 16, confirmSetStart: true, operationId: "source-start-4",
})).rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
expect(project.executeTransaction).toHaveBeenCalledTimes(3);
expect(startSeconds).toBe(16);
expect(durationSeconds).toBe(59);
const betaPromiseMedia = {
start: Promise.resolve({ seconds: 16 }),
duration: Promise.resolve({ seconds: 59 }),
createSetStartAction: vi.fn(),
};
clip.getMedia.mockResolvedValue(betaPromiseMedia);
await expect(registry.dispatch("source.mediaTiming.setStart", {
projectItemId: "clip-1", expectedTiming: { startSeconds: 16, durationSeconds: 59 },
startSeconds: 19, confirmSetStart: true,
})).rejects.toMatchObject({ code: "UXP_COMMAND_UNAVAILABLE" });
expect(betaPromiseMedia.createSetStartAction).not.toHaveBeenCalled();
});
it("guards, serializes, replays, and reads back source-media interpretation overrides", async () => {
let frameRate = 23.976, pixelAspectRatio = 1;
const clip = {
getId: vi.fn(async () => "clip-1"),
getFootageInterpretation: vi.fn(async () => ({
getFrameRate: () => frameRate,
getPixelAspectRatio: () => pixelAspectRatio,
})),
createSetOverrideFrameRateAction: vi.fn((value: number) => ({ apply: () => { frameRate = value; } })),
createSetOverridePixelAspectRatioAction: vi.fn((numerator: number, denominator: number) => ({
apply: () => { pixelAspectRatio = numerator / denominator; },
})),
};
const root = { getItems: vi.fn(async () => [clip]) };
const project = {
guid: "project-1",
getRootItem: vi.fn(async () => root),
lockedAccess: vi.fn((callback: () => void) => callback()),
executeTransaction: vi.fn((callback: (compound: { addAction: (action: { apply: () => void }) => boolean }) => void) => {
callback({ addAction: (action) => { action.apply(); return true; } });
return true;
}),
};
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) },
ProjectItem: { cast: vi.fn((item: unknown) => item) },
ClipProjectItem: { cast: vi.fn((item: unknown) => item) },
FolderItem: { cast: vi.fn(() => null) },
};
const registry = Commands.createCommandRegistry({ ppro, Protocol });
const initial = { projectGuid: "project-1", frameRate: 23.976, pixelAspectRatio: 1 };
await expect(registry.dispatch("source.mediaOverrides.inspect", { projectItemId: "clip-1" })).resolves.toEqual({
...initial,
projectItemId: "clip-1",
verificationBoundary: "source_media_effective_interpretation_readback",
});
await expect(registry.dispatch("source.mediaOverrides.update", {
projectItemId: "clip-1", expectedOverrides: initial, frameRate: 25,
})).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
await expect(registry.dispatch("source.mediaOverrides.update", {
projectItemId: "clip-1", expectedOverrides: initial, frameRate: 25, confirmMediaInterpretation: true,
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
expect(clip.createSetOverrideFrameRateAction).not.toHaveBeenCalled();
const firstArgs = {
projectItemId: "clip-1", expectedOverrides: initial, frameRate: 25,
pixelAspectRatio: { numerator: 4, denominator: 3 }, confirmMediaInterpretation: true,
operationId: "source-override-1",
};
await expect(registry.dispatch("source.mediaOverrides.update", firstArgs)).resolves.toMatchObject({
updated: true, operationId: "source-override-1", outcome: "verified",
before: { projectGuid: "project-1", projectItemId: "clip-1", frameRate: 23.976, pixelAspectRatio: 1 },
after: { projectGuid: "project-1", projectItemId: "clip-1", frameRate: 25, pixelAspectRatio: 4 / 3 },
requested: { frameRate: 25, pixelAspectRatio: { numerator: 4, denominator: 3 } },
});
expect(project.executeTransaction).toHaveBeenCalledWith(expect.any(Function), "Set source media interpretation override");
await expect(registry.dispatch("source.mediaOverrides.update", firstArgs)).resolves.toMatchObject({
replayed: true, operationId: "source-override-1",
});
expect(clip.createSetOverrideFrameRateAction).toHaveBeenCalledTimes(1);
expect(clip.createSetOverridePixelAspectRatioAction).toHaveBeenCalledTimes(1);
const expectedCurrent = { projectGuid: "project-1", frameRate: 25, pixelAspectRatio: 4 / 3 };
const concurrentFirst = registry.dispatch("source.mediaOverrides.update", {
projectItemId: "clip-1", expectedOverrides: expectedCurrent, frameRate: 30,
confirmMediaInterpretation: true, operationId: "source-override-2",
});
const concurrentSecond = registry.dispatch("source.mediaOverrides.update", {
projectItemId: "clip-1", expectedOverrides: expectedCurrent, pixelAspectRatio: { numerator: 16, denominator: 9 },
confirmMediaInterpretation: true, operationId: "source-override-3",
});
await expect(concurrentFirst).resolves.toMatchObject({
updated: true, operationId: "source-override-2", after: { frameRate: 30, pixelAspectRatio: 4 / 3 },
});
await expect(concurrentSecond).rejects.toMatchObject({ code: "UXP_STALE_TARGET" });
expect(clip.createSetOverrideFrameRateAction).toHaveBeenCalledTimes(2);
expect(clip.createSetOverridePixelAspectRatioAction).toHaveBeenCalledTimes(1);
expect(frameRate).toBe(30);
expect(pixelAspectRatio).toBeCloseTo(4 / 3);
await expect(registry.dispatch("source.mediaOverrides.update", {
projectItemId: "clip-1", expectedOverrides: expectedCurrent, frameRate: 24,
confirmMediaInterpretation: true, operationId: "source-override-4",
})).rejects.toMatchObject({ code: "UXP_STALE_TARGET" });
expect(clip.createSetOverrideFrameRateAction).toHaveBeenCalledTimes(2);
clip.createSetOverrideFrameRateAction.mockImplementationOnce(() => ({ apply: () => { frameRate = 29.97; } }));
await expect(registry.dispatch("source.mediaOverrides.update", {
projectItemId: "clip-1", expectedOverrides: { projectGuid: "project-1", frameRate: 30, pixelAspectRatio: 4 / 3 }, frameRate: 24,
confirmMediaInterpretation: true, operationId: "source-override-5",
})).rejects.toMatchObject({ code: "UXP_VERIFICATION_FAILED" });
expect(project.executeTransaction).toHaveBeenCalledTimes(3);
expect(frameRate).toBe(29.97);
});
it("sets caption-track mute state through direct host promises and reads it back", async () => {
let muted = false;
const captionTrack = {
id: 9, name: "English",
getIndex: vi.fn(async () => 0),
isMuted: vi.fn(async () => muted),
setMute: vi.fn(async (value: boolean) => { muted = value; return true; }),
};
const sequence = {
guid: "sequence-1",
getVideoTrackCount: vi.fn(async () => 0),
getAudioTrackCount: vi.fn(async () => 0),
getCaptionTrackCount: vi.fn(async () => 1),
getCaptionTrack: vi.fn(async () => captionTrack),
};
const project = {
getActiveSequence: vi.fn(async () => sequence),
getSequences: vi.fn(async () => [sequence]),
};
const definitions = NextWorkflows.createNextWorkflowDefinitions({
ppro: { Project: { getActiveProject: vi.fn(async () => project) } },
});
await expect(definitions["track.state.set"].handler({
sequenceId: "sequence-1", expectedSequenceId: "sequence-1",
mediaType: "caption", trackIndices: [0], muted: true, expectedMuted: false,
})).resolves.toMatchObject({
sequenceId: "sequence-1", mediaType: "caption", requested: 1, updated: 1, failed: 0,
tracks: [{ mediaType: "caption", trackIndex: 0, beforeMuted: false, afterMuted: true, verified: true }],
outcome: "verified", undoable: false, verificationBoundary: "per_track_mute_readback",
});
await expect(definitions["track.state.inspect"].handler({
sequenceId: "sequence-1", mediaType: "all",
})).resolves.toMatchObject({
count: 1, tracks: [{ mediaType: "caption", trackIndex: 0, muted: true }],
});
});
it("commits source trim actions together, verifies time readback, and withholds scale verification", async () => {
let inSeconds = 1, outSeconds = 9;
const clip = {
name: "Camera A",
getId: vi.fn(() => "clip-1"),
getInPoint: vi.fn(async () => ({ seconds: inSeconds })),
getOutPoint: vi.fn(async () => ({ seconds: outSeconds })),
createSetInOutPointsAction: vi.fn((inPoint: { seconds: number }, outPoint: { seconds: number }) => ({
apply: () => { inSeconds = inPoint.seconds; outSeconds = outPoint.seconds; },
})),
createSetInPointAction: vi.fn(),
createSetOutPointAction: vi.fn(),
createClearInOutPointsAction: vi.fn(),
createSetScaleToFrameSizeAction: vi.fn(() => ({ apply: () => undefined })),
};
const root = { getItems: vi.fn(async () => [clip]) };
const project = {
getRootItem: vi.fn(async () => root),
lockedAccess: vi.fn((callback: () => void) => callback()),
executeTransaction: vi.fn((callback: (compound: { addAction: (action: { apply: () => void }) => boolean }) => void) => {
callback({ addAction: (action) => { action.apply(); return true; } });
return true;
}),
};
const definitions = NextWorkflows.createNextWorkflowDefinitions({
ppro: {
Project: { getActiveProject: vi.fn(async () => project) },
ClipProjectItem: { cast: vi.fn((item: unknown) => item) },
ProjectItem: { cast: vi.fn((item: unknown) => item) },
FolderItem: { cast: vi.fn(() => null) },
TickTime: { createWithSeconds: vi.fn((seconds: number) => ({ seconds })) },
Constants: { MediaType: { VIDEO: 1, AUDIO: 2 } },
},
});
await expect(definitions["source.clip.update"].handler({
items: [{
projectItemId: "clip-1", mediaType: "video", expectedInSeconds: 1, expectedOutSeconds: 9,
inSeconds: 2, outSeconds: 8,
}],
})).resolves.toMatchObject({
updated: 1, outcome: "verified", verificationBoundary: "source_in_out_readback",
items: [{ after: { inSeconds: 2, outSeconds: 8 }, trimVerified: true, scaleToFrameRequested: false }],
});
await expect(definitions["source.clip.update"].handler({
items: [{ projectItemId: "clip-1", mediaType: "video", scaleToFrame: true }],
})).resolves.toMatchObject({
outcome: "committed_unverified",
verificationBoundary: "transaction_commit_with_missing_clear_or_scale_getter",
items: [{ scaleToFrameRequested: true, scaleToFrameVerified: false }],
});
expect(project.executeTransaction).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,113 @@
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
type Options = { sequenceMasks?: Record<string, boolean>; changeSequenceMaskOnSecondRead?: boolean; disappearOnSecondLookup?: boolean };
function objectMaskHost(options: Options = {}) {
const sequences = [
{ guid: "sequence-b", name: "B sequence" },
{ guid: "sequence-a", name: "A sequence" },
];
const sequenceMasks = new Map(Object.entries({ "sequence-a": false, "sequence-b": true, ...options.sequenceMasks }));
const sequenceCalls = new Map<string, number>();
let sequenceLookupCalls = 0;
const project = {
guid: "project-1",
getActiveSequence: vi.fn(async () => null),
getSequences: vi.fn(async () => sequences),
getSequence: vi.fn(async (guid: { toString: () => string }) => {
sequenceLookupCalls += 1;
if (options.disappearOnSecondLookup && sequenceLookupCalls > 1) return null;
return sequences.find((sequence) => sequence.guid === guid.toString()) || null;
}),
};
const hasObjectMask = vi.fn((target: typeof project | (typeof sequences)[number]) => {
if (target === project) return Array.from(sequenceMasks.values()).some(Boolean);
const sequence = target as (typeof sequences)[number];
const calls = (sequenceCalls.get(sequence.guid) || 0) + 1;
sequenceCalls.set(sequence.guid, calls);
if (options.changeSequenceMaskOnSecondRead && sequence.guid === "sequence-a" && calls > 1) return true;
return sequenceMasks.get(sequence.guid) || false;
});
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) },
Guid: { fromString: vi.fn((value: string) => ({ toString: () => value })) },
ObjectMaskUtils: { hasObjectMask },
};
return { project, sequences, ppro, hasObjectMask, registry: Commands.createCommandRegistry({ ppro, Protocol }) };
}
describe("bounded documented UXP object-mask audit", () => {
it("advertises a read-only target-probed audit and double-reads a deterministic full-project sequence result", async () => {
const value = objectMaskHost();
await expect(value.registry.capabilities()).resolves.toMatchObject({ commands: {
"objectMask.audit": { supported: true, readOnly: true, minHostVersion: "26.3.0", targetCapabilityProbe: "invocation" },
} });
await expect(value.registry.dispatch("objectMask.audit", {})).resolves.toEqual({
projectGuid: "project-1",
scope: "all_project_sequences",
projectHasObjectMask: true,
sequenceCount: 2,
maskedSequenceCount: 1,
sequences: [
{ id: "sequence-a", name: "A sequence", hasObjectMask: false },
{ id: "sequence-b", name: "B sequence", hasObjectMask: true },
],
verificationBoundary: "bounded_project_and_sequence_object_mask_double_readback",
});
expect(value.project.getSequences).toHaveBeenCalledTimes(2);
expect(value.hasObjectMask).toHaveBeenCalledTimes(6);
});
it("resolves exact requested GUIDs without enumerating every project sequence", async () => {
const value = objectMaskHost();
await expect(value.registry.dispatch("objectMask.audit", {
expectedProjectGuid: "project-1", sequenceIds: ["sequence-b", "sequence-a"],
})).resolves.toMatchObject({
scope: "explicit_sequences",
sequences: [
{ id: "sequence-a", hasObjectMask: false },
{ id: "sequence-b", hasObjectMask: true },
],
});
expect(value.project.getSequences).not.toHaveBeenCalled();
expect(value.project.getSequence).toHaveBeenCalledTimes(4);
expect(value.ppro.Guid.fromString).toHaveBeenCalledTimes(4);
});
it("rejects duplicate, stale, and oversized selectors without presenting a mixed audit", async () => {
const value = objectMaskHost();
await expect(value.registry.dispatch("objectMask.audit", { sequenceIds: ["sequence-a", "sequence-a"] }))
.rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("objectMask.audit", { expectedProjectGuid: "other-project" }))
.rejects.toMatchObject({ code: "UXP_STALE_OBJECT_MASK_AUDIT" });
await expect(value.registry.dispatch("objectMask.audit", { unexpected: true }))
.rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
const tooLarge = objectMaskHost();
tooLarge.project.getSequences.mockResolvedValueOnce(Array.from({ length: 65 }, (_, index) => ({ guid: `sequence-${index}`, name: "too many" })));
await expect(tooLarge.registry.dispatch("objectMask.audit", {})).rejects.toMatchObject({ code: "UXP_PROJECT_TOO_LARGE" });
});
it("fails closed when a sequence result or exact target changes during the double read", async () => {
const changed = objectMaskHost({ changeSequenceMaskOnSecondRead: true });
await expect(changed.registry.dispatch("objectMask.audit", {})).rejects.toMatchObject({ code: "UXP_STALE_OBJECT_MASK_AUDIT" });
const missing = objectMaskHost({ disappearOnSecondLookup: true });
await expect(missing.registry.dispatch("objectMask.audit", { sequenceIds: ["sequence-a"] }))
.rejects.toMatchObject({ code: "UXP_STALE_OBJECT_MASK_AUDIT" });
});
it("does not claim compatibility when the documented static object-mask API is absent", async () => {
const value = objectMaskHost();
delete (value.ppro as { ObjectMaskUtils?: unknown }).ObjectMaskUtils;
const registry = Commands.createCommandRegistry({ ppro: value.ppro, Protocol });
await expect(registry.capabilities()).resolves.toMatchObject({ commands: {
"objectMask.audit": { supported: false, reason: expect.any(String) },
} });
});
});
+161
View File
@@ -0,0 +1,161 @@
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const protocol = require("../../uxp-plugin/protocol.cjs");
describe("UXP bridge protocol", () => {
it("builds versioned envelopes", () => {
const result = protocol.envelope("event", { name: "changed" }, "r1");
expect(result).toMatchObject({ protocolVersion: 2, type: "event", requestId: "r1", payload: { name: "changed" } });
expect(Date.parse(result.sentAt)).not.toBeNaN();
});
it("parses commands with safe defaults", () => {
expect(protocol.parseCommand('{"type":"command","command":"state.get"}')).toEqual({ requestId: null, command: "state.get", args: {} });
});
it.each([
"projectSelection.views",
"bins.createSmart",
"sequenceSettings.update",
"parameters.keyframeAdd",
"timeline.cloneSelection",
"sequences.createFromMedia",
"encoder.projectItem",
])("accepts registered lower-camel command segments: %s", (command) => {
expect(protocol.parseCommand({ type: "command", command })).toMatchObject({ command });
});
it("rejects malformed commands", () => expect(() => protocol.parseCommand({ type: "event" })).toThrow("Invalid UXP bridge command"));
it("rejects invalid protocol versions and argument shapes", () => {
expect(() => protocol.parseCommand({ protocolVersion: 1, type: "command", command: "state.get" })).toThrow("Unsupported UXP protocol version");
expect(() => protocol.parseCommand({ type: "command", command: "state.get", args: [] })).toThrow("args must be an object");
expect(() => protocol.parseCommand({ type: "command", command: "../state" })).toThrow("Invalid UXP bridge command");
expect(() => protocol.parseCommand({ type: "command", command: "ProjectSelection.views" })).toThrow("Invalid UXP bridge command");
expect(() => protocol.parseCommand({ type: "command", command: "project_selection.views" })).toThrow("Invalid UXP bridge command");
});
it("bounds request identifiers and command size", () => {
expect(() => protocol.parseCommand({ type: "command", requestId: "", command: "state.get" })).toThrow("requestId");
const oversized = JSON.stringify({ type: "command", command: "state.get", padding: "x".repeat(protocol.MAX_COMMAND_BYTES) });
expect(() => protocol.parseCommand(oversized)).toThrow("64 KiB");
});
it("bounds complete result envelopes by UTF-8 bytes", () => {
expect(protocol.utf8ByteLength("aé😀")).toBe(7);
expect(() => protocol.assertResultSize({
projectMetadata: "😀".repeat(170_000),
xmpMetadata: "😀".repeat(170_000),
})).toThrow("1 MiB");
expect(protocol.serializeEnvelope(protocol.envelope("result", { ok: true, result: { value: "small" } }, "r1")))
.toContain('"type":"result"');
});
it("pre-serializes a success result before publishing its completed event", () => {
const panel = readFileSync(new URL("../../uxp-plugin/index.cjs", import.meta.url), "utf8");
const dispatchStart = panel.indexOf("async function dispatch(raw)");
const validation = panel.indexOf("Protocol.serializeEnvelope(response);", dispatchStart);
const completion = panel.indexOf('publishOperation("completed"', dispatchStart);
expect(validation).toBeGreaterThan(dispatchStart);
expect(completion).toBeGreaterThan(validation);
});
it("routes transcript imports through the replay-aware command registry", () => {
const panel = readFileSync(new URL("../../uxp-plugin/index.cjs", import.meta.url), "utf8");
expect(panel).toContain("transcriptImportHandler: transcriptImportRuntime && transcriptImportRuntime.importTranscript");
expect(panel).not.toContain('cmd.command === "transcript.import") result = await importTranscript');
});
it("registers the bounded root operation-boundary event definitions with host subscriptions", () => {
const panel = readFileSync(new URL("../../uxp-plugin/index.cjs", import.meta.url), "utf8");
expect(panel).toContain("EventSupport.createOperationBoundaryEventDefinitions(ppro.OperationCompleteEvent)");
expect(panel).toContain("].concat(snapEvents, operationBoundaryEvents)");
});
it("prevents filename path traversal", () => {
expect(protocol.safeFilename("shot-01.png")).toBe("shot-01.png");
expect(() => protocol.safeFilename("../shot.png")).toThrow();
expect(() => protocol.safeFilename("shot.jpg")).toThrow();
});
it("passes a bare frame stem to Premiere while retaining the public PNG filename", () => {
expect(protocol.exporterFrameName("shot-01.png")).toBe("shot-01");
expect(protocol.exporterFrameName("Frame.PNG")).toBe("Frame");
});
it("joins Windows and POSIX-style output paths", () => {
expect(protocol.joinPath("C:/temp", "a.png")).toBe("C:/temp/a.png");
expect(protocol.joinPath("C:/temp/", "a.png")).toBe("C:/temp/a.png");
});
it("keeps the one-extension normalization in the panel's direct frame-export path", () => {
const panel = readFileSync(new URL("../../uxp-plugin/index.cjs", import.meta.url), "utf8");
expect(panel).toContain("const exporterFilename = Protocol.exporterFrameName(filename);");
expect(panel).toContain("exportSequenceFrame(sequence, position, exporterFilename, outputDirectory, width, height)");
});
it("describes verification, undo, transaction, and cancellation boundaries", () => {
expect(protocol.operationSemantics({
mutatesProject: true,
verificationStatus: "verified",
verificationBoundary: "post_action_snapshot",
verificationEvidence: [{ type: "sequence", id: "s1" }],
undoSupported: true,
undoLabel: "Rename track",
transactionActionGroup: true,
cancellationSupported: false,
})).toEqual({
mutatesProject: true,
verification: {
status: "verified",
boundary: "post_action_snapshot",
evidence: [{ type: "sequence", id: "s1" }],
},
undo: {
supported: true,
boundary: "premiere_undo_history",
label: "Rename track",
},
transaction: {
actionGroup: true,
boundary: "project_executeTransaction",
atomicRollback: false,
},
cancellation: {
supported: false,
boundary: "before_non_cancellable_host_call",
},
});
});
it("emits correlated operation lifecycle events", () => {
expect(protocol.operationEvent(
"progress",
{ requestId: "op-1", command: "frame.export" },
{ phase: "verification", progress: 0.8 },
)).toMatchObject({
protocolVersion: 2,
type: "event",
requestId: "op-1",
payload: {
name: "premiere.operation.progress",
operation: {
requestId: "op-1",
command: "frame.export",
phase: "verification",
progress: 0.8,
},
},
});
});
it("only accepts cancellation before a non-cancellable host call", () => {
const tracker = protocol.createOperationTracker();
const operation = tracker.begin("op-1", "frame.export");
expect(tracker.requestCancel("op-1")).toEqual({
accepted: true,
reason: "cancellation_requested",
});
tracker.finish(operation);
expect(tracker.requestCancel("op-1")).toEqual({
accepted: false,
reason: "operation_not_active",
});
const inHost = tracker.begin("op-2", "frame.export");
inHost.phase = "host_call";
expect(tracker.requestCancel("op-2")).toEqual({
accepted: false,
reason: "host_call_not_cancellable",
});
});
});
@@ -0,0 +1,172 @@
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
type State = { projectItemId: string; start: number; end: number; inPoint: number; outPoint: number; speed: number; reversed: boolean };
type HostOptions = { pauseFirstSnapshot?: boolean };
function rippleHost(options: HostOptions = {}) {
const states: State[] = [
{ projectItemId: "target-1", start: 0, end: 10, inPoint: 20, outPoint: 30, speed: 1, reversed: false },
{ projectItemId: "following-1", start: 10, end: 20, inPoint: 40, outPoint: 50, speed: 1, reversed: false },
];
let releaseFirstSnapshot: (() => void) | null = null;
let firstSnapshotStarted: (() => void) | null = null;
const firstSnapshot = new Promise<void>((resolve) => { firstSnapshotStarted = resolve; });
const firstSnapshotRelease = new Promise<void>((resolve) => { releaseFirstSnapshot = resolve; });
let pauseFirstSnapshot = options.pauseFirstSnapshot === true;
function itemFor(state: State) {
return {
getProjectItem: vi.fn(async () => ({ getId: vi.fn(async () => state.projectItemId) })),
getStartTime: vi.fn(async () => ({ seconds: state.start })),
getEndTime: vi.fn(async () => ({ seconds: state.end })),
getInPoint: vi.fn(async () => {
if (pauseFirstSnapshot) {
pauseFirstSnapshot = false;
firstSnapshotStarted?.();
await firstSnapshotRelease;
}
return { seconds: state.inPoint };
}),
getOutPoint: vi.fn(async () => ({ seconds: state.outPoint })),
getDuration: vi.fn(async () => ({ seconds: state.end - state.start })),
getSpeed: vi.fn(async () => state.speed),
isSpeedReversed: vi.fn(async () => state.reversed),
};
}
const items = states.map(itemFor);
const videoTrack = { getTrackItems: vi.fn(async () => items) };
const sequence = {
guid: "sequence-1",
getVideoTrackCount: vi.fn(async () => 1), getVideoTrack: vi.fn(async () => videoTrack),
getAudioTrackCount: vi.fn(async () => 0), getAudioTrack: vi.fn(async () => null),
};
let selected: typeof items[number] | null = null;
const createRemoveItemsAction = vi.fn((_selection: unknown, ripple: boolean, mediaType: number, shiftOverlapping: boolean) => ({
apply: () => {
if (!selected || !ripple || mediaType !== 1 || shiftOverlapping !== false) throw new Error("unexpected remove action");
const index = items.indexOf(selected);
if (index < 0) throw new Error("selected item was not on the track");
const duration = states[index]!.end - states[index]!.start;
states.splice(index, 1);
items.splice(index, 1);
for (let later = index; later < states.length; later += 1) {
states[later]!.start -= duration;
states[later]!.end -= duration;
}
},
}));
const addAction = vi.fn((action: { apply: () => void }) => { action.apply(); return true; });
const project = {
guid: "project-1", getActiveSequence: vi.fn(async () => sequence), lockedAccess: vi.fn((callback: () => void) => callback()),
executeTransaction: vi.fn((callback: (compound: { addAction: typeof addAction }) => void) => { callback({ addAction }); return true; }),
};
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) },
SequenceEditor: { getEditor: vi.fn(() => ({ createRemoveItemsAction })) },
TrackItemSelection: { createEmptySelection: vi.fn((callback: (selection: { addItem: (item: typeof items[number], select: boolean) => boolean }) => void) => {
callback({ addItem: (item) => { selected = item; return true; } });
return true;
}) },
Constants: { TrackItemType: { CLIP: 1 }, MediaType: { VIDEO: 1, AUDIO: 2 } },
};
return {
states, items, project, createRemoveItemsAction, firstSnapshot, releaseFirstSnapshot: () => releaseFirstSnapshot?.(),
registry: Commands.createCommandRegistry({ ppro, Protocol }),
};
}
const target = { projectItemId: "target-1", startSeconds: 0, endSeconds: 10, inSeconds: 20, outSeconds: 30, durationSeconds: 10, speed: 1, reversed: false };
const following = { projectItemId: "following-1", startSeconds: 10, endSeconds: 20, inSeconds: 40, outSeconds: 50, durationSeconds: 10, speed: 1, reversed: false };
const expectedSnapshot = { projectGuid: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0, clipIndex: 0, trackItemCount: 2, target, following };
const targetCoordinates = { mediaType: "video", trackIndex: 0, clipIndex: 0 };
describe("guarded documented UXP contiguous track-item ripple delete workflow", () => {
it("advertises bounded inspection and an idempotent undoable transaction command", async () => {
const value = rippleHost();
await expect(value.registry.capabilities()).resolves.toMatchObject({ commands: {
"trackItem.rippleDelete.inspect": { supported: true, readOnly: true, minHostVersion: "25.6.0" },
"trackItem.rippleDelete": { supported: true, destructive: true, undoable: true, idempotent: true, targetCapabilityProbe: "invocation" },
} });
await expect(value.registry.dispatch("trackItem.rippleDelete.inspect", targetCoordinates)).resolves.toEqual(expectedSnapshot);
expect(value.project.executeTransaction).not.toHaveBeenCalled();
});
it("removes exactly one reviewed item in one ripple transaction and reads back only its successor coordinate", async () => {
const value = rippleHost();
await expect(value.registry.dispatch("trackItem.rippleDelete", {
...targetCoordinates, expectedSnapshot, confirmRippleDelete: true, operationId: "ripple-apply-1",
})).resolves.toMatchObject({
operationId: "ripple-apply-1", rippleDeleted: true, outcome: "verified", before: expectedSnapshot,
after: {
projectGuid: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0,
successorClipIndex: 0, trackItemCount: 1,
successor: { ...following, startSeconds: 0, endSeconds: 10 },
},
verificationBoundary: "contiguous_successor_track_item_readback",
});
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.project.executeTransaction.mock.calls[0]?.[1]).toBe("Ripple delete timeline item");
expect(value.createRemoveItemsAction).toHaveBeenCalledWith(expect.objectContaining({ addItem: expect.any(Function) }), true, 1, false);
expect(value.states).toEqual([{ projectItemId: "following-1", start: 0, end: 10, inPoint: 40, outPoint: 50, speed: 1, reversed: false }]);
});
it("does not invoke unrelated earlier item getters during preflight or successor-only readback", async () => {
const value = rippleHost();
const unrelatedGetter = vi.fn(async () => { throw new Error("unrelated item must not be read"); });
value.states.unshift({ projectItemId: "unrelated-1", start: 0, end: 5, inPoint: 0, outPoint: 5, speed: 1, reversed: false });
value.items.unshift({
getProjectItem: unrelatedGetter, getStartTime: unrelatedGetter, getEndTime: unrelatedGetter,
getInPoint: unrelatedGetter, getOutPoint: unrelatedGetter, getDuration: unrelatedGetter,
getSpeed: unrelatedGetter, isSpeedReversed: unrelatedGetter,
});
const reviewedTarget = { mediaType: "video", trackIndex: 0, clipIndex: 1 };
const reviewedSnapshot = { ...expectedSnapshot, clipIndex: 1, trackItemCount: 3 };
await expect(value.registry.dispatch("trackItem.rippleDelete", {
...reviewedTarget, expectedSnapshot: reviewedSnapshot, confirmRippleDelete: true, operationId: "bounded-readback",
})).resolves.toMatchObject({ outcome: "verified", after: { trackItemCount: 2, successorClipIndex: 1 } });
expect(unrelatedGetter).not.toHaveBeenCalled();
});
it("rejects missing authority, stale state, a final item, and a gap before action creation", async () => {
const value = rippleHost();
await expect(value.registry.dispatch("trackItem.rippleDelete", { ...targetCoordinates, expectedSnapshot, operationId: "missing-confirmation" })).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
await expect(value.registry.dispatch("trackItem.rippleDelete", { ...targetCoordinates, expectedSnapshot, confirmRippleDelete: true })).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("trackItem.rippleDelete", {
...targetCoordinates, expectedSnapshot: { ...expectedSnapshot, following: { ...following, outSeconds: 51 } }, confirmRippleDelete: true, operationId: "stale",
})).rejects.toMatchObject({ code: "UXP_STALE_TRACK_ITEM" });
const gap = rippleHost();
gap.states[1]!.start = 11;
await expect(gap.registry.dispatch("trackItem.rippleDelete.inspect", targetCoordinates)).rejects.toMatchObject({ code: "UXP_TARGET_UNSUPPORTED" });
const finalItem = rippleHost();
await expect(finalItem.registry.dispatch("trackItem.rippleDelete.inspect", { ...targetCoordinates, clipIndex: 1 })).rejects.toMatchObject({ code: "UXP_TARGET_UNSUPPORTED" });
expect(value.project.executeTransaction).not.toHaveBeenCalled();
});
it("replays a completed operation ID without creating a second host transaction", async () => {
const value = rippleHost();
const args = { ...targetCoordinates, expectedSnapshot, confirmRippleDelete: true, operationId: "replay-ripple" };
await expect(value.registry.dispatch("trackItem.rippleDelete", args)).resolves.toMatchObject({ outcome: "verified" });
await expect(value.registry.dispatch("trackItem.rippleDelete", args)).resolves.toMatchObject({ outcome: "verified", replayed: true });
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.states).toHaveLength(1);
});
it("serializes distinct operation IDs so a stale reviewed ripple cannot remove the wrong successor", async () => {
const value = rippleHost({ pauseFirstSnapshot: true });
const first = value.registry.dispatch("trackItem.rippleDelete", { ...targetCoordinates, expectedSnapshot, confirmRippleDelete: true, operationId: "concurrent-first" });
await value.firstSnapshot;
const second = value.registry.dispatch("trackItem.rippleDelete", { ...targetCoordinates, expectedSnapshot, confirmRippleDelete: true, operationId: "concurrent-second" });
const secondExpectation = expect(second).rejects.toMatchObject({ code: "UXP_STALE_TRACK_ITEM" });
value.releaseFirstSnapshot();
await expect(first).resolves.toMatchObject({ outcome: "verified" });
await secondExpectation;
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.states).toEqual([{ projectItemId: "following-1", start: 0, end: 10, inPoint: 40, outPoint: 50, speed: 1, reversed: false }]);
});
});
@@ -0,0 +1,138 @@
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
type HostOptions = { pauseLockedPreflight?: boolean; sequenceCount?: number };
function previewFrameHost(options: HostOptions = {}) {
let previewWidth = 640;
let previewHeight = 360;
let pendingRect: { width: number; height: number } | null = null;
let getterCalls = 0;
let notifyLockedPreflight: (() => void) | null = null;
let releaseLockedPreflight: (() => void) | null = null;
const lockedPreflight = new Promise<void>((resolve) => { notifyLockedPreflight = resolve; });
const lockedPreflightRelease = new Promise<void>((resolve) => { releaseLockedPreflight = resolve; });
const settings = {
getPreviewFrameRect: vi.fn(async () => {
getterCalls += 1;
if (options.pauseLockedPreflight && getterCalls === 1) {
notifyLockedPreflight?.();
await lockedPreflightRelease;
}
return { width: previewWidth, height: previewHeight };
}),
setPreviewFrameRect: vi.fn(async (rect: { width: number; height: number }) => {
pendingRect = { width: rect.width, height: rect.height };
return true;
}),
};
const sequence = {
guid: "sequence-1",
getSettings: vi.fn(async () => settings),
createSetSettingsAction: vi.fn(() => ({
apply: () => {
if (!pendingRect) throw new Error("expected staged preview frame");
previewWidth = pendingRect.width;
previewHeight = pendingRect.height;
},
})),
};
const addAction = vi.fn((action: { apply: () => void }) => { action.apply(); return true; });
const sequences = options.sequenceCount === 1025
? Array.from({ length: 1025 }, (_, index) => index === 0 ? sequence : { guid: `sequence-${index}` })
: [sequence];
const project = {
guid: "project-1",
getActiveSequence: vi.fn(async () => sequence),
getSequences: vi.fn(async () => sequences),
lockedAccess: vi.fn((callback: () => void) => callback()),
executeTransaction: vi.fn((callback: (compound: { addAction: typeof addAction }) => void) => { callback({ addAction }); return true; }),
};
function RectF(this: { width: number; height: number }) { this.width = 0; this.height = 0; }
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) },
RectF,
};
return {
project, sequence, settings, lockedPreflight, registry: Commands.createCommandRegistry({ ppro, Protocol }),
releaseLockedPreflight: () => releaseLockedPreflight?.(),
get dimensions() { return { width: previewWidth, height: previewHeight }; },
};
}
const target = { sequenceId: "sequence-1" };
const expectedSnapshot = { projectGuid: "project-1", sequenceId: "sequence-1", previewWidth: 640, previewHeight: 360 };
describe("guarded documented UXP sequence preview-frame workflow", () => {
it("advertises a bounded readback and double-reads one explicit sequence without a transaction", async () => {
const value = previewFrameHost();
await expect(value.registry.capabilities()).resolves.toMatchObject({ commands: {
"sequence.previewFrame.inspect": { supported: true, readOnly: true, minHostVersion: "26.3.0" },
"sequence.previewFrame.update": { supported: true, destructive: true, undoable: true, idempotent: true, targetCapabilityProbe: "invocation" },
} });
await expect(value.registry.dispatch("sequence.previewFrame.inspect", target)).resolves.toEqual(expectedSnapshot);
expect(value.settings.getPreviewFrameRect).toHaveBeenCalledTimes(2);
expect(value.project.executeTransaction).not.toHaveBeenCalled();
});
it("sets exactly the reviewed preview frame in one transaction and reads it back", async () => {
const value = previewFrameHost();
await expect(value.registry.dispatch("sequence.previewFrame.update", {
...target, previewWidth: 1920, previewHeight: 1080, expectedSnapshot,
confirmSetPreviewFrame: true, operationId: "preview-frame-1",
})).resolves.toMatchObject({
operationId: "preview-frame-1", previewFrameUpdated: true, outcome: "verified", before: expectedSnapshot,
after: { ...expectedSnapshot, previewWidth: 1920, previewHeight: 1080 },
verificationBoundary: "sequence_preview_frame_readback", undoLabel: "Set sequence preview frame",
});
expect(value.settings.setPreviewFrameRect).toHaveBeenCalledWith(expect.objectContaining({ width: 1920, height: 1080 }));
expect(value.sequence.createSetSettingsAction).toHaveBeenCalledTimes(1);
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.project.executeTransaction.mock.calls[0]?.[1]).toBe("Set sequence preview frame");
expect(value.dimensions).toEqual({ width: 1920, height: 1080 });
});
it("requires authority, a complete fresh snapshot, a bounded rectangle, and a bounded sequence collection", async () => {
const value = previewFrameHost();
const update = { ...target, previewWidth: 1920, previewHeight: 1080, expectedSnapshot, operationId: "preview-frame-guard" };
await expect(value.registry.dispatch("sequence.previewFrame.update", update)).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
await expect(value.registry.dispatch("sequence.previewFrame.update", {
...update, expectedSnapshot: { ...expectedSnapshot, previewWidth: 641 }, confirmSetPreviewFrame: true,
})).rejects.toMatchObject({ code: "UXP_STALE_SEQUENCE_PREVIEW_FRAME" });
await expect(value.registry.dispatch("sequence.previewFrame.update", {
...update, previewWidth: 10241, confirmSetPreviewFrame: true,
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("sequence.previewFrame.inspect", { sequenceId: "missing" })).rejects.toMatchObject({ code: "UXP_TARGET_NOT_FOUND" });
await expect(previewFrameHost({ sequenceCount: 1025 }).registry.dispatch("sequence.previewFrame.inspect", target)).rejects.toMatchObject({ code: "UXP_TARGET_TOO_LARGE" });
expect(value.project.executeTransaction).not.toHaveBeenCalled();
});
it("replays an accepted operation ID without a second host transaction", async () => {
const value = previewFrameHost();
const args = { ...target, previewWidth: 1920, previewHeight: 1080, expectedSnapshot, confirmSetPreviewFrame: true, operationId: "preview-frame-replay" };
await expect(value.registry.dispatch("sequence.previewFrame.update", args)).resolves.toMatchObject({ outcome: "verified" });
await expect(value.registry.dispatch("sequence.previewFrame.update", args)).resolves.toMatchObject({ outcome: "verified", replayed: true });
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
});
it("serializes distinct operation IDs before preflight so stale second update cannot overwrite the first", async () => {
const value = previewFrameHost({ pauseLockedPreflight: true });
const first = value.registry.dispatch("sequence.previewFrame.update", {
...target, previewWidth: 1920, previewHeight: 1080, expectedSnapshot, confirmSetPreviewFrame: true, operationId: "preview-frame-first",
});
await value.lockedPreflight;
const second = value.registry.dispatch("sequence.previewFrame.update", {
...target, previewWidth: 1280, previewHeight: 720, expectedSnapshot, confirmSetPreviewFrame: true, operationId: "preview-frame-second",
});
const secondExpectation = expect(second).rejects.toMatchObject({ code: "UXP_STALE_SEQUENCE_PREVIEW_FRAME" });
value.releaseLockedPreflight();
await expect(first).resolves.toMatchObject({ outcome: "verified", after: { previewWidth: 1920, previewHeight: 1080 } });
await secondExpectation;
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.dimensions).toEqual({ width: 1920, height: 1080 });
});
});
+163
View File
@@ -0,0 +1,163 @@
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
type SlideHostOptions = { pauseFirstSnapshot?: boolean };
function slideHost(options: SlideHostOptions = {}) {
const state = {
previous: { start: 0, end: 10, inPoint: 0, outPoint: 10 },
target: { start: 10, end: 20, inPoint: 20, outPoint: 30 },
following: { start: 20, end: 30, inPoint: 30, outPoint: 40 },
};
let releaseFirstSnapshot: (() => void) | null = null;
let firstSnapshotStarted: (() => void) | null = null;
const firstSnapshot = new Promise<void>((resolve) => { firstSnapshotStarted = resolve; });
const firstSnapshotRelease = new Promise<void>((resolve) => { releaseFirstSnapshot = resolve; });
let waitForFirstSnapshot = options.pauseFirstSnapshot === true;
function itemFor(key: keyof typeof state) {
const itemState = state[key];
return {
getStartTime: vi.fn(async () => ({ seconds: itemState.start })),
getEndTime: vi.fn(async () => ({ seconds: itemState.end })),
getInPoint: vi.fn(async () => {
if (key === "target" && waitForFirstSnapshot) {
waitForFirstSnapshot = false;
firstSnapshotStarted?.();
await firstSnapshotRelease;
}
return { seconds: itemState.inPoint };
}),
getOutPoint: vi.fn(async () => ({ seconds: itemState.outPoint })),
getDuration: vi.fn(async () => ({ seconds: itemState.end - itemState.start })),
getSpeed: vi.fn(async () => 1),
isSpeedReversed: vi.fn(async () => false),
createMoveAction: vi.fn((time: { seconds: number }) => ({ apply: () => { itemState.start += time.seconds; itemState.end += time.seconds; } })),
createSetStartAction: vi.fn((time: { seconds: number }) => ({ apply: () => { itemState.start = time.seconds; } })),
createSetEndAction: vi.fn((time: { seconds: number }) => ({ apply: () => { itemState.end = time.seconds; } })),
createSetInPointAction: vi.fn((time: { seconds: number }) => ({ apply: () => { itemState.inPoint = time.seconds; } })),
createSetOutPointAction: vi.fn((time: { seconds: number }) => ({ apply: () => { itemState.outPoint = time.seconds; } })),
};
}
const previous = itemFor("previous"), target = itemFor("target"), following = itemFor("following");
const videoTrack = { getTrackItems: vi.fn(async () => [previous, target, following]) };
const sequence = {
guid: "sequence-1",
getVideoTrackCount: vi.fn(async () => 1), getVideoTrack: vi.fn(async () => videoTrack),
getAudioTrackCount: vi.fn(async () => 0), getAudioTrack: vi.fn(async () => null),
};
const addAction = vi.fn((action: { apply: () => void }) => { action.apply(); return true; });
const project = {
guid: "project-1", getActiveSequence: vi.fn(async () => sequence), lockedAccess: vi.fn((callback: () => void) => callback()),
executeTransaction: vi.fn((callback: (compound: { addAction: typeof addAction }) => void) => { callback({ addAction }); return true; }),
};
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) }, TickTime: { createWithSeconds: vi.fn((seconds: number) => ({ seconds })) },
Constants: { TrackItemType: { CLIP: 1 } },
};
return { state, previous, target, following, project, firstSnapshot, releaseFirstSnapshot: () => releaseFirstSnapshot?.(), registry: Commands.createCommandRegistry({ ppro, Protocol }) };
}
const 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 },
};
const targetCoordinates = { mediaType: "video", trackIndex: 0, clipIndex: 1 };
describe("guarded documented UXP track-item slide workflow", () => {
it("advertises bounded inspection and a replay-safe undoable transaction command", async () => {
const value = slideHost();
await expect(value.registry.capabilities()).resolves.toMatchObject({ commands: {
"trackItem.slide.inspect": { supported: true, readOnly: true, minHostVersion: "25.6.0" },
"trackItem.slide": { supported: true, destructive: true, undoable: true, idempotent: true, targetCapabilityProbe: "invocation" },
} });
await expect(value.registry.dispatch("trackItem.slide.inspect", targetCoordinates)).resolves.toEqual(expectedSnapshot);
expect(value.project.executeTransaction).not.toHaveBeenCalled();
});
it("slides a complete reviewed triplet in one transaction and reads every timeline and source boundary back", async () => {
const value = slideHost();
await expect(value.registry.dispatch("trackItem.slide", {
...targetCoordinates, expectedSnapshot, slideBySeconds: 2, confirmSlide: true, operationId: "slide-apply-1",
})).resolves.toMatchObject({
operationId: "slide-apply-1", slid: true, outcome: "verified", before: expectedSnapshot,
after: {
...expectedSnapshot,
previous: { ...expectedSnapshot.previous, endSeconds: 12, outSeconds: 12, durationSeconds: 12 },
target: { ...expectedSnapshot.target, startSeconds: 12, endSeconds: 22 },
following: { ...expectedSnapshot.following, startSeconds: 22, inSeconds: 32, durationSeconds: 8 },
},
verificationBoundary: "three_track_item_source_and_timeline_readback",
});
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.project.executeTransaction.mock.calls[0]?.[1]).toBe("Slide timeline item");
expect(value.target.createMoveAction).toHaveBeenCalledWith({ seconds: 2 });
expect(value.previous.createSetEndAction).toHaveBeenCalledWith({ seconds: 12 });
expect(value.previous.createSetOutPointAction).toHaveBeenCalledWith({ seconds: 12 });
expect(value.following.createSetStartAction).toHaveBeenCalledWith({ seconds: 22 });
expect(value.following.createSetInPointAction).toHaveBeenCalledWith({ seconds: 32 });
expect(value.state).toEqual({
previous: { start: 0, end: 12, inPoint: 0, outPoint: 12 },
target: { start: 12, end: 22, inPoint: 20, outPoint: 30 },
following: { start: 22, end: 30, inPoint: 32, outPoint: 40 },
});
});
it("rejects incomplete confirmation/replay/stale/edge requests before action creation", async () => {
const value = slideHost();
await expect(value.registry.dispatch("trackItem.slide", { ...targetCoordinates, expectedSnapshot, slideBySeconds: 1, operationId: "missing-confirmation" })).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
await expect(value.registry.dispatch("trackItem.slide", { ...targetCoordinates, expectedSnapshot, slideBySeconds: 1, confirmSlide: true })).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("trackItem.slide", {
...targetCoordinates, expectedSnapshot: { ...expectedSnapshot, following: { ...expectedSnapshot.following, inSeconds: 31 } }, slideBySeconds: 1, confirmSlide: true, operationId: "stale",
})).rejects.toMatchObject({ code: "UXP_STALE_TRACK_ITEM" });
await expect(value.registry.dispatch("trackItem.slide", { ...targetCoordinates, expectedSnapshot, slideBySeconds: 10, confirmSlide: true, operationId: "zero-following" })).rejects.toMatchObject({ code: "UXP_TARGET_UNSUPPORTED" });
await expect(value.registry.dispatch("trackItem.slide.inspect", { mediaType: "video", trackIndex: 0, clipIndex: 0 })).rejects.toMatchObject({ code: "UXP_TARGET_UNSUPPORTED" });
expect(value.project.executeTransaction).not.toHaveBeenCalled();
});
it("replays a completed operation ID without creating a second host transaction", async () => {
const value = slideHost();
const args = { ...targetCoordinates, expectedSnapshot, slideBySeconds: -2, confirmSlide: true, operationId: "replay-slide" };
await expect(value.registry.dispatch("trackItem.slide", args)).resolves.toMatchObject({ outcome: "verified" });
await expect(value.registry.dispatch("trackItem.slide", args)).resolves.toMatchObject({ outcome: "verified", replayed: true });
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.state.target).toEqual({ start: 8, end: 18, inPoint: 20, outPoint: 30 });
});
it("serializes different operation IDs through the complete triplet preflight so a stale request cannot trim the wrong neighbours", async () => {
const value = slideHost({ pauseFirstSnapshot: true });
const first = value.registry.dispatch("trackItem.slide", { ...targetCoordinates, expectedSnapshot, slideBySeconds: 1, confirmSlide: true, operationId: "concurrent-first" });
await value.firstSnapshot;
const second = value.registry.dispatch("trackItem.slide", { ...targetCoordinates, expectedSnapshot, slideBySeconds: -1, confirmSlide: true, operationId: "concurrent-second" });
const secondExpectation = expect(second).rejects.toMatchObject({ code: "UXP_STALE_TRACK_ITEM" });
value.releaseFirstSnapshot();
await expect(first).resolves.toMatchObject({ outcome: "verified" });
await secondExpectation;
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.state).toEqual({
previous: { start: 0, end: 11, inPoint: 0, outPoint: 11 }, target: { start: 11, end: 21, inPoint: 20, outPoint: 30 }, following: { start: 21, end: 30, inPoint: 31, outPoint: 40 },
});
});
it("shares the track lock with slips so a concurrent source-only edit cannot bypass the slide snapshot", async () => {
const value = slideHost({ pauseFirstSnapshot: true });
const slide = value.registry.dispatch("trackItem.slide", { ...targetCoordinates, expectedSnapshot, slideBySeconds: 1, confirmSlide: true, operationId: "slide-before-slip" });
await value.firstSnapshot;
const slip = value.registry.dispatch("trackItem.slip", {
...targetCoordinates,
expectedSnapshot: { projectGuid: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0, clipIndex: 1, startSeconds: 10, endSeconds: 20, inSeconds: 20, outSeconds: 30, durationSeconds: 10, speed: 1, reversed: false },
slipBySeconds: 1, confirmSlip: true, operationId: "slip-after-slide",
});
const slipExpectation = expect(slip).rejects.toMatchObject({ code: "UXP_STALE_TRACK_ITEM" });
value.releaseFirstSnapshot();
await expect(slide).resolves.toMatchObject({ outcome: "verified" });
await slipExpectation;
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
});
});
+172
View File
@@ -0,0 +1,172 @@
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
type SlipHostOptions = {
reverse?: boolean;
speed?: number;
pauseFirstSnapshot?: boolean;
};
function slipHost(options: SlipHostOptions = {}) {
const state = { start: 10, end: 20, inPoint: 30, outPoint: 40 };
let releaseFirstSnapshot: (() => void) | null = null;
let firstSnapshotStarted: (() => void) | null = null;
const firstSnapshot = new Promise<void>((resolve) => { firstSnapshotStarted = resolve; });
const firstSnapshotRelease = new Promise<void>((resolve) => { releaseFirstSnapshot = resolve; });
let waitForFirstSnapshot = options.pauseFirstSnapshot === true;
const item = {
getStartTime: vi.fn(async () => ({ seconds: state.start })),
getEndTime: vi.fn(async () => ({ seconds: state.end })),
getInPoint: vi.fn(async () => {
if (waitForFirstSnapshot) {
waitForFirstSnapshot = false;
firstSnapshotStarted?.();
await firstSnapshotRelease;
}
return { seconds: state.inPoint };
}),
getOutPoint: vi.fn(async () => ({ seconds: state.outPoint })),
getDuration: vi.fn(async () => ({ seconds: state.end - state.start })),
getSpeed: vi.fn(async () => options.speed ?? 1),
isSpeedReversed: vi.fn(async () => options.reverse ?? false),
createSetInPointAction: vi.fn((time: { seconds: number }) => ({ apply: () => { state.inPoint = time.seconds; } })),
createSetOutPointAction: vi.fn((time: { seconds: number }) => ({ apply: () => { state.outPoint = time.seconds; } })),
};
const videoTrack = { getTrackItems: vi.fn(async () => [item]) };
const sequence = {
guid: "sequence-1",
getVideoTrackCount: vi.fn(async () => 1),
getVideoTrack: vi.fn(async () => videoTrack),
getAudioTrackCount: vi.fn(async () => 0),
getAudioTrack: vi.fn(async () => null),
};
const addAction = vi.fn((action: { apply: () => void }) => { action.apply(); return true; });
const project = {
guid: "project-1",
getActiveSequence: vi.fn(async () => sequence),
lockedAccess: vi.fn((callback: () => void) => callback()),
executeTransaction: vi.fn((callback: (compound: { addAction: typeof addAction }) => void) => {
callback({ addAction });
return true;
}),
};
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) },
TickTime: { createWithSeconds: vi.fn((seconds: number) => ({ seconds })) },
Constants: { TrackItemType: { CLIP: 1 } },
};
return {
state,
item,
project,
firstSnapshot,
releaseFirstSnapshot: () => releaseFirstSnapshot?.(),
registry: Commands.createCommandRegistry({ ppro, Protocol }),
};
}
const expectedSnapshot = {
projectGuid: "project-1",
sequenceId: "sequence-1",
mediaType: "video",
trackIndex: 0,
clipIndex: 0,
startSeconds: 10,
endSeconds: 20,
inSeconds: 30,
outSeconds: 40,
durationSeconds: 10,
speed: 1,
reversed: false,
};
const target = { mediaType: "video", trackIndex: 0, clipIndex: 0 };
describe("guarded documented UXP track-item slip workflow", () => {
it("advertises separate bounded inspection and one-transaction mutation commands", async () => {
const value = slipHost();
await expect(value.registry.capabilities()).resolves.toMatchObject({
commands: {
"trackItem.slip.inspect": { supported: true, readOnly: true, minHostVersion: "25.6.0" },
"trackItem.slip": { supported: true, destructive: true, undoable: true, idempotent: true, targetCapabilityProbe: "invocation" },
},
});
await expect(value.registry.dispatch("trackItem.slip.inspect", target)).resolves.toEqual(expectedSnapshot);
expect(value.project.executeTransaction).not.toHaveBeenCalled();
});
it("slips one reviewed forward 1x item without moving timeline timing and reads every guarded field back", async () => {
const value = slipHost();
await expect(value.registry.dispatch("trackItem.slip", {
...target,
expectedSnapshot,
slipBySeconds: 2.5,
confirmSlip: true,
operationId: "slip-apply-1",
})).resolves.toMatchObject({
operationId: "slip-apply-1",
slipped: true,
outcome: "verified",
before: expectedSnapshot,
after: { ...expectedSnapshot, inSeconds: 32.5, outSeconds: 42.5 },
verificationBoundary: "track_item_source_and_timeline_readback",
});
expect(value.state).toEqual({ start: 10, end: 20, inPoint: 32.5, outPoint: 42.5 });
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.project.executeTransaction.mock.calls[0]?.[1]).toBe("Slip timeline item source");
expect(value.item.createSetInPointAction).toHaveBeenCalledWith({ seconds: 32.5 });
expect(value.item.createSetOutPointAction).toHaveBeenCalledWith({ seconds: 42.5 });
});
it("requires a complete unchanged snapshot, confirmation, replay key, forward 1x state, and valid source bounds before action creation", async () => {
const value = slipHost();
await expect(value.registry.dispatch("trackItem.slip", {
...target, expectedSnapshot, slipBySeconds: 1, operationId: "missing-confirmation",
})).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
await expect(value.registry.dispatch("trackItem.slip", {
...target, expectedSnapshot, slipBySeconds: 1, confirmSlip: true,
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("trackItem.slip", {
...target, expectedSnapshot: { ...expectedSnapshot, endSeconds: 21 }, slipBySeconds: 1, confirmSlip: true, operationId: "stale",
})).rejects.toMatchObject({ code: "UXP_STALE_TRACK_ITEM" });
await expect(value.registry.dispatch("trackItem.slip", {
...target, expectedSnapshot, slipBySeconds: -31, confirmSlip: true, operationId: "negative-handle",
})).rejects.toMatchObject({ code: "UXP_TARGET_UNSUPPORTED" });
expect(value.project.executeTransaction).not.toHaveBeenCalled();
const reverse = slipHost({ reverse: true });
await expect(reverse.registry.dispatch("trackItem.slip", {
...target, expectedSnapshot: { ...expectedSnapshot, reversed: true }, slipBySeconds: 1, confirmSlip: true, operationId: "reverse",
})).rejects.toMatchObject({ code: "UXP_TARGET_UNSUPPORTED" });
expect(reverse.project.executeTransaction).not.toHaveBeenCalled();
});
it("replays the same operation ID without creating a second host transaction", async () => {
const value = slipHost();
const args = { ...target, expectedSnapshot, slipBySeconds: 1, confirmSlip: true, operationId: "replay-slip" };
await expect(value.registry.dispatch("trackItem.slip", args)).resolves.toMatchObject({ outcome: "verified" });
await expect(value.registry.dispatch("trackItem.slip", args)).resolves.toMatchObject({ outcome: "verified", replayed: true });
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.state).toEqual({ start: 10, end: 20, inPoint: 31, outPoint: 41 });
});
it("serializes different operation IDs through preflight, action creation, and readback so a stale later snapshot cannot slip the wrong source", async () => {
const value = slipHost({ pauseFirstSnapshot: true });
const first = value.registry.dispatch("trackItem.slip", {
...target, expectedSnapshot, slipBySeconds: 1, confirmSlip: true, operationId: "concurrent-first",
});
await value.firstSnapshot;
const second = value.registry.dispatch("trackItem.slip", {
...target, expectedSnapshot, slipBySeconds: 2, confirmSlip: true, operationId: "concurrent-second",
});
value.releaseFirstSnapshot();
await expect(first).resolves.toMatchObject({ outcome: "verified", after: { inSeconds: 31, outSeconds: 41 } });
await expect(second).rejects.toMatchObject({ code: "UXP_STALE_TRACK_ITEM" });
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.state).toEqual({ start: 10, end: 20, inPoint: 31, outPoint: 41 });
});
});
@@ -0,0 +1,122 @@
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
type HostOptions = {
mutateSecondMediaRead?: boolean;
projectItemCount?: number;
};
function provenanceHost(options: HostOptions = {}) {
let mediaPath = "C:/media/clip.mov";
let originatingProjectPath = "C:/projects/origin.prproj";
let mediaReads = 0;
const unrelated = {
getId: vi.fn(() => "unrelated-1"),
getMediaFilePath: vi.fn(() => { throw new Error("unrelated media path must not be read"); }),
getOriginatingProjectPath: vi.fn(() => { throw new Error("unrelated origin path must not be read"); }),
};
const clip = {
getId: vi.fn(() => "clip-1"),
getMediaFilePath: vi.fn(async () => {
mediaReads += 1;
if (options.mutateSecondMediaRead && mediaReads === 2) mediaPath = "C:/media/changed.mov";
return mediaPath;
}),
getOriginatingProjectPath: vi.fn(async () => originatingProjectPath),
};
const extraItems = options.projectItemCount
? Array.from({ length: options.projectItemCount }, (_, index) => ({ getId: () => "extra-" + index }))
: [];
const root = {
getId: vi.fn(() => "root-1"),
getItems: vi.fn(async () => [clip, unrelated, ...extraItems]),
};
const project = {
guid: "project-1",
getRootItem: vi.fn(async () => root),
getActiveSequence: vi.fn(async () => ({ guid: "sequence-1" })),
};
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) },
FolderItem: { cast: vi.fn((item: unknown) => item === root ? root : null) },
ClipProjectItem: { cast: vi.fn((item: unknown) => {
if (item === clip) return clip;
throw new Error("not a clip");
}) },
};
return {
clip,
unrelated,
project,
root,
registry: Commands.createCommandRegistry({ ppro, Protocol }),
};
}
const target = {
projectItemId: "clip-1",
includeMediaFilePath: true,
includeOriginatingProjectPath: true,
};
describe("bounded documented UXP source-media provenance workflow", () => {
it("double-resolves only the requested clip and reads only explicitly selected paths", async () => {
const value = provenanceHost();
await expect(value.registry.capabilities()).resolves.toMatchObject({ commands: {
"source.provenance.inspect": {
supported: true, readOnly: true, minHostVersion: "26.3.0", targetCapabilityProbe: "invocation",
},
} });
await expect(value.registry.dispatch("source.provenance.inspect", target)).resolves.toEqual({
projectGuid: "project-1",
projectItemId: "clip-1",
mediaFilePath: "C:/media/clip.mov",
originatingProjectPath: "C:/projects/origin.prproj",
});
expect(value.project.getRootItem).toHaveBeenCalledTimes(2);
expect(value.root.getItems).toHaveBeenCalledTimes(2);
expect(value.clip.getMediaFilePath).toHaveBeenCalledTimes(2);
expect(value.clip.getOriginatingProjectPath).toHaveBeenCalledTimes(2);
expect(value.unrelated.getMediaFilePath).not.toHaveBeenCalled();
expect(value.unrelated.getOriginatingProjectPath).not.toHaveBeenCalled();
});
it("does not read a path whose disclosure flag was not selected", async () => {
const value = provenanceHost();
await expect(value.registry.dispatch("source.provenance.inspect", {
projectItemId: "clip-1", includeOriginatingProjectPath: true,
})).resolves.toEqual({
projectGuid: "project-1", projectItemId: "clip-1", originatingProjectPath: "C:/projects/origin.prproj",
});
expect(value.clip.getMediaFilePath).not.toHaveBeenCalled();
expect(value.clip.getOriginatingProjectPath).toHaveBeenCalledTimes(2);
});
it("rejects missing path authority, malformed requests, oversized traversal, and non-media targets before a path getter", async () => {
const value = provenanceHost();
await expect(value.registry.dispatch("source.provenance.inspect", { projectItemId: "clip-1" }))
.rejects.toMatchObject({ code: "UXP_PATH_DISCLOSURE_REQUIRED" });
await expect(value.registry.dispatch("source.provenance.inspect", {
projectItemId: "clip-1", includeMediaFilePath: "true",
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("source.provenance.inspect", {
projectItemId: "unrelated-1", includeMediaFilePath: true,
})).rejects.toMatchObject({ code: "UXP_TARGET_NOT_MEDIA" });
await expect(provenanceHost({ projectItemCount: 4095 }).registry.dispatch("source.provenance.inspect", {
projectItemId: "clip-1", includeMediaFilePath: true,
})).rejects.toMatchObject({ code: "UXP_TARGET_TOO_LARGE" });
expect(value.clip.getMediaFilePath).not.toHaveBeenCalled();
});
it("fails closed when a selected provenance path changes between complete snapshots", async () => {
const value = provenanceHost({ mutateSecondMediaRead: true });
await expect(value.registry.dispatch("source.provenance.inspect", target))
.rejects.toMatchObject({ code: "UXP_STALE_SOURCE_PROVENANCE" });
expect(value.clip.getMediaFilePath).toHaveBeenCalledTimes(2);
expect(value.clip.getOriginatingProjectPath).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,133 @@
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
type HostOptions = {
mutateSecondProxyRead?: boolean;
projectItemCount?: number;
hasProxy?: boolean;
};
function proxyHost(options: HostOptions = {}) {
let proxyPath = "C:/proxies/clip-proxy.mov";
let proxyReads = 0;
const state = {
canChangeMediaPath: true,
isOffline: false,
canProxy: true,
hasProxy: options.hasProxy ?? true,
};
const unrelated = {
getId: vi.fn(() => "unrelated-1"),
canChangeMediaPath: vi.fn(() => { throw new Error("unrelated proxy state must not be read"); }),
isOffline: vi.fn(() => { throw new Error("unrelated proxy state must not be read"); }),
canProxy: vi.fn(() => { throw new Error("unrelated proxy state must not be read"); }),
hasProxy: vi.fn(() => { throw new Error("unrelated proxy state must not be read"); }),
getProxyPath: vi.fn(() => { throw new Error("unrelated proxy path must not be read"); }),
};
const clip = {
getId: vi.fn(() => "clip-1"),
canChangeMediaPath: vi.fn(async () => state.canChangeMediaPath),
isOffline: vi.fn(async () => state.isOffline),
canProxy: vi.fn(async () => state.canProxy),
hasProxy: vi.fn(async () => state.hasProxy),
getProxyPath: vi.fn(async () => {
proxyReads += 1;
if (options.mutateSecondProxyRead && proxyReads === 2) proxyPath = "C:/proxies/changed.mov";
return proxyPath;
}),
};
const extraItems = options.projectItemCount
? Array.from({ length: options.projectItemCount }, (_, index) => ({ getId: () => "extra-" + index }))
: [];
const root = {
getId: vi.fn(() => "root-1"),
getItems: vi.fn(async () => [clip, unrelated, ...extraItems]),
};
const project = {
guid: "project-1",
getRootItem: vi.fn(async () => root),
getActiveSequence: vi.fn(async () => ({ guid: "sequence-1" })),
};
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) },
FolderItem: { cast: vi.fn((item: unknown) => item === root ? root : null) },
ClipProjectItem: { cast: vi.fn((item: unknown) => {
if (item === clip) return clip;
throw new Error("not a clip");
}) },
};
return {
clip,
unrelated,
project,
root,
registry: Commands.createCommandRegistry({ ppro, Protocol }),
};
}
const target = { projectItemId: "clip-1", includeProxyPath: true };
describe("bounded documented UXP source-proxy inspection workflow", () => {
it("double-resolves only the requested clip and reads the proxy path only with explicit opt-in", async () => {
const value = proxyHost();
await expect(value.registry.capabilities()).resolves.toMatchObject({ commands: {
"source.proxy.inspect": {
supported: true, readOnly: true, minHostVersion: "26.3.0", targetCapabilityProbe: "invocation",
},
} });
await expect(value.registry.dispatch("source.proxy.inspect", target)).resolves.toEqual({
projectGuid: "project-1",
projectItemId: "clip-1",
canChangeMediaPath: true,
isOffline: false,
canProxy: true,
hasProxy: true,
proxyPath: "C:/proxies/clip-proxy.mov",
});
expect(value.project.getRootItem).toHaveBeenCalledTimes(2);
expect(value.root.getItems).toHaveBeenCalledTimes(2);
expect(value.clip.canChangeMediaPath).toHaveBeenCalledTimes(2);
expect(value.clip.isOffline).toHaveBeenCalledTimes(2);
expect(value.clip.canProxy).toHaveBeenCalledTimes(2);
expect(value.clip.hasProxy).toHaveBeenCalledTimes(2);
expect(value.clip.getProxyPath).toHaveBeenCalledTimes(2);
expect(value.unrelated.canChangeMediaPath).not.toHaveBeenCalled();
expect(value.unrelated.getProxyPath).not.toHaveBeenCalled();
});
it("does not query a proxy path without opt-in or when no proxy is attached", async () => {
const noOptIn = proxyHost();
await expect(noOptIn.registry.dispatch("source.proxy.inspect", { projectItemId: "clip-1" })).resolves.toMatchObject({
projectGuid: "project-1", projectItemId: "clip-1", hasProxy: true,
});
expect(noOptIn.clip.getProxyPath).not.toHaveBeenCalled();
const noProxy = proxyHost({ hasProxy: false });
await expect(noProxy.registry.dispatch("source.proxy.inspect", target)).resolves.toMatchObject({
projectGuid: "project-1", projectItemId: "clip-1", hasProxy: false,
});
expect(noProxy.clip.getProxyPath).not.toHaveBeenCalled();
});
it("rejects malformed requests, non-media targets, and oversized traversal before any target proxy getter", async () => {
const value = proxyHost();
await expect(value.registry.dispatch("source.proxy.inspect", { projectItemId: "clip-1", includeProxyPath: "true" }))
.rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("source.proxy.inspect", { projectItemId: "unrelated-1" }))
.rejects.toMatchObject({ code: "UXP_TARGET_NOT_MEDIA" });
await expect(proxyHost({ projectItemCount: 4095 }).registry.dispatch("source.proxy.inspect", { projectItemId: "clip-1" }))
.rejects.toMatchObject({ code: "UXP_TARGET_TOO_LARGE" });
expect(value.clip.canChangeMediaPath).not.toHaveBeenCalled();
});
it("fails closed when an explicitly selected proxy path changes between complete snapshots", async () => {
const value = proxyHost({ mutateSecondProxyRead: true });
await expect(value.registry.dispatch("source.proxy.inspect", target))
.rejects.toMatchObject({ code: "UXP_STALE_SOURCE_PROXY" });
expect(value.clip.getProxyPath).toHaveBeenCalledTimes(2);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
const TICKS_PER_SECOND = 254_016_000_000n;
function tickTimeHost() {
const makeTickTime = (ticks: bigint) => ({
get ticks() { return ticks.toString(); },
get seconds() { return Number(ticks) / Number(TICKS_PER_SECOND); },
add: vi.fn((other: { ticks: string }) => makeTickTime(ticks + BigInt(other.ticks))),
subtract: vi.fn((other: { ticks: string }) => makeTickTime(ticks - BigInt(other.ticks))),
multiply: vi.fn((factor: number) => makeTickTime(ticks * BigInt(factor))),
divide: vi.fn((factor: number) => makeTickTime(ticks / BigInt(factor))),
});
const createWithTicks = vi.fn((ticks: string) => makeTickTime(BigInt(ticks)));
const ppro = {
TickTime: { createWithTicks },
Project: { getActiveProject: vi.fn(async () => null) },
};
return { createWithTicks, registry: Commands.createCommandRegistry({ ppro, Protocol }) };
}
describe("bounded documented UXP TickTime arithmetic", () => {
it("advertises the independent read-only native TickTime command", async () => {
const value = tickTimeHost();
await expect(value.registry.capabilities()).resolves.toMatchObject({ commands: {
"time.tickArithmetic.inspect": { supported: true, readOnly: true, minHostVersion: "25.6.0", targetCapabilityProbe: "invocation" },
} });
});
it("returns native TickTime add and subtract readback without project access", async () => {
const value = tickTimeHost();
await expect(value.registry.dispatch("time.tickArithmetic.inspect", {
operation: "add", baseTicks: "254016000000", operandTicks: "127008000000",
})).resolves.toEqual({
operation: "add", base: { ticks: "254016000000", seconds: 1 }, operand: { ticks: "127008000000", seconds: 0.5 },
result: { ticks: "381024000000", seconds: 1.5 }, verificationBoundary: "native_tick_time_value_readback",
limitations: ["This is pure native TickTime arithmetic over caller-supplied ticks; it does not align frames, infer timecode, inspect Premiere project state, or prove a licensed host."],
});
await expect(value.registry.dispatch("time.tickArithmetic.inspect", {
operation: "subtract", baseTicks: "254016000000", operandTicks: "381024000000",
})).resolves.toMatchObject({ result: { ticks: "-127008000000", seconds: -0.5 } });
expect(value.createWithTicks).toHaveBeenCalled();
});
it("uses integer multiply and divide factors while rejecting ambiguous or unsafe inputs", async () => {
const value = tickTimeHost();
await expect(value.registry.dispatch("time.tickArithmetic.inspect", {
operation: "multiply", baseTicks: "127008000000", factor: 3,
})).resolves.toMatchObject({ factor: 3, result: { ticks: "381024000000", seconds: 1.5 } });
await expect(value.registry.dispatch("time.tickArithmetic.inspect", {
operation: "divide", baseTicks: "381024000000", factor: 3,
})).resolves.toMatchObject({ factor: 3, result: { ticks: "127008000000", seconds: 0.5 } });
await expect(value.registry.dispatch("time.tickArithmetic.inspect", {
operation: "add", baseTicks: "01", operandTicks: "1",
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("time.tickArithmetic.inspect", {
operation: "multiply", baseTicks: "1", factor: 0,
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("time.tickArithmetic.inspect", {
operation: "divide", baseTicks: "1", operandTicks: "1", factor: 2,
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
});
});
@@ -0,0 +1,131 @@
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
type HostOptions = { pauseLockedPreflight?: boolean };
function sourceLabelHost(options: HostOptions = {}) {
let colorIndex = 3;
let colorGetterCalls = 0;
let notifyLockedPreflight: (() => void) | null = null;
let releaseLockedPreflight: (() => void) | null = null;
const lockedPreflight = new Promise<void>((resolve) => { notifyLockedPreflight = resolve; });
const lockedPreflightRelease = new Promise<void>((resolve) => { releaseLockedPreflight = resolve; });
const source = {
getId: vi.fn(async () => "source-1"),
getColorLabelIndex: vi.fn(async () => {
colorGetterCalls += 1;
if (options.pauseLockedPreflight && colorGetterCalls === 2) {
notifyLockedPreflight?.();
await lockedPreflightRelease;
}
return colorIndex;
}),
createSetColorLabelAction: vi.fn((next: number) => ({ apply: () => { colorIndex = next; } })),
};
const item = {
getProjectItem: vi.fn(async () => source),
getStartTime: vi.fn(async () => ({ seconds: 12 })),
getEndTime: vi.fn(async () => ({ seconds: 20 })),
};
const videoTrack = { getTrackItems: vi.fn(async () => [item]) };
const audioTrack = { getTrackItems: vi.fn(async () => [item]) };
const sequence = {
guid: "sequence-1",
getVideoTrackCount: vi.fn(async () => 1), getVideoTrack: vi.fn(async () => videoTrack),
getAudioTrackCount: vi.fn(async () => 1), getAudioTrack: vi.fn(async () => audioTrack),
};
const addAction = vi.fn((action: { apply: () => void }) => { action.apply(); return true; });
const project = {
guid: "project-1", getActiveSequence: vi.fn(async () => sequence),
lockedAccess: vi.fn((callback: () => void) => callback()),
executeTransaction: vi.fn((callback: (compound: { addAction: typeof addAction }) => void) => { callback({ addAction }); return true; }),
};
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) },
ClipProjectItem: { cast: vi.fn((value: unknown) => value === source ? source : null) },
Constants: { TrackItemType: { CLIP: 1 } },
};
return {
project, source, get colorIndex() { return colorIndex; }, lockedPreflight,
releaseLockedPreflight: () => releaseLockedPreflight?.(),
registry: Commands.createCommandRegistry({ ppro, Protocol }),
};
}
const target = { mediaType: "video", trackIndex: 0, clipIndex: 0 };
const expectedSnapshot = {
projectGuid: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0, clipIndex: 0,
trackItemCount: 1, sourceProjectItemId: "source-1", sourceColorLabelIndex: 3, startSeconds: 12, endSeconds: 20,
};
describe("guarded documented UXP timeline source-label workflow", () => {
it("advertises bounded readback and returns an active-coordinate source snapshot without a transaction", async () => {
const value = sourceLabelHost();
await expect(value.registry.capabilities()).resolves.toMatchObject({ commands: {
"timeline.sourceLabel.inspect": { supported: true, readOnly: true, minHostVersion: "25.6.0" },
"timeline.sourceLabel.update": { supported: true, destructive: true, undoable: true, idempotent: true, targetCapabilityProbe: "invocation" },
} });
await expect(value.registry.dispatch("timeline.sourceLabel.inspect", target)).resolves.toEqual(expectedSnapshot);
await expect(value.registry.dispatch("timeline.sourceLabel.inspect", { ...target, mediaType: "audio" })).resolves.toMatchObject({ ...expectedSnapshot, mediaType: "audio" });
expect(value.project.executeTransaction).not.toHaveBeenCalled();
});
it("sets exactly the reviewed source label in one transaction and reads the coordinate back", async () => {
const value = sourceLabelHost();
await expect(value.registry.dispatch("timeline.sourceLabel.update", {
...target, colorIndex: 9, expectedSnapshot, confirmSetLabel: true, operationId: "source-label-1",
})).resolves.toMatchObject({
operationId: "source-label-1", sourceLabelUpdated: true, outcome: "verified", before: expectedSnapshot,
after: { ...expectedSnapshot, sourceColorLabelIndex: 9 },
verificationBoundary: "timeline_coordinate_source_color_label_readback", undoLabel: "Set timeline source label",
});
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.project.executeTransaction.mock.calls[0]?.[1]).toBe("Set timeline source label");
expect(value.source.createSetColorLabelAction).toHaveBeenCalledWith(9);
expect(value.colorIndex).toBe(9);
});
it("requires authority and a complete fresh snapshot before action creation", async () => {
const value = sourceLabelHost();
await expect(value.registry.dispatch("timeline.sourceLabel.update", {
...target, colorIndex: 9, expectedSnapshot, operationId: "missing-confirmation",
})).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
await expect(value.registry.dispatch("timeline.sourceLabel.update", {
...target, colorIndex: 9, expectedSnapshot, confirmSetLabel: true,
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("timeline.sourceLabel.update", {
...target, colorIndex: 9, expectedSnapshot: { ...expectedSnapshot, sourceColorLabelIndex: 2 }, confirmSetLabel: true, operationId: "stale",
})).rejects.toMatchObject({ code: "UXP_STALE_SOURCE_LABEL" });
await expect(value.registry.dispatch("timeline.sourceLabel.inspect", { ...target, clipIndex: 1 })).rejects.toMatchObject({ code: "UXP_TARGET_NOT_FOUND" });
expect(value.project.executeTransaction).not.toHaveBeenCalled();
});
it("replays an accepted operation ID without a second host transaction", async () => {
const value = sourceLabelHost();
const args = { ...target, colorIndex: 9, expectedSnapshot, confirmSetLabel: true, operationId: "source-label-replay" };
await expect(value.registry.dispatch("timeline.sourceLabel.update", args)).resolves.toMatchObject({ outcome: "verified" });
await expect(value.registry.dispatch("timeline.sourceLabel.update", args)).resolves.toMatchObject({ outcome: "verified", replayed: true });
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
});
it("serializes distinct operation IDs so a second reviewed label cannot overwrite the first", async () => {
const value = sourceLabelHost({ pauseLockedPreflight: true });
const first = value.registry.dispatch("timeline.sourceLabel.update", {
...target, colorIndex: 9, expectedSnapshot, confirmSetLabel: true, operationId: "source-label-first",
});
await value.lockedPreflight;
const second = value.registry.dispatch("timeline.sourceLabel.update", {
...target, colorIndex: 11, expectedSnapshot, confirmSetLabel: true, operationId: "source-label-second",
});
const secondExpectation = expect(second).rejects.toMatchObject({ code: "UXP_STALE_SOURCE_LABEL" });
value.releaseLockedPreflight();
await expect(first).resolves.toMatchObject({ outcome: "verified", after: { sourceColorLabelIndex: 9 } });
await secondExpectation;
expect(value.project.executeTransaction).toHaveBeenCalledTimes(1);
expect(value.colorIndex).toBe(9);
});
});
@@ -0,0 +1,188 @@
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
const Transcript = require("../../uxp-plugin/transcript.cjs");
const TranscriptImport = require("../../uxp-plugin/transcript-import.cjs");
const beforeJson = '{"segments":[{"text":"before"}]}';
const replacementJson = '{"segments":[{"text":"after"}]}';
function revision(json: string) {
return `sha256:${createHash("sha256").update(json, "utf8").digest("hex")}`;
}
function fixture(options: { initialJson?: string | null; apply?: boolean; itemCount?: number; readbackFailure?: boolean } = {}) {
const state = { json: options.initialJson === undefined ? beforeJson : options.initialJson };
const clip = { kind: "clip", getId: vi.fn(async () => "clip-1") };
const filler = Array.from({ length: options.itemCount ?? 1 }, (_, index) => ({
kind: "clip", getId: vi.fn(async () => index === (options.itemCount ?? 1) - 1 ? "clip-1" : `other-${index}`),
}));
const root = { kind: "folder", getItems: vi.fn(async () => filler) };
if ((options.itemCount ?? 1) === 1) filler[0] = clip;
const addAction = vi.fn((action: { apply?: () => void }) => {
action.apply?.();
return true;
});
const project = {
guid: "project-1",
getRootItem: vi.fn(async () => root),
lockedAccess: vi.fn((work: () => void) => work()),
executeTransaction: vi.fn((work: (compound: { addAction: typeof addAction }) => void) => {
work({ addAction });
return true;
}),
};
let exportCalls = 0;
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) },
FolderItem: { cast: vi.fn((item: { kind?: string }) => item.kind === "folder" ? item : null) },
ClipProjectItem: { cast: vi.fn((item: { kind?: string }) => item.kind === "clip" ? item : null) },
Transcript: {
hasTranscript: vi.fn(() => state.json !== null),
exportToJSON: vi.fn(async () => {
exportCalls += 1;
if (options.readbackFailure && exportCalls > 2) throw new Error("post-commit export unavailable");
if (state.json === null) throw new Error("no transcript");
return state.json;
}),
importFromJSON: vi.fn((json: string) => ({ json })),
createImportTextSegmentsAction: vi.fn((segments: { json: string }) => ({
apply: () => { if (options.apply !== false) state.json = segments.json; },
})),
},
};
const runtime = TranscriptImport.createTranscriptImportRuntime({ ppro, TranscriptSupport: Transcript, Protocol });
return { state, clip, project, ppro, runtime, addAction };
}
function args(overrides: Record<string, unknown> = {}) {
return {
projectItemId: "clip-1",
expectedProjectGuid: "project-1",
expectedTranscriptRevision: revision(beforeJson),
json: replacementJson,
confirmDestructive: true,
operationId: "transcript-import-1",
...overrides,
};
}
describe("guarded UXP transcript import", () => {
it("matches Node's UTF-8 SHA-256 revision for non-ASCII transcript text", () => {
const json = '{"segments":[{"text":"café 🎬"}]}';
expect(Transcript.transcriptRevision(json)).toBe(revision(json));
expect(Transcript.utf8ByteLength(json)).toBe(Buffer.byteLength(json, "utf8"));
const unpairedSurrogate = "transcript-" + String.fromCharCode(0xd800);
expect(Transcript.transcriptRevision(unpairedSurrogate)).toBe(revision(unpairedSurrogate));
expect(Transcript.utf8ByteLength(unpairedSurrogate)).toBe(Buffer.byteLength(unpairedSurrogate, "utf8"));
});
it("imports one exact clip in one transaction and verifies exact export readback", async () => {
const value = fixture();
await expect(value.runtime.importTranscript(args())).resolves.toMatchObject({
committed: true,
verified: true,
outcome: "verified",
projectGuid: "project-1",
projectItemId: "clip-1",
requestedTranscriptRevision: revision(replacementJson),
verificationBoundary: "transcript_export_exact_readback",
operation: { verification: { status: "verified", boundary: "transcript_export_exact_readback" } },
});
expect(value.state.json).toBe(replacementJson);
expect(value.project.lockedAccess).toHaveBeenCalledOnce();
expect(value.project.executeTransaction).toHaveBeenCalledOnce();
expect(value.addAction).toHaveBeenCalledOnce();
});
it("permits null only for an currently untranscribed exact clip", async () => {
const value = fixture({ initialJson: null });
await expect(value.runtime.importTranscript(args({ expectedTranscriptRevision: null }))).resolves.toMatchObject({
committed: true, verified: true, requestedTranscriptRevision: revision(replacementJson),
});
expect(value.state.json).toBe(replacementJson);
});
it("rejects a stale preflight before creating an action", async () => {
const value = fixture({ initialJson: replacementJson });
await expect(value.runtime.importTranscript(args())).rejects.toMatchObject({ code: "UXP_STALE_TRANSCRIPT" });
expect(value.project.executeTransaction).not.toHaveBeenCalled();
expect(value.ppro.Transcript.createImportTextSegmentsAction).not.toHaveBeenCalled();
});
it("serializes distinct operation IDs so a stale concurrent import cannot overwrite the first", async () => {
const value = fixture();
const results = await Promise.allSettled([
value.runtime.importTranscript(args({ operationId: "transcript-first" })),
value.runtime.importTranscript(args({ operationId: "transcript-second", json: '{"segments":[{"text":"wrong"}]}' })),
]);
expect(results).toEqual([
expect.objectContaining({ status: "fulfilled", value: expect.objectContaining({ verified: true }) }),
expect.objectContaining({ status: "rejected", reason: expect.objectContaining({ code: "UXP_STALE_TRANSCRIPT" }) }),
]);
expect(value.state.json).toBe(replacementJson);
expect(value.project.executeTransaction).toHaveBeenCalledOnce();
});
it("returns committed_unverified when the committed import cannot be proven by readback", async () => {
const value = fixture({ apply: false });
await expect(value.runtime.importTranscript(args())).resolves.toMatchObject({
committed: true,
verified: false,
outcome: "committed_unverified",
verificationBoundary: "transcript_transaction_commit_with_readback_mismatch",
operation: { verification: { status: "committed_unverified" } },
});
expect(value.project.executeTransaction).toHaveBeenCalledOnce();
});
it("does not turn a committed transaction into verified when post-commit export throws", async () => {
const value = fixture({ readbackFailure: true });
await expect(value.runtime.importTranscript(args())).resolves.toMatchObject({
committed: true,
verified: false,
outcome: "committed_unverified",
verificationBoundary: "transcript_transaction_commit_with_readback_unavailable",
readbackError: "post-commit export unavailable",
operation: { verification: { status: "committed_unverified" } },
});
expect(value.project.executeTransaction).toHaveBeenCalledOnce();
});
it("requires confirmation and rejects a project search beyond the bounded item limit", async () => {
const confirmation = fixture();
await expect(confirmation.runtime.importTranscript(args({ confirmDestructive: false }))).rejects.toMatchObject({ code: "UXP_CONFIRMATION_REQUIRED" });
expect(confirmation.project.getRootItem).not.toHaveBeenCalled();
const oversized = fixture({ itemCount: 513 });
await expect(oversized.runtime.importTranscript(args())).rejects.toMatchObject({ code: "UXP_PROJECT_TOO_LARGE" });
expect(oversized.project.executeTransaction).not.toHaveBeenCalled();
});
it("uses the command registry's operation-id replay without a second transaction", async () => {
const value = fixture();
const registry = Commands.createCommandRegistry({
ppro: value.ppro,
Protocol,
transcriptImportHandler: value.runtime.importTranscript,
transcriptImportProbe: value.runtime.canImportTranscript,
});
const input = args({ operationId: "transcript-replay" });
const first = await registry.dispatch("transcript.import", input);
const replay = await registry.dispatch("transcript.import", input);
expect(first).toMatchObject({ verified: true, operationId: "transcript-replay" });
expect(replay).toMatchObject({ verified: true, replayed: true, operationId: "transcript-replay" });
expect(value.project.executeTransaction).toHaveBeenCalledOnce();
});
});
+68
View File
@@ -0,0 +1,68 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const transcript = require("../../uxp-plugin/transcript.cjs");
describe("UXP transcript helpers", () => {
it("validates and parses Premiere transcript JSON", () => {
expect(transcript.parseTranscriptJSON('{"segments":[]}')).toEqual({ segments: [] });
expect(() => transcript.parseTranscriptJSON("")).toThrow("non-empty");
expect(() => transcript.parseTranscriptJSON("{")).toThrow("invalid");
});
it("searches nested strings without assuming a transcript schema", () => {
const raw = JSON.stringify({
language: "en",
segments: [
{ speaker: "A", text: "Ship the launch date today" },
{ words: [{ value: "LAUNCH DATE" }] },
],
});
const result = transcript.searchTranscriptJSON(raw, "launch date", { maxResults: 10 });
expect(result.matches).toHaveLength(2);
expect(result.matches[0]).toMatchObject({ path: "$.segments[0].text", index: 9 });
expect(result.matches[1].path).toBe("$.segments[1].words[0].value");
});
it("supports case-sensitive search and bounded results", () => {
const raw = JSON.stringify({ text: "Cut cut cut cut" });
expect(transcript.searchTranscriptJSON(raw, "Cut", { caseSensitive: true }).matches).toHaveLength(1);
expect(transcript.searchTranscriptJSON(raw, "cut", { maxResults: 2 })).toMatchObject({
limited: true,
matches: [{ index: 0 }, { index: 4 }],
});
expect(() => transcript.searchTranscriptJSON(raw, "cut", { maxResults: 0 })).toThrow("between 1 and 500");
});
it("compares Premiere versions numerically", () => {
expect(transcript.versionAtLeast("25.6.0", "25.6.0")).toBe(true);
expect(transcript.versionAtLeast("26.3", "25.6.0")).toBe(true);
expect(transcript.versionAtLeast("25.5.9", "25.6.0")).toBe(false);
});
it("continues past a same-name non-clip but fails an exact non-clip ID", () => {
const folder = { name: "Interview" };
const clip = { name: "Interview", kind: "clip" };
const cast = (item: typeof folder | typeof clip) => {
if (!("kind" in item)) throw new Error("not a clip");
return item;
};
expect(transcript.matchingClipCandidate(folder, "folder-1", "", "Interview", cast)).toEqual({
matched: true,
clip: null,
});
expect(transcript.matchingClipCandidate(clip, "clip-1", "", "Interview", cast).clip).toBe(clip);
expect(() => transcript.matchingClipCandidate(folder, "folder-1", "folder-1", "", cast)).toThrow("not a clip");
});
it("propagates export-probe failures instead of reporting transcript absence", async () => {
await expect(transcript.probeTranscriptExport(async () => "")).resolves.toBe(false);
await expect(transcript.probeTranscriptExport(async () => '{"segments":[]}')).resolves.toBe(true);
await expect(
transcript.probeTranscriptExport(async () => {
throw new Error("offline media");
}),
).rejects.toThrow("offline media");
});
});
+225
View File
@@ -0,0 +1,225 @@
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import {
UxpBridgeError,
UxpWebSocketBridge,
} from "../../src/bridge/uxp-websocket-bridge.js";
const TOKEN = "test-token-at-least-16-characters";
const bridges: UxpWebSocketBridge[] = [];
async function createBridge(options: {
requestTimeoutMs?: number;
handshakeTimeoutMs?: number;
} = {}) {
const bridge = new UxpWebSocketBridge({
token: TOKEN,
port: 0,
...options,
});
bridges.push(bridge);
await bridge.start();
return bridge;
}
function bridgeUrl(bridge: UxpWebSocketBridge, token = TOKEN): string {
const address = bridge.address();
return `ws://${address.host}:${address.port}${address.path}?token=${encodeURIComponent(token)}`;
}
async function connectHost(
bridge: UxpWebSocketBridge,
commands: Record<string, { supported: boolean }> = {
"state.get": { supported: true },
"frame.export": { supported: true },
},
) {
const client = new WebSocket(bridgeUrl(bridge));
await once(client, "open");
const connected = once(bridge, "connected");
client.send(JSON.stringify({
protocolVersion: 1,
type: "hello",
payload: {
backend: "uxp",
protocolVersion: 1,
commands,
},
}));
await connected;
return client;
}
afterEach(async () => {
await Promise.all(bridges.splice(0).map((bridge) => bridge.stop()));
});
describe("UXP WebSocket bridge", () => {
it("binds only to IPv4 loopback and requires the configured token", async () => {
const bridge = await createBridge();
expect(bridge.address()).toMatchObject({ host: "127.0.0.1", path: "/uxp" });
const unauthorized = new WebSocket(bridgeUrl(bridge, "wrong-token"));
unauthorized.on("error", () => {});
const [, response] = await once(unauthorized, "unexpected-response");
expect(response.statusCode).toBe(401);
response.resume();
expect(bridge.getState()).toEqual({ status: "listening", connected: false });
});
it("validates the versioned hello before reporting capabilities", async () => {
const bridge = await createBridge();
const client = await connectHost(bridge);
expect(bridge.getState()).toMatchObject({
status: "connected",
connected: true,
protocolVersion: 1,
capabilities: {
backend: "uxp",
commands: { "state.get": { supported: true } },
},
});
client.close();
});
it("correlates concurrent command results by request id", async () => {
const bridge = await createBridge();
const client = await connectHost(bridge);
const commands: any[] = [];
client.on("message", (data) => {
const message = JSON.parse(data.toString());
commands.push(message);
if (commands.length !== 2) return;
for (const command of [...commands].reverse()) {
client.send(JSON.stringify({
protocolVersion: 1,
type: "result",
requestId: command.requestId,
payload: { ok: true, result: { command: command.command } },
}));
}
});
const [state, frame] = await Promise.all([
bridge.request("state.get"),
bridge.request("frame.export", { filename: "frame.png" }),
]);
expect(state).toEqual({ command: "state.get" });
expect(frame).toEqual({ command: "frame.export" });
client.close();
});
it("rejects unsupported commands before sending them", async () => {
const bridge = await createBridge();
const client = await connectHost(bridge, { "state.get": { supported: true } });
await expect(bridge.request("frame.export")).rejects.toMatchObject({
code: "UXP_COMMAND_UNSUPPORTED",
});
client.close();
});
it("times out requests and rejects in-flight work on disconnect", async () => {
const bridge = await createBridge({ requestTimeoutMs: 30 });
const client = await connectHost(bridge);
await expect(bridge.request("state.get")).rejects.toMatchObject({
code: "UXP_TIMEOUT",
});
const pending = bridge.request("state.get");
client.close();
await expect(pending).rejects.toMatchObject({ code: "UXP_DISCONNECTED" });
});
it("honors a per-request minimum timeout for bounded host waits", async () => {
const bridge = await createBridge({ requestTimeoutMs: 20 });
const client = await connectHost(bridge);
client.once("message", (data) => {
const command = JSON.parse(data.toString());
setTimeout(() => {
client.send(JSON.stringify({
protocolVersion: 1,
type: "result",
requestId: command.requestId,
payload: { ok: true, result: { waited: true } },
}));
}, 40);
});
await expect(bridge.request("state.get", {}, { minimumTimeoutMs: 100 }))
.resolves.toEqual({ waited: true });
client.close();
});
it("rejects invalid per-request minimum timeouts before dispatch", async () => {
const bridge = await createBridge();
const client = await connectHost(bridge);
await expect(bridge.request("state.get", {}, { minimumTimeoutMs: -1 }))
.rejects.toThrow("non-negative integer");
await expect(bridge.request("state.get", {}, { minimumTimeoutMs: 1.5 }))
.rejects.toThrow("non-negative integer");
client.close();
});
it("rejects invalid configuration without opening a listener", () => {
expect(() => new UxpWebSocketBridge({ token: "short" })).toThrow(
"at least 16 characters",
);
expect(() => new UxpWebSocketBridge({
token: TOKEN,
port: 70_000,
})).toThrow("between 0 and 65535");
expect(new UxpBridgeError("CODE", "message")).toMatchObject({
code: "CODE",
message: "message",
});
expect(() => new UxpWebSocketBridge({ token: TOKEN, path: "uxp" }))
.toThrow("must begin with '/'");
});
it("forwards host events and command failures", async () => {
const bridge = await createBridge();
const client = await connectHost(bridge);
const event = once(bridge, "event");
client.send(JSON.stringify({ protocolVersion: 1, type: "event", payload: { kind: "projectChanged" } }));
await expect(event).resolves.toEqual([{ kind: "projectChanged" }]);
client.once("message", (data) => {
const command = JSON.parse(data.toString());
client.send(JSON.stringify({
protocolVersion: 1, type: "result", requestId: command.requestId,
payload: { ok: false, error: { code: "HOST_BUSY", message: "Dialog open" } },
}));
});
await expect(bridge.request("state.get")).rejects.toMatchObject({ code: "HOST_BUSY", message: "Dialog open" });
client.close();
});
it("rejects requests while stopped and rejects work when a host reconnects", async () => {
const bridge = await createBridge();
await expect(bridge.request("state.get")).rejects.toMatchObject({ code: "UXP_NOT_CONNECTED" });
const first = await connectHost(bridge);
const pending = bridge.request("state.get");
const rejection = expect(pending).rejects.toMatchObject({ code: "UXP_RECONNECTED" });
const second = await connectHost(bridge);
await rejection;
first.on("error", () => {});
second.close();
});
it("closes clients that send invalid JSON or an invalid handshake", async () => {
const bridge = await createBridge();
const invalidJson = new WebSocket(bridgeUrl(bridge));
await once(invalidJson, "open");
invalidJson.send("{");
const [jsonCode] = await once(invalidJson, "close");
expect(jsonCode).toBe(1007);
const invalidHello = new WebSocket(bridgeUrl(bridge));
await once(invalidHello, "open");
invalidHello.send(JSON.stringify({ protocolVersion: 99, type: "hello", payload: { backend: "uxp", protocolVersion: 99, commands: {} } }));
const [helloCode] = await once(invalidHello, "close");
expect(helloCode).toBe(1008);
});
});
@@ -0,0 +1,126 @@
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Commands = require("../../uxp-plugin/commands.cjs");
const Protocol = require("../../uxp-plugin/protocol.cjs");
type Options = {
replaceItemOnSecondRootRead?: boolean;
getRootItem?: boolean;
};
function nativeGuid(value: string) {
return { toString: vi.fn(() => value) };
}
function uniqueIdentityHost(options: Options = {}) {
const originalItem = { uniqueId: "unique-item-1", getId: vi.fn(async () => "item-1") };
const replacementItem = { uniqueId: "unique-item-2", getId: vi.fn(async () => "item-1") };
let currentItem = originalItem;
const root = {
getId: vi.fn(async () => "root"),
getItems: vi.fn(async () => [currentItem]),
};
const sequence = { guid: nativeGuid("sequence-1"), uniqueId: "unique-sequence-1" };
let rootReads = 0;
const project = {
guid: nativeGuid("project-1"),
getActiveSequence: vi.fn(async () => null),
getRootItem: vi.fn(async () => {
rootReads += 1;
if (options.replaceItemOnSecondRootRead && rootReads > 1) currentItem = replacementItem;
return root;
}),
getSequence: vi.fn(async (guid: { toString: () => string }) =>
guid.toString() === "sequence-1" ? sequence : null),
};
const ppro = {
Project: { getActiveProject: vi.fn(async () => project) },
Guid: { fromString: vi.fn((value: string) => nativeGuid(value)) },
UniqueSerializeable: {
cast: vi.fn((target: { uniqueId?: string }) => ({
getUniqueID: vi.fn(async () => nativeGuid(target.uniqueId || "")),
})),
},
};
return {
root,
project,
ppro,
registry: Commands.createCommandRegistry({ ppro, Protocol }),
};
}
describe("bounded documented UXP unique serializable identity inspection", () => {
it("advertises a read-only target-probed command and double-reads one project-item identity", async () => {
const value = uniqueIdentityHost();
await expect(value.registry.capabilities()).resolves.toMatchObject({ commands: {
"object.uniqueIdentity.inspect": {
supported: true,
readOnly: true,
minHostVersion: "25.6.0",
targetCapabilityProbe: "invocation",
},
} });
await expect(value.registry.dispatch("object.uniqueIdentity.inspect", {
projectItemId: "item-1",
expectedProjectGuid: "project-1",
expectedUniqueId: "unique-item-1",
})).resolves.toEqual({
projectGuid: "project-1",
target: { kind: "project_item", projectItemId: "item-1", uniqueId: "unique-item-1" },
verificationBoundary: "bounded_unique_serializable_double_readback",
});
expect(value.project.getRootItem).toHaveBeenCalledTimes(2);
expect(value.ppro.UniqueSerializeable.cast).toHaveBeenCalledTimes(2);
});
it("resolves an exact sequence GUID without traversing the project tree", async () => {
const value = uniqueIdentityHost();
await expect(value.registry.dispatch("object.uniqueIdentity.inspect", {
sequenceGuid: "sequence-1",
})).resolves.toMatchObject({
projectGuid: "project-1",
target: { kind: "sequence", sequenceGuid: "sequence-1", uniqueId: "unique-sequence-1" },
});
expect(value.project.getRootItem).not.toHaveBeenCalled();
expect(value.project.getSequence).toHaveBeenCalledTimes(2);
expect(value.ppro.Guid.fromString).toHaveBeenCalledTimes(2);
});
it("rejects ambiguous, stale, unsupported, and oversized requests", async () => {
const value = uniqueIdentityHost();
await expect(value.registry.dispatch("object.uniqueIdentity.inspect", {}))
.rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("object.uniqueIdentity.inspect", {
projectItemId: "item-1", sequenceGuid: "sequence-1",
})).rejects.toMatchObject({ code: "UXP_INVALID_ARGUMENT" });
await expect(value.registry.dispatch("object.uniqueIdentity.inspect", {
projectItemId: "item-1", expectedUniqueId: "different",
})).rejects.toMatchObject({ code: "UXP_STALE_UNIQUE_IDENTITY" });
const tooLarge = uniqueIdentityHost();
tooLarge.root.getItems.mockResolvedValueOnce(Array.from({ length: 512 }, (_, index) => ({
getId: vi.fn(async () => `item-${index}`),
})));
await expect(tooLarge.registry.dispatch("object.uniqueIdentity.inspect", { projectItemId: "missing" }))
.rejects.toMatchObject({ code: "UXP_PROJECT_TOO_LARGE" });
});
it("fails closed when a retained project-item ID resolves to a replacement during the second read", async () => {
const value = uniqueIdentityHost({ replaceItemOnSecondRootRead: true });
await expect(value.registry.dispatch("object.uniqueIdentity.inspect", { projectItemId: "item-1" }))
.rejects.toMatchObject({ code: "UXP_STALE_UNIQUE_IDENTITY" });
});
it("does not advertise compatibility when the documented unique serializable static API is absent", async () => {
const value = uniqueIdentityHost();
delete (value.ppro as { UniqueSerializeable?: unknown }).UniqueSerializeable;
const registry = Commands.createCommandRegistry({ ppro: value.ppro, Protocol });
await expect(registry.capabilities()).resolves.toMatchObject({ commands: {
"object.uniqueIdentity.inspect": { supported: false, reason: expect.any(String) },
} });
});
});
+123
View File
@@ -0,0 +1,123 @@
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const Workspace = require("../../uxp-plugin/workspace.cjs");
function storageFixture() {
const files = new Map<string, string>();
const dataFolder = {
getEntry: vi.fn(async (name: string) => {
if (!files.has(name)) throw new Error("missing");
return {
read: vi.fn(async () => files.get(name)),
delete: vi.fn(async () => { files.delete(name); return 0; }),
};
}),
createFile: vi.fn(async (name: string) => ({
write: vi.fn(async (value: string) => { files.set(name, value); }),
})),
};
const root = { isFolder: true, name: "Approved Media", nativePath: "D:\\Projects\\Film" };
const fs = {
getDataFolder: vi.fn(async () => dataFolder),
getFolder: vi.fn(async () => root),
createPersistentToken: vi.fn(async () => "persistent-capability-token"),
getEntryForPersistentToken: vi.fn(async () => root),
};
const resolveCanonicalPath = vi.fn(async (value: string) => value);
return { fs, files, resolveCanonicalPath };
}
describe("least-privilege UXP workspace broker", () => {
it("uses Adobe's compatible manifest permission while retaining a loopback runtime boundary", () => {
const manifest = JSON.parse(readFileSync(join(process.cwd(), "uxp-plugin", "manifest.json"), "utf8"));
expect(manifest.requiredPermissions.network.domains).toBe("all");
expect(Workspace.validateLoopbackBridgeUrl("ws://127.0.0.1:7777/uxp").hostname).toBe("127.0.0.1");
expect(() => Workspace.validateLoopbackBridgeUrl("ws://example.com:7777/uxp")).toThrow("Bridge URL must");
});
it("normalizes absolute paths without allowing root traversal", () => {
expect(Workspace.parseAbsolutePath("D:\\Projects\\Film\\media\\..\\clip.mov", "path")).toMatchObject({
normalized: "D:/Projects/Film/clip.mov",
kind: "windows",
});
expect(Workspace.isContained("D:\\Projects\\Film", "d:/projects/film/clip.mov", false)).toBe(true);
expect(Workspace.isContained("D:\\Projects\\Film", "D:/Projects/Filmography/clip.mov", false)).toBe(false);
expect(Workspace.parseAbsolutePath("/Projects/Film\\Archive/clip.mov", "path")).toMatchObject({
normalized: "/Projects/Film\\Archive/clip.mov",
kind: "posix",
});
expect(Workspace.isContained("/Projects/Film\\Archive", "/Projects/Film/Archive/clip.mov", false)).toBe(false);
expect(() => Workspace.parseAbsolutePath("D:/../Windows/system.ini", "path")).toThrow("escapes");
expect(() => Workspace.parseAbsolutePath("D:/Projects/Film/NUL", "path")).toThrow("Windows-ambiguous");
expect(() => Workspace.parseAbsolutePath("D:/Projects/Film/clip.mov:stream", "path")).toThrow("Windows-ambiguous");
expect(Workspace.parseAbsolutePath("/Volumes/Film/ clip .mov ", "path").normalized)
.toBe("/Volumes/Film/ clip .mov ");
});
it("accepts only the manifest's exact loopback WebSocket endpoint", () => {
expect(Workspace.validateLoopbackBridgeUrl("ws://127.0.0.1:7777/uxp?ignored=true").toString())
.toBe("ws://127.0.0.1:7777/uxp");
expect(Workspace.validateLoopbackBridgeUrl("ws://localhost:9000/uxp").hostname).toBe("localhost");
expect(() => Workspace.validateLoopbackBridgeUrl("wss://example.com/uxp")).toThrow("Bridge URL must");
expect(() => Workspace.validateLoopbackBridgeUrl("ws://localhost.evil/uxp")).toThrow("Bridge URL must");
expect(() => Workspace.validateLoopbackBridgeUrl("ws://localhost:7777/other")).toThrow("Bridge URL must");
});
it("persists only the opaque token and never discloses the native root", async () => {
const fixture = storageFixture();
const broker = Workspace.createWorkspaceBroker({ fs: fixture.fs, resolveCanonicalPath: fixture.resolveCanonicalPath });
await expect(broker.requestRoot()).resolves.toEqual({
configured: true,
accessMode: "request",
rootName: "Approved Media",
persistent: true,
pathDisclosure: "redacted",
canonicalPathValidation: "available",
});
expect(JSON.parse(fixture.files.get("workspace-access.json") ?? "{}")).toEqual({
schemaVersion: 1,
persistentToken: "persistent-capability-token",
});
await expect(broker.assertPathAllowed("D:\\Projects\\Film\\exports\\edit.aaf", { label: "output", kind: "file" }))
.resolves.toBe("D:/Projects/Film/exports/edit.aaf");
await expect(broker.assertPathAllowed("D:\\Projects\\Other\\edit.aaf", { label: "output", kind: "file" }))
.rejects.toMatchObject({ code: "UXP_PATH_OUTSIDE_WORKSPACE" });
});
it("rejects canonical link targets outside the approved workspace and fails closed without a resolver", async () => {
const fixture = storageFixture();
fixture.resolveCanonicalPath.mockImplementation(async (value: string) =>
value.includes("linked") ? "D:/Secrets/outside.mov" : value);
const broker = Workspace.createWorkspaceBroker({ fs: fixture.fs, resolveCanonicalPath: fixture.resolveCanonicalPath });
await broker.requestRoot();
await expect(broker.assertPathAllowed("D:/Projects/Film/linked/outside.mov", { label: "media", kind: "file" }))
.rejects.toMatchObject({ code: "UXP_PATH_OUTSIDE_WORKSPACE" });
const unavailable = Workspace.createWorkspaceBroker({ fs: fixture.fs });
await unavailable.requestRoot();
await expect(unavailable.assertPathAllowed("D:/Projects/Film/media.mov", { label: "media", kind: "file" }))
.rejects.toMatchObject({ code: "UXP_CANONICAL_PATH_UNAVAILABLE" });
});
it("restores and revokes a persisted folder capability", async () => {
const fixture = storageFixture();
const first = Workspace.createWorkspaceBroker({ fs: fixture.fs });
await first.requestRoot();
const restored = Workspace.createWorkspaceBroker({ fs: fixture.fs });
await expect(restored.initialize()).resolves.toMatchObject({ configured: true, rootName: "Approved Media" });
expect(fixture.fs.getEntryForPersistentToken).toHaveBeenCalledWith("persistent-capability-token");
await expect(restored.revoke()).resolves.toMatchObject({ configured: false, persistent: false });
expect(fixture.files.has("workspace-access.json")).toBe(false);
});
it("rejects filesystem-root grants as broader than a project workspace", async () => {
const fixture = storageFixture();
fixture.fs.getFolder.mockResolvedValueOnce({ isFolder: true, name: "Drive", nativePath: "D:\\" });
const broker = Workspace.createWorkspaceBroker({ fs: fixture.fs });
await expect(broker.requestRoot()).rejects.toMatchObject({ code: "UXP_WORKSPACE_TOO_BROAD" });
expect(fixture.files.size).toBe(0);
});
});