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:
@@ -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" });
|
||||
});
|
||||
});
|
||||
+1910
File diff suppressed because it is too large
Load Diff
+162
@@ -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);
|
||||
});
|
||||
});
|
||||
+1121
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);
|
||||
});
|
||||
});
|
||||
+134
@@ -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
@@ -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
@@ -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
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
+138
@@ -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
@@ -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
@@ -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 });
|
||||
});
|
||||
});
|
||||
+122
@@ -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);
|
||||
});
|
||||
});
|
||||
+1088
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" });
|
||||
});
|
||||
});
|
||||
+131
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user