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:
+1141
File diff suppressed because it is too large
Load Diff
+577
@@ -0,0 +1,577 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ADOBE_UXP_COVERAGE_MANIFEST,
|
||||
buildAdobeUxpCoverageReport,
|
||||
buildPlatformCapabilityReport,
|
||||
} from "../src/platform-capabilities.js";
|
||||
import { resolveCapabilities } from "../src/security/capabilities.js";
|
||||
|
||||
describe("Adobe Premiere Pro 26.3 UXP coverage", () => {
|
||||
it("pins the official stable 26.3 type baseline", () => {
|
||||
expect(ADOBE_UXP_COVERAGE_MANIFEST.source).toEqual({
|
||||
apiPackage: "@adobe/premierepro",
|
||||
apiVersion: "26.3.0",
|
||||
changelogUrl: "https://developer.adobe.com/premiere-pro/uxp/changelog/",
|
||||
});
|
||||
});
|
||||
|
||||
it("has unique, official, complete capability metadata", () => {
|
||||
const entries = ADOBE_UXP_COVERAGE_MANIFEST.entries;
|
||||
expect(new Set(entries.map((entry) => entry.id)).size).toBe(entries.length);
|
||||
expect(entries.map((entry) => entry.id)).toEqual(expect.arrayContaining([
|
||||
"action-transaction-discipline",
|
||||
"project-sequence-create-preset",
|
||||
"native-ticktime-frame-alignment",
|
||||
"native-project-insertion-bin-snapshot",
|
||||
"native-installed-mogrt-directory-inspection",
|
||||
"transactional-sequence-range",
|
||||
"guarded-sequence-playhead-control",
|
||||
"guarded-app-preferences",
|
||||
"native-sequence-timing-snapshot",
|
||||
"native-caption-track-inventory",
|
||||
"track-rename",
|
||||
"subclip-create",
|
||||
"single-source-silence-stringout",
|
||||
"marker-list-with-guid",
|
||||
"source-monitor-set-position",
|
||||
"aaf-export",
|
||||
"media-encoder-launch",
|
||||
"native-effects-pipeline",
|
||||
"bounded-effect-parameter-catalog",
|
||||
"selection-compound-effect-batch",
|
||||
"deterministic-timeline-selection",
|
||||
"native-scene-edit-detection",
|
||||
"proxy-ingest-controller",
|
||||
"offline-media-relink-repair",
|
||||
"transactional-metadata-xmp",
|
||||
"color-footage-conformance",
|
||||
"source-monitor-audition",
|
||||
"productions-storage-preflight",
|
||||
"least-privilege-uxp-workspace",
|
||||
"project-view-selection-resolver",
|
||||
"native-marker-crud",
|
||||
"native-beat-grid-markers",
|
||||
"native-marker-batch-removal",
|
||||
"transactional-bin-organizer",
|
||||
"bounded-native-project-tree",
|
||||
"guarded-empty-sequence-creation",
|
||||
"sequence-settings-profiles",
|
||||
"guarded-sequence-display-format",
|
||||
"workspace-gated-project-import",
|
||||
"typed-parameter-keyframe-automation",
|
||||
"native-track-item-identity",
|
||||
"native-tick-time-arithmetic",
|
||||
"track-item-transformations",
|
||||
"bounded-native-timeline-structure",
|
||||
"sequence-editor-timeline-layer",
|
||||
"sequence-lifecycle-derivatives",
|
||||
"ame-encode-controller",
|
||||
"guarded-source-media-timing",
|
||||
"guarded-source-media-overrides",
|
||||
"guarded-transcript-json-import",
|
||||
"guarded-track-item-append-duplicate",
|
||||
"guarded-project-metadata-schema-field-creation",
|
||||
"guarded-track-item-contiguous-ripple-delete",
|
||||
"guarded-timeline-source-color-label",
|
||||
"bounded-unique-serializable-identity",
|
||||
"bounded-source-media-provenance",
|
||||
"bounded-source-proxy-inspection",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "native-marker-crud")?.mcpTools).toEqual(["manage_markers_uxp"]);
|
||||
expect(entries.find((entry) => entry.id === "native-ticktime-frame-alignment")).toMatchObject({
|
||||
uxpCommand: "time.frameAlignment.inspect",
|
||||
mcpTools: ["inspect_frame_alignment_uxp"],
|
||||
verificationBoundary: "native_ticktime_value_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
mutatesProject: false,
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "native-ticktime-frame-alignment")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"FrameRate.createWithValue",
|
||||
"TickTime.createWithSeconds",
|
||||
"TickTime.createWithFrameAndFrameRate",
|
||||
"TickTime.alignToFrame",
|
||||
"TickTime.alignToNearestFrame",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "marker-list-with-guid")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"Marker.guid", "Marker.getColor", "Color.red", "Color.green", "Color.blue", "Color.alpha",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "native-beat-grid-markers")).toMatchObject({
|
||||
uxpCommand: "markers.addBeatGrid",
|
||||
mcpTools: ["apply_beat_markers_uxp"],
|
||||
verificationBoundary: "beat_marker_guid_and_time_readback",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "bounded-media-health-maintenance")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"ClipProjectItem.getMedia",
|
||||
"Media.start",
|
||||
"Media.duration",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "bounded-media-health-maintenance")?.adobeApi).not.toEqual(expect.arrayContaining([
|
||||
"Media.getStart",
|
||||
"Media.getDuration",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "guarded-source-media-timing")).toMatchObject({
|
||||
uxpCommand: "source.mediaTiming.setStart",
|
||||
mcpTools: ["manage_source_media_timing_uxp"],
|
||||
verificationBoundary: "guarded_source_media_timing_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "guarded-source-media-timing")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"ClipProjectItem.getMedia",
|
||||
"Media.start",
|
||||
"Media.duration",
|
||||
"Media.createSetStartAction",
|
||||
"Project.lockedAccess",
|
||||
"Project.executeTransaction",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "guarded-source-media-overrides")).toMatchObject({
|
||||
uxpCommand: "source.mediaOverrides.update",
|
||||
mcpTools: ["manage_source_media_overrides_uxp"],
|
||||
verificationBoundary: "guarded_source_media_effective_interpretation_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "guarded-source-media-overrides")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"ClipProjectItem.getFootageInterpretation",
|
||||
"FootageInterpretation.getFrameRate",
|
||||
"FootageInterpretation.getPixelAspectRatio",
|
||||
"ClipProjectItem.createSetOverrideFrameRateAction",
|
||||
"ClipProjectItem.createSetOverridePixelAspectRatioAction",
|
||||
"Project.lockedAccess",
|
||||
"Project.executeTransaction",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "guarded-transcript-json-import")).toMatchObject({
|
||||
uxpCommand: "transcript.import",
|
||||
mcpTools: ["import_transcript_uxp"],
|
||||
verificationBoundary: "guarded_transcript_export_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "guarded-transcript-json-import")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"Project.getActiveProject",
|
||||
"Project.getRootItem",
|
||||
"Project.lockedAccess",
|
||||
"Project.executeTransaction",
|
||||
"FolderItem.getItems",
|
||||
"ProjectItem.getId",
|
||||
"ClipProjectItem.cast",
|
||||
"Transcript.hasTranscript",
|
||||
"Transcript.exportToJSON",
|
||||
"Transcript.importFromJSON",
|
||||
"Transcript.createImportTextSegmentsAction",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "typed-parameter-keyframe-automation")).toMatchObject({
|
||||
uxpCommand: "parameters.keyframeAdd",
|
||||
mcpTools: ["automate_effect_parameters_uxp"],
|
||||
verificationBoundary: "parameter_value_keyframe_animation_mode_or_lookup_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "typed-parameter-keyframe-automation")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"ComponentParam.areKeyframesSupported",
|
||||
"ComponentParam.isTimeVarying",
|
||||
"ComponentParam.getKeyframeListAsTickTimes",
|
||||
"ComponentParam.createSetTimeVaryingAction",
|
||||
"ComponentParam.getKeyframePtr",
|
||||
"ComponentParam.findNearestKeyframe",
|
||||
"ComponentParam.findNextKeyframe",
|
||||
"ComponentParam.findPreviousKeyframe",
|
||||
"Keyframe.position",
|
||||
"Keyframe.getTemporalInterpolationMode",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "bounded-point-effect-parameter-displacement")).toMatchObject({
|
||||
uxpCommand: "parameters.point.displacement.inspect",
|
||||
mcpTools: ["automate_effect_parameters_uxp"],
|
||||
mutatesProject: false,
|
||||
undoable: false,
|
||||
verificationBoundary: "native_point_distance_complete_endpoint_double_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "bounded-point-effect-parameter-displacement")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"ComponentParam.getValueAtTime",
|
||||
"ComponentParam.isTimeVarying",
|
||||
"TickTime.createWithSeconds",
|
||||
"PointF.distanceTo",
|
||||
"PointF.x",
|
||||
"PointF.y",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "bounded-effect-parameter-catalog")).toMatchObject({
|
||||
uxpCommand: "parameters.catalog.inspect",
|
||||
mcpTools: ["inspect_effect_parameter_catalog_uxp"],
|
||||
verificationBoundary: "bounded_effect_parameter_catalog_double_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "bounded-effect-parameter-catalog")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"Component.getParamCount",
|
||||
"Component.getParam",
|
||||
"ComponentParam.displayName",
|
||||
"ComponentParam.areKeyframesSupported",
|
||||
"ComponentParam.isTimeVarying",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "bounded-unique-serializable-identity")).toMatchObject({
|
||||
uxpCommand: "object.uniqueIdentity.inspect",
|
||||
mcpTools: ["inspect_unique_object_identity_uxp"],
|
||||
verificationBoundary: "bounded_unique_serializable_double_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
mutatesProject: false,
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "bounded-unique-serializable-identity")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"Project.getActiveProject",
|
||||
"Project.getRootItem",
|
||||
"Project.getSequence",
|
||||
"ProjectItem.getId",
|
||||
"FolderItem.getItems",
|
||||
"UniqueSerializeable.cast",
|
||||
"UniqueSerializeable.getUniqueID",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "native-track-item-identity")).toMatchObject({
|
||||
uxpCommand: "trackItem.identity.inspect",
|
||||
mcpTools: ["inspect_track_item_identity_uxp"],
|
||||
verificationBoundary: "active_sequence_identity_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "native-track-item-identity")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"VideoClipTrackItem.getMatchName",
|
||||
"VideoClipTrackItem.getType",
|
||||
"VideoClipTrackItem.getMediaType",
|
||||
"VideoClipTrackItem.getTrackIndex",
|
||||
"VideoClipTrackItem.getIsSelected",
|
||||
"AudioClipTrackItem.getMatchName",
|
||||
"AudioClipTrackItem.getType",
|
||||
"AudioClipTrackItem.getMediaType",
|
||||
"AudioClipTrackItem.getTrackIndex",
|
||||
"AudioClipTrackItem.getIsSelected",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "guarded-app-preferences")).toMatchObject({
|
||||
uxpCommand: "preferences.set",
|
||||
mcpTools: ["manage_app_preferences_uxp"],
|
||||
verificationBoundary: "app_preference_native_string_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "guarded-app-preferences")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"AppPreference.getValue",
|
||||
"AppPreference.setValue",
|
||||
"AppPreference.KEY_AUTO_PEAK_GENERATION",
|
||||
"AppPreference.KEY_IMPORT_WORKSPACE",
|
||||
"AppPreference.KEY_SHOW_QUICKSTART_DIALOG",
|
||||
"AppPreference.PROPERTY_PERSISTENT",
|
||||
"AppPreference.PROPERTY_NON_PERSISTENT",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "native-marker-batch-removal")).toMatchObject({
|
||||
uxpCommand: "markers.removeMany",
|
||||
mcpTools: ["manage_markers_uxp"],
|
||||
verificationBoundary: "marker_guid_absence_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "native-marker-batch-removal")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"Marker.getName",
|
||||
"Marker.getStart",
|
||||
"Marker.getDuration",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "native-sequence-timing-snapshot")).toMatchObject({
|
||||
uxpCommand: "sequence.timing.inspect",
|
||||
mcpTools: ["inspect_sequence_timing_uxp"],
|
||||
verificationBoundary: "sequence_timing_readback_or_targeted_sequence_timing_double_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "native-sequence-timing-snapshot")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"Sequence.getFrameSize",
|
||||
"Sequence.getTimebase",
|
||||
"Sequence.getSequenceAudioTimeDisplayFormat",
|
||||
"Sequence.getSequenceVideoTimeDisplayFormat",
|
||||
"Sequence.getProjectItem",
|
||||
"Project.getSequence",
|
||||
"Guid.fromString",
|
||||
"Sequence.guid",
|
||||
"Sequence.name",
|
||||
"ProjectItem.getId",
|
||||
"ProjectItem.name",
|
||||
"RectF",
|
||||
"RectF.width",
|
||||
"RectF.height",
|
||||
"TimeDisplay",
|
||||
"TimeDisplay.type",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "native-project-insertion-bin-snapshot")).toMatchObject({
|
||||
uxpCommand: "project.insertionBin.inspect",
|
||||
mcpTools: ["inspect_project_insertion_bin_uxp"],
|
||||
verificationBoundary: "project_insertion_bin_identity_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "native-project-insertion-bin-snapshot")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"Project.getInsertionBin",
|
||||
"ProjectItem.getId",
|
||||
"ProjectItem.name",
|
||||
"ProjectItem.type",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "native-installed-mogrt-directory-inspection")).toMatchObject({
|
||||
uxpCommand: "graphics.mogrtPath.inspect",
|
||||
mcpTools: ["inspect_installed_mogrt_directory_uxp"],
|
||||
verificationBoundary: "documented_sequence_editor_installation_directory_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "native-installed-mogrt-directory-inspection")?.adobeApi).toEqual([
|
||||
"SequenceEditor.getInstalledMogrtPath",
|
||||
]);
|
||||
expect(entries.find((entry) => entry.id === "guarded-sequence-display-format")).toMatchObject({
|
||||
uxpCommand: "sequence.displayFormat.update",
|
||||
mcpTools: ["manage_sequence_display_format_uxp"],
|
||||
verificationBoundary: "sequence_display_format_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "guarded-sequence-display-format")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"Sequence.getSettings",
|
||||
"Sequence.createSetSettingsAction",
|
||||
"SequenceSettings.getAudioDisplayFormat",
|
||||
"SequenceSettings.setAudioDisplayFormat",
|
||||
"SequenceSettings.getVideoDisplayFormat",
|
||||
"SequenceSettings.setVideoDisplayFormat",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "bounded-native-project-tree")).toMatchObject({
|
||||
uxpCommand: "projectTree.inspect",
|
||||
mcpTools: ["inspect_project_tree_uxp"],
|
||||
verificationBoundary: "bounded_project_tree_item_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "bounded-native-project-tree")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"Project.getRootItem",
|
||||
"FolderItem.getItems",
|
||||
"ProjectItem.getId",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "bounded-native-timeline-structure")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"VideoClipTrackItem.getProjectItem",
|
||||
"AudioClipTrackItem.getProjectItem",
|
||||
"ProjectItem.getId",
|
||||
"ClipProjectItem.cast",
|
||||
"ClipProjectItem.isSequence",
|
||||
"ClipProjectItem.isMergedClip",
|
||||
"ClipProjectItem.isMulticamClip",
|
||||
"ClipProjectItem.isOffline",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "native-tick-time-arithmetic")).toMatchObject({
|
||||
uxpCommand: "time.tickArithmetic.inspect",
|
||||
mcpTools: ["calculate_tick_time_uxp"],
|
||||
verificationBoundary: "native_tick_time_value_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "native-tick-time-arithmetic")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"TickTime.createWithTicks",
|
||||
"TickTime.add",
|
||||
"TickTime.subtract",
|
||||
"TickTime.multiply",
|
||||
"TickTime.divide",
|
||||
"TickTime.ticks",
|
||||
"TickTime.seconds",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "guarded-empty-sequence-creation")).toMatchObject({
|
||||
uxpCommand: "sequences.createEmpty",
|
||||
mcpTools: ["create_empty_sequence_uxp"],
|
||||
verificationBoundary: "sequence_collection_identity_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "guarded-empty-sequence-creation")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"Project.createSequence",
|
||||
"Project.getSequences",
|
||||
"Sequence.guid",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "bounded-project-columns-metadata-inspection")).toMatchObject({
|
||||
uxpCommand: "metadata.columns.get",
|
||||
mcpTools: ["inspect_project_panel_metadata_uxp"],
|
||||
verificationBoundary: "bounded_project_columns_metadata_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "bounded-project-panel-metadata-inspection")).toMatchObject({
|
||||
uxpCommand: "metadata.projectPanel.get",
|
||||
mcpTools: ["inspect_project_panel_metadata_uxp"],
|
||||
verificationBoundary: "bounded_project_panel_metadata_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "guarded-project-panel-metadata-update")).toMatchObject({
|
||||
uxpCommand: "metadata.projectPanel.update",
|
||||
mcpTools: ["manage_project_panel_metadata_uxp"],
|
||||
mutatesProject: true,
|
||||
undoable: false,
|
||||
idempotency: "operation_id",
|
||||
verificationBoundary: "project_panel_metadata_exact_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "guarded-project-metadata-schema-field-creation")).toMatchObject({
|
||||
uxpCommand: "metadata.projectSchema.create",
|
||||
mcpTools: ["create_project_metadata_field_uxp"],
|
||||
mutatesProject: true,
|
||||
undoable: false,
|
||||
idempotency: "operation_id",
|
||||
verificationStatus: "committed_unverified",
|
||||
verificationBoundary: "project_panel_metadata_change_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "guarded-project-metadata-schema-field-creation")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"Metadata.addPropertyToProjectMetadataSchema",
|
||||
"Metadata.METADATA_TYPE_INTEGER",
|
||||
"Metadata.METADATA_TYPE_REAL",
|
||||
"Metadata.METADATA_TYPE_TEXT",
|
||||
"Metadata.METADATA_TYPE_BOOLEAN",
|
||||
"Project.lockedAccess",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "guarded-track-item-slip")).toMatchObject({
|
||||
uxpCommand: "trackItem.slip",
|
||||
mcpTools: ["slip_track_item_uxp"],
|
||||
verificationBoundary: "guarded_track_item_source_and_timeline_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "guarded-track-item-slide")).toMatchObject({
|
||||
uxpCommand: "trackItem.slide",
|
||||
mcpTools: ["slide_track_item_uxp"],
|
||||
verificationBoundary: "guarded_three_track_item_source_and_timeline_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "guarded-track-item-slide")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"AudioClipTrackItem.createMoveAction",
|
||||
"VideoClipTrackItem.createSetEndAction",
|
||||
"Project.lockedAccess",
|
||||
"Project.executeTransaction",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "guarded-track-item-append-duplicate")).toMatchObject({
|
||||
uxpCommand: "trackItem.clone",
|
||||
mcpTools: ["duplicate_track_item_uxp"],
|
||||
verificationBoundary: "guarded_source_and_appended_track_item_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "guarded-track-item-append-duplicate")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"SequenceEditor.getEditor",
|
||||
"SequenceEditor.createCloneTrackItemAction",
|
||||
"TickTime.createWithSeconds",
|
||||
"Project.lockedAccess",
|
||||
"Project.executeTransaction",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "guarded-track-item-contiguous-ripple-delete")).toMatchObject({
|
||||
uxpCommand: "trackItem.rippleDelete",
|
||||
mcpTools: ["ripple_delete_track_item_uxp"],
|
||||
verificationBoundary: "guarded_contiguous_successor_track_item_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "guarded-track-item-contiguous-ripple-delete")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"TrackItemSelection.createEmptySelection",
|
||||
"TrackItemSelection.addItem",
|
||||
"Constants.MediaType.VIDEO",
|
||||
"SequenceEditor.getEditor",
|
||||
"SequenceEditor.createRemoveItemsAction",
|
||||
"Project.lockedAccess",
|
||||
"Project.executeTransaction",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "guarded-timeline-source-color-label")).toMatchObject({
|
||||
uxpCommand: "timeline.sourceLabel.update",
|
||||
mcpTools: ["manage_timeline_source_label_uxp"],
|
||||
verificationBoundary: "timeline_coordinate_source_color_label_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "guarded-timeline-source-color-label")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"VideoClipTrackItem.getProjectItem",
|
||||
"AudioClipTrackItem.getProjectItem",
|
||||
"ClipProjectItem.cast",
|
||||
"ProjectItem.getColorLabelIndex",
|
||||
"ProjectItem.createSetColorLabelAction",
|
||||
"Project.lockedAccess",
|
||||
"Project.executeTransaction",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "guarded-sequence-preview-frame")).toMatchObject({
|
||||
uxpCommand: "sequence.previewFrame.update",
|
||||
mcpTools: ["manage_sequence_preview_frame_uxp"],
|
||||
verificationBoundary: "sequence_preview_frame_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "guarded-sequence-preview-frame")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"Project.getSequences",
|
||||
"Sequence.getSettings",
|
||||
"Sequence.createSetSettingsAction",
|
||||
"SequenceSettings.getPreviewFrameRect",
|
||||
"SequenceSettings.setPreviewFrameRect",
|
||||
"RectF.[[construct]]",
|
||||
"Project.lockedAccess",
|
||||
"Project.executeTransaction",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "bounded-source-media-provenance")).toMatchObject({
|
||||
uxpCommand: "source.provenance.inspect",
|
||||
mcpTools: ["inspect_source_media_provenance_uxp"],
|
||||
verificationBoundary: "bounded_opt_in_source_path_double_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
mutatesProject: false,
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "bounded-source-media-provenance")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"Project.getRootItem",
|
||||
"ProjectItem.getId",
|
||||
"FolderItem.getItems",
|
||||
"ClipProjectItem.getMediaFilePath",
|
||||
"ClipProjectItem.getOriginatingProjectPath",
|
||||
]));
|
||||
expect(entries.find((entry) => entry.id === "bounded-source-proxy-inspection")).toMatchObject({
|
||||
uxpCommand: "source.proxy.inspect",
|
||||
mcpTools: ["inspect_source_proxy_uxp"],
|
||||
verificationBoundary: "bounded_source_proxy_state_double_readback",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
mutatesProject: false,
|
||||
});
|
||||
expect(entries.find((entry) => entry.id === "bounded-source-proxy-inspection")?.adobeApi).toEqual(expect.arrayContaining([
|
||||
"Project.getRootItem",
|
||||
"ProjectItem.getId",
|
||||
"FolderItem.getItems",
|
||||
"ClipProjectItem.canChangeMediaPath",
|
||||
"ClipProjectItem.isOffline",
|
||||
"ClipProjectItem.canProxy",
|
||||
"ClipProjectItem.hasProxy",
|
||||
"ClipProjectItem.getProxyPath",
|
||||
]));
|
||||
for (const entry of entries) {
|
||||
expect(entry.minimumPremiereVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
expect(entry.backend).toBe("uxp");
|
||||
expect(entry.adobeApi.length).toBeGreaterThan(0);
|
||||
expect(new Set(entry.adobeApi).size).toBe(entry.adobeApi.length);
|
||||
expect(entry.mcpTools.length).toBeGreaterThan(0);
|
||||
expect(entry.documentationUrls.every((url) =>
|
||||
url.startsWith("https://developer.adobe.com/premiere-pro/uxp/"),
|
||||
)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps unimplemented 26.3 work visibly planned", () => {
|
||||
const report = buildAdobeUxpCoverageReport();
|
||||
expect(report.summary).toEqual({
|
||||
total: 87,
|
||||
current: 84,
|
||||
planned: 3,
|
||||
implemented: 84,
|
||||
committedUnverified: 9,
|
||||
automatedContractVerified: 75,
|
||||
liveHostVerified: 0,
|
||||
});
|
||||
expect(report.entries.find((entry) => entry.id === "aaf-export")).toMatchObject({
|
||||
availability: "current",
|
||||
implementationStatus: "implemented",
|
||||
verificationStatus: "automated_contract_verified",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
uxpCommand: "interchange.aaf.export",
|
||||
});
|
||||
expect(report.entries.find((entry) => entry.id === "bounded-operation-boundary-event-journal")).toMatchObject({
|
||||
availability: "current",
|
||||
implementationStatus: "implemented",
|
||||
verificationStatus: "automated_contract_verified",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
uxpCommand: "events.list",
|
||||
mcpTools: ["inspect_premiere_events_uxp"],
|
||||
verificationBoundary: "bounded_redacted_operation_boundary_event_receipt",
|
||||
});
|
||||
expect(report.entries.every((entry) => entry.liveHostVerificationStatus === "not_run")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns detached report data for safe capability reporting", () => {
|
||||
const first = buildAdobeUxpCoverageReport();
|
||||
first.entries[0].mcpTools.push("should-not-persist");
|
||||
const second = buildAdobeUxpCoverageReport();
|
||||
expect(second.entries[0].mcpTools).not.toContain("should-not-persist");
|
||||
});
|
||||
|
||||
it("surfaces the baseline in the platform capability report", () => {
|
||||
const report = buildPlatformCapabilityReport(resolveCapabilities("inspect"), "win32");
|
||||
expect(report.backends.uxp.apiCoverage.summary).toMatchObject({
|
||||
current: 84,
|
||||
planned: 3,
|
||||
committedUnverified: 9,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAdvancedFeatureSupport } from "../src/advanced-feature-support.js";
|
||||
import { getHealthTools } from "../src/tools/health.js";
|
||||
|
||||
describe("advanced collaboration and AI feature support", () => {
|
||||
it("defaults to the production CEP transport and fails closed", () => {
|
||||
const report = buildAdvancedFeatureSupport();
|
||||
expect(report.context.backend).toBe("cep");
|
||||
expect(report.policy).toMatchObject({
|
||||
publicApisOnly: true,
|
||||
uiAutomation: false,
|
||||
privateApis: false,
|
||||
reportTool: {
|
||||
transport: "local",
|
||||
callableThroughCurrentMcpTransport: true,
|
||||
contactsPremiereHost: false,
|
||||
},
|
||||
featureOperations: {
|
||||
currentMcpTransport: "cep",
|
||||
uxpOperationsRoutedByCurrentMcpTransport: false,
|
||||
liveHostCapabilityNegotiationRequired: true,
|
||||
},
|
||||
});
|
||||
expect(report.features.productions).toMatchObject({
|
||||
status: "uxp-read-only",
|
||||
staticEligibility: {
|
||||
backendEligible: false,
|
||||
eligible: false,
|
||||
},
|
||||
liveHostVerificationRequired: true,
|
||||
callableThroughCurrentMcpTransport: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks documented Productions inspection available only for eligible UXP hosts", () => {
|
||||
expect(buildAdvancedFeatureSupport({ backend: "uxp", premiereVersion: "25.6.0" }).features.productions.staticEligibility.eligible).toBe(true);
|
||||
expect(buildAdvancedFeatureSupport({ backend: "uxp", premiereVersion: "25.5.9" }).features.productions.staticEligibility.eligible).toBe(false);
|
||||
expect(buildAdvancedFeatureSupport({ backend: "uxp" }).features.productions.staticEligibility.eligible).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps entitlements separate from API availability", () => {
|
||||
const report = buildAdvancedFeatureSupport({
|
||||
backend: "uxp",
|
||||
premiereVersion: "26.3",
|
||||
frameIoEntitled: true,
|
||||
generativeAiEntitled: true,
|
||||
networkAvailable: true,
|
||||
});
|
||||
expect(report.features.frameIo).toMatchObject({
|
||||
status: "external-api-required",
|
||||
entitlementSatisfied: true,
|
||||
callableThroughCurrentMcpTransport: false,
|
||||
});
|
||||
expect(report.features.generativeExtend).toMatchObject({
|
||||
status: "user-assisted",
|
||||
entitlementSatisfied: true,
|
||||
callableThroughCurrentMcpTransport: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("documents artifact detection boundaries without overstating provenance", () => {
|
||||
const features = buildAdvancedFeatureSupport().features;
|
||||
expect(features.generativeExtend.detection).toContain("No documented flag");
|
||||
expect(features.objectMask).toMatchObject({
|
||||
status: "partial",
|
||||
callableThroughCurrentMcpTransport: true,
|
||||
});
|
||||
expect(features.objectMask.detection).toContain("ObjectMaskUtils.hasObjectMask");
|
||||
expect(features.objectMask.unsupportedOperations).toContain("create an Object Mask");
|
||||
expect(features.captionTranslation.detection).toContain("No documented metadata");
|
||||
expect(features.remix.detection).toContain("not proof");
|
||||
expect(features.editorialPlans).toMatchObject({
|
||||
status: "local-planning",
|
||||
access: "direct",
|
||||
callableThroughCurrentMcpTransport: true,
|
||||
});
|
||||
expect(features.premiereAiAssistant).toMatchObject({
|
||||
status: "user-assisted",
|
||||
access: "user-assisted",
|
||||
callableThroughCurrentMcpTransport: false,
|
||||
});
|
||||
expect(features.localSemanticIndex).toMatchObject({
|
||||
status: "planned-local",
|
||||
access: "planned",
|
||||
callableThroughCurrentMcpTransport: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-numeric host versions", () => {
|
||||
expect(() => buildAdvancedFeatureSupport({ premiereVersion: "26.3-beta" })).toThrow("numeric version");
|
||||
});
|
||||
|
||||
it("exposes the report through the health tool without contacting Premiere", async () => {
|
||||
const tool = getHealthTools({}).get_advanced_feature_support;
|
||||
const result = await tool.handler({ backend: "uxp", premiere_version: "26.3.0" });
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
context: { backend: "uxp", premiereVersion: "26.3.0" },
|
||||
features: {
|
||||
speechToText: {
|
||||
status: "user-assisted",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { createServer } from "../src/server.js";
|
||||
import { getHealthTools } from "../src/tools/health.js";
|
||||
import { resolveCapabilities } from "../src/security/capabilities.js";
|
||||
import { resolveToolPacks } from "../src/workflows/tool-packs.js";
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
const catalog = {
|
||||
get_transcript: { description: "Read speech evidence" },
|
||||
get_notes: { description: "Read transcript review notes" },
|
||||
delete_clip: { description: "Remove a transcript clip" },
|
||||
execute_extendscript: { description: "Run transcript scripts" },
|
||||
};
|
||||
|
||||
describe("bounded agent tool discovery", () => {
|
||||
it("ranks exact names first and intersects query with explicit names", async () => {
|
||||
const tool = getHealthTools({}, resolveCapabilities("inspect"), () => catalog).get_capabilities;
|
||||
const exact = await tool.handler({ tool_query: " GET_TRANSCRIPT " });
|
||||
expect(exact.data?.tools.tools[0]).toMatchObject({ name: "get_transcript", registered: true });
|
||||
const result = await tool.handler({ tool_query: "transcript", tool_names: ["get_notes"] });
|
||||
expect(result.data?.tools.tools).toEqual([
|
||||
expect.objectContaining({ name: "get_notes", description: "Read transcript review notes", registered: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("withholds unauthorized matches by default and labels diagnostic matches", async () => {
|
||||
const tool = getHealthTools({}, resolveCapabilities("inspect"), () => catalog).get_capabilities;
|
||||
const result = await tool.handler({ tool_query: "transcript" });
|
||||
expect(result.data?.tools.tools.map((tool) => tool.name)).toEqual(["get_transcript", "get_notes"]);
|
||||
const diagnostic = await tool.handler({ tool_query: "transcript", available_only: false });
|
||||
expect(diagnostic.data?.tools.tools).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: "delete_clip", registered: false }),
|
||||
expect.objectContaining({ name: "execute_extendscript", registered: false }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("respects pack membership independently of authority", async () => {
|
||||
const tool = getHealthTools({}, resolveCapabilities("inspect,edit"), () => catalog,
|
||||
{ toolPacks: resolveToolPacks("inspection") }).get_capabilities;
|
||||
expect((await tool.handler({ tool_query: "transcript" })).data?.tools.tools).toEqual([]);
|
||||
expect((await tool.handler({ tool_query: "delete_clip", available_only: false })).data?.tools.tools[0])
|
||||
.toMatchObject({ name: "delete_clip", registered: false, authority: { enabled: true } });
|
||||
});
|
||||
|
||||
it("defaults search to 20 results and pages deterministically without duplicates", async () => {
|
||||
const many = Object.fromEntries(Array.from({ length: 25 }, (_, i) =>
|
||||
[`get_evidence_${String(i).padStart(2, "0")}`, { description: "Read evidence" }]));
|
||||
const tool = getHealthTools({}, resolveCapabilities("inspect"), () => many).get_capabilities;
|
||||
const first = await tool.handler({ tool_query: "evidence" });
|
||||
const second = await tool.handler({ tool_query: "evidence", tool_offset: 20 });
|
||||
expect(first.data?.tools.pagination).toMatchObject({ returned: 20, hasMore: true, nextOffset: 20 });
|
||||
expect(second.data?.tools.pagination).toMatchObject({ returned: 5, hasMore: false, nextOffset: null });
|
||||
const names = [...first.data!.tools.tools, ...second.data!.tools.tools].map((tool) => tool.name);
|
||||
expect(new Set(names).size).toBe(25);
|
||||
expect(names).toEqual(Object.keys(many));
|
||||
expect((await tool.handler({ tool_query: "no-match" })).data?.tools.pagination)
|
||||
.toMatchObject({ totalMatching: 0, hasMore: false, nextOffset: null });
|
||||
});
|
||||
|
||||
it("preserves unfiltered legacy shape and rejects invalid search input", async () => {
|
||||
const tool = getHealthTools({}, resolveCapabilities("inspect"), () => catalog).get_capabilities;
|
||||
const legacy = await tool.handler({});
|
||||
expect(legacy.data?.tools.tools).toHaveLength(4);
|
||||
expect(legacy.data?.tools).not.toHaveProperty("pagination");
|
||||
expect(legacy.data?.tools.tools[0]).not.toHaveProperty("registered");
|
||||
for (const query of ["", " ", "x".repeat(257)]) {
|
||||
expect(await tool.handler({ tool_query: query })).toMatchObject({ success: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent guidance over MCP", () => {
|
||||
it.each(["full", "inspection"])("matches discovery to tools/list in the %s pack", async (pack) => {
|
||||
vi.stubEnv("PREMIERE_MCP_CAPABILITIES", "inspect,edit,export,filesystem");
|
||||
vi.stubEnv("PREMIERE_MCP_TELEMETRY", "off");
|
||||
const server = createServer({}, { toolPacks: pack });
|
||||
const client = new Client({ name: "astra-discovery-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
const listed = new Set((await client.listTools()).tools.map((tool) => tool.name));
|
||||
const result = await client.callTool({ name: "get_capabilities", arguments: {
|
||||
tool_query: "sequence", tool_limit: 128,
|
||||
} });
|
||||
const data = (result.structuredContent as any).data;
|
||||
expect(data.tools.tools.length).toBeGreaterThan(0);
|
||||
expect(data.discovery).toMatchObject({ detail: "summary" });
|
||||
expect(data.backends.uxp).not.toHaveProperty("apiCoverage");
|
||||
for (const tool of data.tools.tools) {
|
||||
expect(tool.registered).toBe(true);
|
||||
expect(listed.has(tool.name)).toBe(true);
|
||||
}
|
||||
const instructions = client.getInstructions()!;
|
||||
const resource = await client.readResource({ uri: "config://premiere-instructions" });
|
||||
expect(resource.contents[0]).toMatchObject({ text: instructions });
|
||||
expect(instructions).toContain("tool_query");
|
||||
expect(instructions).toContain("Serialize operations");
|
||||
expect(instructions).toContain("timeout can leave host outcome unknown");
|
||||
const routes = instructions.split("AVAILABLE WORKFLOW ROUTES:\n")[1].split("\n\nRECOVER")[0];
|
||||
for (const line of routes.split("\n")) {
|
||||
if (!line.includes(": ")) continue;
|
||||
for (const name of line.slice(2).split(": ")[0].split(" -> ")) {
|
||||
expect(listed.has(name), `${name} must be registered`).toBe(true);
|
||||
}
|
||||
}
|
||||
if (pack === "inspection") expect(routes).not.toContain("apply_edit_plan");
|
||||
else expect(routes).toContain("create_editorial_context_pack");
|
||||
if (pack === "full") {
|
||||
const legacy = await client.callTool({ name: "get_capabilities", arguments: {} });
|
||||
const search = await client.callTool({ name: "get_capabilities", arguments: {
|
||||
tool_query: "transcript", tool_limit: 10,
|
||||
} });
|
||||
expect(JSON.stringify(search).length).toBeLessThan(JSON.stringify(legacy).length / 4);
|
||||
}
|
||||
const invalid = await client.callTool({ name: "get_capabilities", arguments: { tool_query: " " } });
|
||||
expect(invalid.isError).toBe(true);
|
||||
await expect(client.callTool({ name: "execute_extendscript", arguments: { script: "1" } }))
|
||||
.rejects.toThrow(/not found/);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { join } from "node:path";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import {
|
||||
AFTER_EFFECTS_BRIDGE_HELPERS,
|
||||
AFTER_EFFECTS_DEFAULT_TEMP_DIR_NAME,
|
||||
getAfterEffectsTempDir,
|
||||
sendAfterEffectsCommand,
|
||||
} from "../../src/bridge/after-effects-bridge.js";
|
||||
|
||||
describe("After Effects bridge", () => {
|
||||
it("uses an independent bridge directory and AE-only helpers", async () => {
|
||||
expect(getAfterEffectsTempDir(undefined, "D:/Temp")).toBe(join("D:/Temp", AFTER_EFFECTS_DEFAULT_TEMP_DIR_NAME));
|
||||
await sendAfterEffectsCommand("return __aeResult({});", { tempDir: "D:/PremiereOnly" });
|
||||
expect(sendCommand).toHaveBeenCalledWith("return __aeResult({});", expect.objectContaining({
|
||||
tempDir: getAfterEffectsTempDir(),
|
||||
helpers: AFTER_EFFECTS_BRIDGE_HELPERS,
|
||||
}));
|
||||
expect(AFTER_EFFECTS_BRIDGE_HELPERS.source).toContain("__aeResult");
|
||||
expect(AFTER_EFFECTS_BRIDGE_HELPERS.source).not.toContain("__findSequence");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
import { once } from "node:events";
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
watch,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { WebSocket } from "ws";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import {
|
||||
UxpWebSocketBridge,
|
||||
type UxpHello,
|
||||
} from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
readdirSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
chmodSync: vi.fn(),
|
||||
watch: vi.fn(),
|
||||
}));
|
||||
|
||||
const fs = {
|
||||
exists: vi.mocked(existsSync),
|
||||
mkdir: vi.mocked(mkdirSync),
|
||||
write: vi.mocked(writeFileSync),
|
||||
read: vi.mocked(readFileSync),
|
||||
unlink: vi.mocked(unlinkSync),
|
||||
readdir: vi.mocked(readdirSync),
|
||||
rename: vi.mocked(renameSync),
|
||||
stat: vi.mocked(statSync),
|
||||
chmod: vi.mocked(chmodSync),
|
||||
watch: vi.mocked(watch),
|
||||
};
|
||||
|
||||
const originalGetuid = Object.getOwnPropertyDescriptor(process, "getuid");
|
||||
|
||||
function ownedStat(mode = 0o700): ReturnType<typeof statSync> {
|
||||
return {
|
||||
uid: 4242,
|
||||
mode,
|
||||
mtimeMs: Date.now(),
|
||||
} as unknown as ReturnType<typeof statSync>;
|
||||
}
|
||||
|
||||
function usePosixProcess(uid = 4242): void {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
|
||||
Object.defineProperty(process, "getuid", {
|
||||
configurable: true,
|
||||
value: () => uid,
|
||||
});
|
||||
}
|
||||
|
||||
function restoreGetuid(): void {
|
||||
if (originalGetuid) {
|
||||
Object.defineProperty(process, "getuid", originalGetuid);
|
||||
} else {
|
||||
delete (process as { getuid?: () => number }).getuid;
|
||||
}
|
||||
}
|
||||
|
||||
describe("file bridge uncovered security and fallback paths", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
fs.watch.mockImplementation(() => {
|
||||
throw new Error("watch unavailable");
|
||||
});
|
||||
fs.stat.mockReturnValue(ownedStat());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
restoreGetuid();
|
||||
});
|
||||
|
||||
it("rejects a pre-existing POSIX directory owned by another user", async () => {
|
||||
usePosixProcess();
|
||||
fs.exists.mockReturnValue(true);
|
||||
fs.stat.mockReturnValue(ownedStat());
|
||||
fs.stat.mockReturnValueOnce({
|
||||
uid: 31337,
|
||||
mode: 0o700,
|
||||
mtimeMs: Date.now(),
|
||||
} as unknown as ReturnType<typeof statSync>);
|
||||
|
||||
await expect(sendCommand("var unsafe = true;", {
|
||||
tempDir: "/tmp/untrusted-bridge",
|
||||
})).rejects.toThrow(/owned by uid 31337/);
|
||||
});
|
||||
|
||||
it("restores private POSIX permissions on an owned directory", async () => {
|
||||
usePosixProcess();
|
||||
fs.exists.mockReturnValue(true);
|
||||
fs.stat.mockReturnValueOnce(ownedStat(0o755));
|
||||
fs.read.mockReturnValue('{"success":true,"data":{"repaired":true}}');
|
||||
|
||||
await expect(sendCommand("var repair = true;", {
|
||||
tempDir: "/tmp/owned-bridge",
|
||||
})).resolves.toEqual({ success: true, data: { repaired: true } });
|
||||
|
||||
expect(fs.chmod).toHaveBeenCalledWith("/tmp/owned-bridge", 0o700);
|
||||
});
|
||||
|
||||
it("reports a busy Premiere operation when its heartbeat cannot be statted", async () => {
|
||||
usePosixProcess();
|
||||
fs.exists.mockImplementation((path) => {
|
||||
const value = String(path);
|
||||
if (value.includes("res_")) return false;
|
||||
if (value.includes("busy_")) return true;
|
||||
return true;
|
||||
});
|
||||
fs.stat.mockImplementation(((path) => {
|
||||
if (String(path).includes("busy_")) throw new Error("heartbeat locked");
|
||||
return ownedStat();
|
||||
}) as typeof statSync);
|
||||
|
||||
const response = sendCommand("var waiting = true;", {
|
||||
tempDir: "/tmp/busy-stat-error",
|
||||
timeoutMs: 25,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(110);
|
||||
|
||||
await expect(response).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("modal dialog"),
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes non-Error response read failures into command results", async () => {
|
||||
usePosixProcess();
|
||||
fs.exists.mockImplementation((path) => !String(path).includes("busy_"));
|
||||
fs.read.mockImplementation(() => {
|
||||
throw "response file is locked";
|
||||
});
|
||||
|
||||
const response = sendCommand("var parse = true;", {
|
||||
tempDir: "/tmp/non-error-read-failure",
|
||||
timeoutMs: 25,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(110);
|
||||
|
||||
await expect(response).resolves.toEqual({
|
||||
success: false,
|
||||
error: "Failed to parse response: response file is locked",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const TOKEN = "bridge-edge-token-at-least-16-characters";
|
||||
const liveBridges: UxpWebSocketBridge[] = [];
|
||||
|
||||
async function startBridge(options: {
|
||||
port?: number;
|
||||
handshakeTimeoutMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
} = {}): Promise<UxpWebSocketBridge> {
|
||||
const bridge = new UxpWebSocketBridge({ token: TOKEN, port: 0, ...options });
|
||||
liveBridges.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,
|
||||
protocolVersion = 1,
|
||||
commands: Record<string, { supported: boolean }> = { "state.get": { supported: true } },
|
||||
): Promise<WebSocket> {
|
||||
const client = new WebSocket(bridgeUrl(bridge));
|
||||
await once(client, "open");
|
||||
const connected = once(bridge, "connected");
|
||||
client.send(JSON.stringify({
|
||||
protocolVersion,
|
||||
type: "hello",
|
||||
payload: {
|
||||
backend: "uxp",
|
||||
protocolVersion,
|
||||
commands,
|
||||
},
|
||||
}));
|
||||
await connected;
|
||||
return client;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(liveBridges.splice(0).map((bridge) => bridge.stop()));
|
||||
});
|
||||
|
||||
describe("UXP WebSocket bridge rejected-message and cleanup paths", () => {
|
||||
it("serves a 404 for regular HTTP traffic and ignores duplicate starts", async () => {
|
||||
const bridge = await startBridge();
|
||||
const listening = vi.fn();
|
||||
bridge.on("listening", listening);
|
||||
|
||||
await bridge.start();
|
||||
const address = bridge.address();
|
||||
const response = await fetch(`http://${address.host}:${address.port}/not-a-websocket`);
|
||||
|
||||
expect(listening).not.toHaveBeenCalled();
|
||||
expect(response.status).toBe(404);
|
||||
await expect(response.text()).resolves.toBe("Not found");
|
||||
});
|
||||
|
||||
it("uses configured address data before it has started", () => {
|
||||
const bridge = new UxpWebSocketBridge({ token: TOKEN, port: 7911, path: "/panel" });
|
||||
expect(bridge.address()).toEqual({ host: "127.0.0.1", port: 7911, path: "/panel" });
|
||||
expect(bridge.getState()).toEqual({ status: "stopped", connected: false });
|
||||
});
|
||||
|
||||
it("uses a timing-safe comparison for equal-length incorrect tokens", async () => {
|
||||
const bridge = await startBridge();
|
||||
const client = new WebSocket(bridgeUrl(bridge, "x".repeat(TOKEN.length)));
|
||||
client.on("error", () => {});
|
||||
const [, response] = await once(client, "unexpected-response");
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
response.resume();
|
||||
});
|
||||
|
||||
it("closes a host that never sends the versioned hello", async () => {
|
||||
const bridge = await startBridge({ handshakeTimeoutMs: 20 });
|
||||
const client = new WebSocket(bridgeUrl(bridge));
|
||||
await once(client, "open");
|
||||
|
||||
const [code, reason] = await once(client, "close");
|
||||
expect(code).toBe(1008);
|
||||
expect(reason.toString()).toContain("Versioned hello required");
|
||||
});
|
||||
|
||||
it("rejects each malformed hello shape before accepting capabilities", async () => {
|
||||
const bridge = await startBridge();
|
||||
const malformedMessages = [
|
||||
{ protocolVersion: 1, type: "event", payload: {} },
|
||||
{ protocolVersion: 1, type: "hello", payload: { backend: "cep", protocolVersion: 1, commands: {} } },
|
||||
{ protocolVersion: 3, type: "hello", payload: { backend: "uxp", protocolVersion: 3, commands: {} } },
|
||||
{ protocolVersion: 1, type: "hello", payload: { backend: "uxp", protocolVersion: 2, commands: {} } },
|
||||
{ protocolVersion: 1, type: "hello", payload: { backend: "uxp", protocolVersion: 1 } },
|
||||
{ protocolVersion: 1, type: "hello", payload: { backend: "uxp", protocolVersion: 1, commands: "invalid" } },
|
||||
{ protocolVersion: 1, type: "hello", payload: { backend: "uxp", protocolVersion: 1, commands: [] } },
|
||||
];
|
||||
|
||||
for (const message of malformedMessages) {
|
||||
const client = new WebSocket(bridgeUrl(bridge));
|
||||
await once(client, "open");
|
||||
const closed = once(client, "close");
|
||||
client.send(JSON.stringify(message));
|
||||
const [code] = await closed;
|
||||
expect(code).toBe(1008);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts protocol 2 and closes the connection when its protocol changes", async () => {
|
||||
const bridge = await startBridge();
|
||||
const client = await connectHost(bridge, 2);
|
||||
expect(bridge.getState()).toMatchObject({ connected: true, protocolVersion: 2 });
|
||||
|
||||
const closed = once(client, "close");
|
||||
client.send(JSON.stringify({ protocolVersion: 1, type: "event", payload: { kind: "wrong-version" } }));
|
||||
const [code, reason] = await closed;
|
||||
expect(code).toBe(1008);
|
||||
expect(reason.toString()).toContain("Protocol version changed");
|
||||
});
|
||||
|
||||
it("ignores unknown messages and reports default host command failures", async () => {
|
||||
const bridge = await startBridge();
|
||||
const client = await connectHost(bridge);
|
||||
client.send(JSON.stringify({ protocolVersion: 1, type: "notice", payload: {} }));
|
||||
client.send(JSON.stringify({
|
||||
protocolVersion: 1,
|
||||
type: "result",
|
||||
requestId: "unknown-request",
|
||||
payload: { ok: true, result: "ignored" },
|
||||
}));
|
||||
|
||||
client.once("message", (data) => {
|
||||
const command = JSON.parse(data.toString());
|
||||
client.send(JSON.stringify({
|
||||
protocolVersion: 1,
|
||||
type: "result",
|
||||
requestId: command.requestId,
|
||||
payload: { ok: false },
|
||||
}));
|
||||
});
|
||||
await expect(bridge.request("state.get")).rejects.toMatchObject({
|
||||
code: "UXP_COMMAND_FAILED",
|
||||
message: "UXP command 'state.get' failed",
|
||||
});
|
||||
expect(bridge.getState()).toMatchObject({ connected: true });
|
||||
});
|
||||
|
||||
it("forwards client errors from an accepted connection", async () => {
|
||||
const bridge = await startBridge();
|
||||
await connectHost(bridge);
|
||||
const emitted = once(bridge, "clientError");
|
||||
const expected = new Error("client transport error");
|
||||
const serverClient = (bridge as unknown as { socket: WebSocket }).socket;
|
||||
|
||||
serverClient.emit("error", expected);
|
||||
await expect(emitted).resolves.toEqual([expected]);
|
||||
});
|
||||
|
||||
it("rejects a request if the socket reports a send failure exactly once", async () => {
|
||||
const bridge = new UxpWebSocketBridge({ token: TOKEN });
|
||||
const internal = bridge as unknown as {
|
||||
socket: {
|
||||
readyState: number;
|
||||
send: (message: string, callback: (error?: Error) => void) => void;
|
||||
close: () => void;
|
||||
} | null;
|
||||
hello: UxpHello | null;
|
||||
};
|
||||
internal.socket = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: (_message, callback) => {
|
||||
callback(new Error("socket write failed"));
|
||||
callback(new Error("duplicate completion"));
|
||||
},
|
||||
close: vi.fn(),
|
||||
};
|
||||
internal.hello = {
|
||||
backend: "uxp",
|
||||
protocolVersion: 1,
|
||||
commands: { "state.get": { supported: true } },
|
||||
};
|
||||
|
||||
await expect(bridge.request("state.get")).rejects.toMatchObject({
|
||||
code: "UXP_SEND_FAILED",
|
||||
message: "socket write failed",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
watch,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { cleanupTempDir, sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
readdirSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
chmodSync: vi.fn(),
|
||||
watch: vi.fn(),
|
||||
}));
|
||||
|
||||
const fs = {
|
||||
exists: vi.mocked(existsSync),
|
||||
mkdir: vi.mocked(mkdirSync),
|
||||
write: vi.mocked(writeFileSync),
|
||||
read: vi.mocked(readFileSync),
|
||||
unlink: vi.mocked(unlinkSync),
|
||||
readdir: vi.mocked(readdirSync),
|
||||
rename: vi.mocked(renameSync),
|
||||
stat: vi.mocked(statSync),
|
||||
chmod: vi.mocked(chmodSync),
|
||||
watch: vi.mocked(watch),
|
||||
};
|
||||
|
||||
describe("file bridge fallback and cleanup branches", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
fs.stat.mockReturnValue({
|
||||
uid: typeof process.getuid === "function" ? process.getuid() : 0,
|
||||
mode: 0o700,
|
||||
mtimeMs: Date.now(),
|
||||
} as ReturnType<typeof statSync>);
|
||||
fs.watch.mockImplementation(() => { throw new Error("watch unavailable"); });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("extends a busy operation, then reports a likely modal dialog", async () => {
|
||||
let busy = true;
|
||||
fs.exists.mockImplementation((path) => {
|
||||
const value = String(path);
|
||||
if (value.includes("res_")) return false;
|
||||
if (value.includes("busy_")) return busy;
|
||||
return true;
|
||||
});
|
||||
fs.stat.mockImplementation(() => ({
|
||||
uid: typeof process.getuid === "function" ? process.getuid() : 0,
|
||||
mode: 0o700,
|
||||
mtimeMs: Date.now(),
|
||||
} as ReturnType<typeof statSync>));
|
||||
|
||||
const response = sendCommand("var operation = true;", {
|
||||
tempDir: "/tmp/busy-bridge",
|
||||
timeoutMs: 100,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
busy = false;
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
await expect(response).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("modal dialog"),
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores unrelated watch events and disables a failed watcher", async () => {
|
||||
let responseExists = false;
|
||||
let change: ((event: string, filename: string | Buffer | null) => void) | undefined;
|
||||
let watcherError: (() => void) | undefined;
|
||||
const watcher = {
|
||||
close: vi.fn(),
|
||||
on: vi.fn((event: string, callback: () => void) => {
|
||||
if (event === "error") watcherError = callback;
|
||||
return watcher;
|
||||
}),
|
||||
};
|
||||
fs.watch.mockImplementation(((_path, _options, callback) => {
|
||||
change = callback;
|
||||
return watcher;
|
||||
}) as typeof watch);
|
||||
fs.exists.mockImplementation((path) => String(path).includes("res_") ? responseExists : true);
|
||||
fs.read.mockReturnValue('{"success":true,"data":{"watched":true}}');
|
||||
|
||||
const response = sendCommand("var watched = true;", { tempDir: "/tmp/watch-bridge" });
|
||||
change?.("rename", null);
|
||||
change?.("rename", "unrelated.json");
|
||||
watcherError?.();
|
||||
responseExists = true;
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await expect(response).resolves.toEqual({ success: true, data: { watched: true } });
|
||||
expect(watcher.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tolerates unlink failures and removes busy protocol files", async () => {
|
||||
fs.exists.mockReturnValue(true);
|
||||
fs.read.mockReturnValue('{"success":true}');
|
||||
fs.unlink.mockImplementation(() => { throw new Error("locked"); });
|
||||
|
||||
const response = sendCommand("var cleanup = true;", { tempDir: "/tmp/cleanup-bridge" });
|
||||
await expect(response).resolves.toEqual({ success: true });
|
||||
|
||||
fs.readdir.mockReturnValue([
|
||||
"busy_1.json", "cmd_1.jsx", "res_1.json", "helpers.jsx",
|
||||
] as ReturnType<typeof readdirSync>);
|
||||
expect(() => cleanupTempDir({ tempDir: "/tmp/cleanup-bridge" })).not.toThrow();
|
||||
expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("busy_1.json"));
|
||||
});
|
||||
|
||||
it("skips POSIX ownership changes on Windows while checking response bounds", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
fs.exists.mockReturnValue(true);
|
||||
fs.read.mockReturnValue('{"success":true}');
|
||||
|
||||
await expect(sendCommand("var windows = true;", {
|
||||
tempDir: "C:\\Temp\\premiere-bridge",
|
||||
})).resolves.toEqual({ success: true });
|
||||
expect(fs.stat).toHaveBeenCalledWith(expect.stringContaining("res_"));
|
||||
expect(fs.chmod).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+612
@@ -0,0 +1,612 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
chmodSync,
|
||||
watch,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
getDarwinUserTempDirectory,
|
||||
getTempDir,
|
||||
getDefaultBridgeTempDir,
|
||||
getBridgeLiveness,
|
||||
MAX_BRIDGE_RESPONSE_BYTES,
|
||||
sendCommand,
|
||||
sendRawCommand,
|
||||
cleanupTempDir,
|
||||
} from "../../src/bridge/file-bridge.js";
|
||||
|
||||
// Mock fs module
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
readdirSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
chmodSync: vi.fn(),
|
||||
watch: vi.fn(() => {
|
||||
throw new Error("watch unavailable in unit-test fallback");
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
const mockedMkdirSync = vi.mocked(mkdirSync);
|
||||
const mockedWriteFileSync = vi.mocked(writeFileSync);
|
||||
const mockedReadFileSync = vi.mocked(readFileSync);
|
||||
const mockedUnlinkSync = vi.mocked(unlinkSync);
|
||||
const mockedReaddirSync = vi.mocked(readdirSync);
|
||||
const mockedRenameSync = vi.mocked(renameSync);
|
||||
const mockedStatSync = vi.mocked(statSync);
|
||||
const mockedChmodSync = vi.mocked(chmodSync);
|
||||
const mockedWatch = vi.mocked(watch);
|
||||
const mockedExecFileSync = vi.mocked(execFileSync);
|
||||
|
||||
// ensureDir on an existing dir stat-checks ownership; default to a dir owned by us
|
||||
// with safe perms so the existing tests exercise the happy path.
|
||||
const myUid = typeof process.getuid === "function" ? process.getuid() : 0;
|
||||
mockedStatSync.mockReturnValue({ uid: myUid, mode: 0o700 } as unknown as ReturnType<typeof statSync>);
|
||||
|
||||
describe("getTempDir", () => {
|
||||
const originalEnv = process.env.PREMIERE_TEMP_DIR;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.PREMIERE_TEMP_DIR = originalEnv;
|
||||
} else {
|
||||
delete process.env.PREMIERE_TEMP_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns custom dir from options", () => {
|
||||
expect(getTempDir({ tempDir: "/custom/dir" })).toBe("/custom/dir");
|
||||
});
|
||||
|
||||
it("returns env var when no options", () => {
|
||||
process.env.PREMIERE_TEMP_DIR = "/env/dir";
|
||||
expect(getTempDir()).toBe("/env/dir");
|
||||
});
|
||||
|
||||
it("returns env var when options have no tempDir", () => {
|
||||
process.env.PREMIERE_TEMP_DIR = "/env/dir";
|
||||
expect(getTempDir({})).toBe("/env/dir");
|
||||
});
|
||||
|
||||
it("returns default when no options or env", () => {
|
||||
delete process.env.PREMIERE_TEMP_DIR;
|
||||
const result = getTempDir();
|
||||
expect(result).toBe(join(tmpdir(), "premiere-mcp-bridge"));
|
||||
});
|
||||
|
||||
it("uses the Darwin per-user temporary root when a GUI client omits TMPDIR", () => {
|
||||
expect(getDefaultBridgeTempDir(
|
||||
"darwin",
|
||||
"/tmp",
|
||||
() => "/var/folders/example/T",
|
||||
{},
|
||||
).replaceAll("\\", "/")).toBe("/var/folders/example/T/premiere-mcp-bridge");
|
||||
});
|
||||
|
||||
it("falls back safely when the Darwin temporary-root lookup is unavailable", () => {
|
||||
expect(getDefaultBridgeTempDir("darwin", "/tmp", () => null, {}).replaceAll("\\", "/"))
|
||||
.toBe("/tmp/premiere-mcp-bridge");
|
||||
});
|
||||
|
||||
it("preserves a configured Darwin temporary root", () => {
|
||||
expect(getDefaultBridgeTempDir(
|
||||
"darwin",
|
||||
"/configured/T",
|
||||
() => "/var/folders/example/T",
|
||||
{ TMPDIR: "/configured/T" },
|
||||
).replaceAll("\\", "/")).toBe("/configured/T/premiere-mcp-bridge");
|
||||
});
|
||||
|
||||
it("prefers options.tempDir over env var", () => {
|
||||
process.env.PREMIERE_TEMP_DIR = "/env/dir";
|
||||
expect(getTempDir({ tempDir: "/custom/dir" })).toBe("/custom/dir");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDarwinUserTempDirectory", () => {
|
||||
it("uses the per-user macOS temporary root reported by getconf", () => {
|
||||
mockedExecFileSync.mockReturnValue("/var/folders/example/T/\n" as never);
|
||||
|
||||
expect(getDarwinUserTempDirectory()).toBe("/var/folders/example/T/");
|
||||
expect(mockedExecFileSync).toHaveBeenCalledWith("/usr/bin/getconf", ["DARWIN_USER_TEMP_DIR"], {
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a non-absolute getconf result", () => {
|
||||
mockedExecFileSync.mockReturnValue("relative-temp\n" as never);
|
||||
|
||||
expect(getDarwinUserTempDirectory()).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back when the getconf lookup fails", () => {
|
||||
mockedExecFileSync.mockImplementation(() => { throw new Error("unavailable"); });
|
||||
|
||||
expect(getDarwinUserTempDirectory()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
mockedStatSync.mockReturnValue({ uid: myUid, mode: 0o700 } as unknown as ReturnType<typeof statSync>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("creates temp directory if it does not exist", async () => {
|
||||
let dirCreated = false;
|
||||
mockedExistsSync.mockImplementation((path) => {
|
||||
const p = String(path);
|
||||
// The temp dir itself does not exist (until mkdirSync is called)
|
||||
if (p === "/tmp/test-bridge" && !dirCreated) return false;
|
||||
if (p.includes("res_")) return true; // response exists immediately
|
||||
return true;
|
||||
});
|
||||
mockedMkdirSync.mockImplementation(() => {
|
||||
dirCreated = true;
|
||||
return undefined;
|
||||
});
|
||||
mockedReadFileSync.mockReturnValue('{"success":true,"data":{"ok":true}}');
|
||||
|
||||
const promise = sendCommand("test script", { tempDir: "/tmp/test-bridge" });
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
await promise;
|
||||
|
||||
expect(mockedMkdirSync).toHaveBeenCalledWith("/tmp/test-bridge", {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
});
|
||||
|
||||
// Security: the bridge temp dir sits at a predictable, world-accessible path, and the
|
||||
// CEP panel executes any cmd_*.jsx it finds there. On shared machines that dir must be
|
||||
// ours and private. See the ensureDir hardening.
|
||||
it("refuses to use an existing temp dir owned by another user", async () => {
|
||||
if (typeof process.getuid !== "function") return; // POSIX-only guard
|
||||
mockedExistsSync.mockReturnValue(true); // dir already exists
|
||||
mockedStatSync.mockReturnValueOnce({
|
||||
uid: process.getuid!() + 1, // someone else owns it
|
||||
mode: 0o700,
|
||||
} as unknown as ReturnType<typeof statSync>);
|
||||
|
||||
await expect(sendCommand("var x = 1;", { tempDir: "/tmp/evil-bridge" })).rejects.toThrow(
|
||||
/owned by uid .* not this user/
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps a group/world-accessible existing temp dir back to 0700", async () => {
|
||||
if (typeof process.getuid !== "function") return;
|
||||
mockedExistsSync.mockImplementation((p) => (String(p).includes("res_") ? true : true));
|
||||
mockedStatSync.mockReturnValueOnce({
|
||||
uid: process.getuid!(),
|
||||
mode: 0o755, // ours, but world-readable
|
||||
} as unknown as ReturnType<typeof statSync>);
|
||||
mockedReadFileSync.mockReturnValue('{"success":true,"data":{}}');
|
||||
|
||||
const promise = sendCommand("var x = 1;", { tempDir: "/tmp/test-bridge" });
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
await promise;
|
||||
|
||||
expect(mockedChmodSync).toHaveBeenCalledWith("/tmp/test-bridge", 0o700);
|
||||
});
|
||||
|
||||
it("atomically publishes a complete command file as .jsx", async () => {
|
||||
mockedExistsSync.mockImplementation((path) => {
|
||||
if (String(path).includes("res_")) return true;
|
||||
return true;
|
||||
});
|
||||
mockedReadFileSync.mockReturnValue('{"success":true,"data":{}}');
|
||||
|
||||
const promise = sendCommand("var x = 1;", { tempDir: "/tmp/test-bridge" });
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
await promise;
|
||||
|
||||
const writeCall = mockedWriteFileSync.mock.calls.find(([path]) => String(path).endsWith(".jsx.staged"));
|
||||
expect(writeCall).toBeDefined();
|
||||
expect(String(writeCall?.[0])).toMatch(/cmd_.*\.jsx\.staged$/);
|
||||
const publishedPath = mockedRenameSync.mock.calls[0]?.[1];
|
||||
expect(String(publishedPath)).toMatch(/cmd_.*\.jsx$/);
|
||||
// command = one-line helpers bootstrap, then the script itself
|
||||
const content = String(writeCall?.[1]);
|
||||
expect(content.endsWith("\nvar x = 1;")).toBe(true);
|
||||
expect(content.replaceAll("\\\\", "/")).toContain('$.evalFile("/tmp/test-bridge/helpers_');
|
||||
expect(writeCall?.[2]).toBe("utf-8");
|
||||
});
|
||||
|
||||
it("returns parsed JSON response", async () => {
|
||||
mockedExistsSync.mockImplementation((path) => {
|
||||
if (String(path).includes("res_")) return true;
|
||||
return true;
|
||||
});
|
||||
mockedReadFileSync.mockReturnValue('{"success":true,"data":{"version":"24.0"}}');
|
||||
|
||||
const promise = sendCommand("test", { tempDir: "/tmp/test-bridge" });
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toEqual({ success: true, data: { version: "24.0" } });
|
||||
});
|
||||
|
||||
it("rejects an oversized bridge response before reading it into memory", async () => {
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedStatSync.mockReturnValue({
|
||||
uid: myUid,
|
||||
mode: 0o700,
|
||||
size: MAX_BRIDGE_RESPONSE_BYTES + 1,
|
||||
} as unknown as ReturnType<typeof statSync>);
|
||||
|
||||
await expect(sendCommand("test", { tempDir: "/tmp/test-bridge" })).resolves.toEqual({
|
||||
success: false,
|
||||
error: `Bridge response exceeds the ${MAX_BRIDGE_RESPONSE_BYTES}-byte limit`,
|
||||
});
|
||||
expect(mockedReadFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses distinct cryptographic IDs for concurrently-created command files", async () => {
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedReadFileSync.mockReturnValue('{"success":true}');
|
||||
|
||||
await Promise.all([
|
||||
sendCommand("first", { tempDir: "/tmp/test-bridge" }),
|
||||
sendCommand("second", { tempDir: "/tmp/test-bridge" }),
|
||||
]);
|
||||
|
||||
const publishedPaths = mockedRenameSync.mock.calls.map(([, target]) => String(target));
|
||||
expect(publishedPaths).toHaveLength(2);
|
||||
expect(new Set(publishedPaths).size).toBe(2);
|
||||
expect(publishedPaths.every((path) => /cmd_[0-9a-f-]{36}\.jsx$/.test(path))).toBe(true);
|
||||
});
|
||||
|
||||
it("attempts event-driven response watching before using the polling fallback", async () => {
|
||||
mockedExistsSync.mockImplementation((path) => String(path).includes("res_"));
|
||||
mockedReadFileSync.mockReturnValue('{"success":true,"data":{}}');
|
||||
|
||||
const promise = sendCommand("test", { tempDir: "/tmp/test-bridge" });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await promise;
|
||||
|
||||
expect(mockedWatch).toHaveBeenCalledWith(
|
||||
dirname(join("/tmp/test-bridge", "response.json")),
|
||||
{ persistent: false },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves immediately when the watcher reports the response file", async () => {
|
||||
let responseExists = false;
|
||||
let onChange: ((event: string, filename: string) => void) | undefined;
|
||||
const fakeWatcher = { on: vi.fn().mockReturnThis(), close: vi.fn() };
|
||||
mockedWatch.mockImplementationOnce(((_path, _options, listener) => {
|
||||
onChange = listener as (event: string, filename: string) => void;
|
||||
return fakeWatcher;
|
||||
}) as typeof watch);
|
||||
mockedExistsSync.mockImplementation((path) =>
|
||||
String(path).includes("res_") ? responseExists : true,
|
||||
);
|
||||
mockedReadFileSync.mockReturnValue('{"success":true,"data":{"eventDriven":true}}');
|
||||
|
||||
const promise = sendCommand("test", { tempDir: "/tmp/test-bridge" });
|
||||
const responsePath = mockedWatch.mock.calls[0]?.[0];
|
||||
expect(responsePath).toBeDefined();
|
||||
responseExists = true;
|
||||
const commandPath = String(mockedRenameSync.mock.calls[0]?.[1]);
|
||||
const responseName = commandPath.replace(/.*[\\/]+cmd_/, "res_").replace(/\.jsx$/, ".json");
|
||||
onChange?.("rename", responseName);
|
||||
|
||||
await expect(promise).resolves.toEqual({ success: true, data: { eventDriven: true } });
|
||||
expect(fakeWatcher.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serializes concurrent bridge commands before they reach CEP", async () => {
|
||||
const readyResponses = new Set<string>();
|
||||
let onChange: ((event: string, filename: string) => void) | undefined;
|
||||
const fakeWatcher = { on: vi.fn().mockReturnThis(), close: vi.fn() };
|
||||
mockedWatch.mockImplementation(((_path, _options, listener) => {
|
||||
onChange = listener as (event: string, filename: string) => void;
|
||||
return fakeWatcher;
|
||||
}) as typeof watch);
|
||||
mockedExistsSync.mockImplementation((path) => {
|
||||
const value = String(path);
|
||||
return value.includes("res_") ? readyResponses.has(value) : true;
|
||||
});
|
||||
mockedReadFileSync.mockReturnValue('{"success":true,"data":{"shared":true}}');
|
||||
|
||||
const pending = [
|
||||
sendCommand("first", { tempDir: "/tmp/shared-watch-bridge" }),
|
||||
sendCommand("second", { tempDir: "/tmp/shared-watch-bridge" }),
|
||||
];
|
||||
expect(mockedWatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockedRenameSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
const signalResponse = (commandPath: unknown) => {
|
||||
const responsePath = String(commandPath)
|
||||
.replace(/cmd_/, "res_")
|
||||
.replace(/\.jsx$/, ".json");
|
||||
readyResponses.add(responsePath);
|
||||
onChange?.("rename", responsePath.split(/[\\/]/).pop()!);
|
||||
};
|
||||
signalResponse(mockedRenameSync.mock.calls[0]?.[1]);
|
||||
await expect(pending[0]).resolves.toEqual({ success: true, data: { shared: true } });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(mockedRenameSync).toHaveBeenCalledTimes(2);
|
||||
expect(mockedWatch).toHaveBeenCalledTimes(2);
|
||||
signalResponse(mockedRenameSync.mock.calls[1]?.[1]);
|
||||
|
||||
await expect(pending[1]).resolves.toEqual({ success: true, data: { shared: true } });
|
||||
expect(fakeWatcher.close).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fails fast when a bridge directory already has the bounded command backlog", async () => {
|
||||
mockedExistsSync.mockImplementation((path) => !String(path).includes("res_"));
|
||||
const commands = Array.from({ length: 34 }, (_, index) => sendCommand(`queued-${index}`, {
|
||||
tempDir: "/tmp/queue-capacity-bridge",
|
||||
}));
|
||||
|
||||
await expect(commands[33]).resolves.toEqual({
|
||||
success: false,
|
||||
error: "Bridge command queue is full (32 waiting); retry after an active command finishes",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps polling a malformed response without resending the command", async () => {
|
||||
mockedExistsSync.mockImplementation((path) => {
|
||||
if (String(path).includes("res_")) return true;
|
||||
if (String(path).includes("busy_")) return false;
|
||||
return true;
|
||||
});
|
||||
mockedReadFileSync.mockReturnValue("not valid json{{{");
|
||||
|
||||
const promise = sendCommand("test", { tempDir: "/tmp/test-bridge", timeoutMs: 500 });
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Failed to parse response");
|
||||
expect(mockedRenameSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns timeout error when response file never appears", async () => {
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
|
||||
const promise = sendCommand("test", {
|
||||
tempDir: "/tmp/test-bridge",
|
||||
timeoutMs: 500,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("timed out");
|
||||
expect(result.error).toContain("CEP plugin");
|
||||
});
|
||||
|
||||
it("fails health-style commands before publication when a current connector is waiting", async () => {
|
||||
vi.setSystemTime(new Date(10_000));
|
||||
mockedExistsSync.mockImplementation((path) => String(path).includes("bridge-heartbeat"));
|
||||
mockedReadFileSync.mockReturnValue('{"protocolVersion":1,"state":"waiting"}');
|
||||
mockedStatSync.mockReturnValue({
|
||||
uid: myUid,
|
||||
mode: 0o700,
|
||||
mtimeMs: 9_500,
|
||||
} as unknown as ReturnType<typeof statSync>);
|
||||
|
||||
await expect(sendCommand("var health = true;", {
|
||||
tempDir: "/tmp/test-bridge",
|
||||
failFastOnUnreadyHeartbeat: true,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("not running") });
|
||||
expect(mockedRenameSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails health-style commands before publication when a known connector heartbeat is stale", async () => {
|
||||
vi.setSystemTime(new Date(10_000));
|
||||
mockedExistsSync.mockImplementation((path) => String(path).includes("bridge-heartbeat"));
|
||||
mockedReadFileSync.mockReturnValue('{"protocolVersion":1,"state":"running"}');
|
||||
mockedStatSync.mockReturnValue({
|
||||
uid: myUid,
|
||||
mode: 0o700,
|
||||
mtimeMs: 1_000,
|
||||
} as unknown as ReturnType<typeof statSync>);
|
||||
|
||||
await expect(sendCommand("var health = true;", {
|
||||
tempDir: "/tmp/test-bridge",
|
||||
failFastOnUnreadyHeartbeat: true,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("heartbeat is stale") });
|
||||
expect(mockedRenameSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to normal command delivery when no heartbeat exists", async () => {
|
||||
mockedExistsSync.mockImplementation((path) => String(path).includes("res_"));
|
||||
mockedReadFileSync.mockReturnValue('{"success":true}');
|
||||
|
||||
await expect(sendCommand("var legacy = true;", {
|
||||
tempDir: "/tmp/test-bridge",
|
||||
failFastOnUnreadyHeartbeat: true,
|
||||
})).resolves.toEqual({ success: true });
|
||||
expect(mockedRenameSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans up command and response files after success", async () => {
|
||||
let responseExists = false;
|
||||
mockedExistsSync.mockImplementation((path) => {
|
||||
if (String(path).includes("res_")) return responseExists;
|
||||
return true;
|
||||
});
|
||||
mockedReadFileSync.mockReturnValue('{"success":true,"data":{}}');
|
||||
|
||||
const promise = sendCommand("test", { tempDir: "/tmp/test-bridge" });
|
||||
responseExists = true;
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
await promise;
|
||||
|
||||
// unlinkSync should be called for both cmd and res files
|
||||
expect(mockedUnlinkSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects scripts containing eval()", async () => {
|
||||
await expect(
|
||||
sendCommand('eval("dangerous")', { tempDir: "/tmp/test-bridge" })
|
||||
).rejects.toThrow("blocked pattern");
|
||||
});
|
||||
|
||||
it("rejects scripts containing new Function()", async () => {
|
||||
await expect(
|
||||
sendCommand('new Function("code")', { tempDir: "/tmp/test-bridge" })
|
||||
).rejects.toThrow("blocked pattern");
|
||||
});
|
||||
|
||||
it("rejects scripts containing System.callSystem()", async () => {
|
||||
await expect(
|
||||
sendCommand('System.callSystem("rm -rf /")', {
|
||||
tempDir: "/tmp/test-bridge",
|
||||
})
|
||||
).rejects.toThrow("blocked pattern");
|
||||
});
|
||||
|
||||
it("rejects scripts exceeding 500KB", async () => {
|
||||
const largeScript = "x".repeat(501 * 1024);
|
||||
await expect(
|
||||
sendCommand(largeScript, { tempDir: "/tmp/test-bridge" })
|
||||
).rejects.toThrow("500KB size limit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBridgeLiveness", () => {
|
||||
it("reports fresh, stale, and unknown states without exposing heartbeat contents", () => {
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedReadFileSync.mockReturnValue('{"protocolVersion":1,"state":"running"}');
|
||||
mockedStatSync.mockReturnValue({ mtimeMs: 9_000 } as ReturnType<typeof statSync>);
|
||||
expect(getBridgeLiveness({ tempDir: "/tmp/test-bridge" }, 10_000)).toEqual({
|
||||
state: "running",
|
||||
ageMs: 1_000,
|
||||
});
|
||||
|
||||
mockedStatSync.mockReturnValue({ mtimeMs: 1_000 } as ReturnType<typeof statSync>);
|
||||
expect(getBridgeLiveness({ tempDir: "/tmp/test-bridge" }, 10_000)).toEqual({
|
||||
state: "stale",
|
||||
ageMs: 9_000,
|
||||
});
|
||||
|
||||
mockedReadFileSync.mockReturnValue('{"state":"running"}');
|
||||
expect(getBridgeLiveness({ tempDir: "/tmp/test-bridge" }, 10_000)).toEqual({
|
||||
state: "unknown",
|
||||
ageMs: null,
|
||||
});
|
||||
|
||||
// This module-level fs mock is shared with the following raw-command
|
||||
// suite, which exercises the POSIX ownership guard.
|
||||
mockedStatSync.mockReturnValue({
|
||||
uid: myUid,
|
||||
mode: 0o700,
|
||||
mtimeMs: Date.now(),
|
||||
} as unknown as ReturnType<typeof statSync>);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendRawCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("allows eval() in raw commands", async () => {
|
||||
mockedExistsSync.mockImplementation((path) => {
|
||||
if (String(path).includes("res_")) return true;
|
||||
return true;
|
||||
});
|
||||
mockedReadFileSync.mockReturnValue('{"success":true,"data":{}}');
|
||||
|
||||
const promise = sendRawCommand('eval("1+1")', {
|
||||
tempDir: "/tmp/test-bridge",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("still enforces size limit on raw commands", async () => {
|
||||
const largeScript = "x".repeat(501 * 1024);
|
||||
await expect(
|
||||
sendRawCommand(largeScript, { tempDir: "/tmp/test-bridge" })
|
||||
).rejects.toThrow("500KB size limit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupTempDir", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("removes cmd_ and res_ files", () => {
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedReaddirSync.mockReturnValue([
|
||||
"cmd_123.jsx" as any,
|
||||
"cmd_124.jsx.staged" as any,
|
||||
"res_123.json" as any,
|
||||
"other_file.txt" as any,
|
||||
]);
|
||||
|
||||
cleanupTempDir({ tempDir: "/tmp/test-bridge" });
|
||||
|
||||
// Should unlink cmd_ and res_ files but not other_file.txt
|
||||
const unlinkCalls = mockedUnlinkSync.mock.calls.map((c) => String(c[0]));
|
||||
expect(unlinkCalls).toContainEqual(
|
||||
join("/tmp/test-bridge", "cmd_123.jsx")
|
||||
);
|
||||
expect(unlinkCalls).toContainEqual(
|
||||
join("/tmp/test-bridge", "res_123.json")
|
||||
);
|
||||
expect(unlinkCalls).toContainEqual(
|
||||
join("/tmp/test-bridge", "cmd_124.jsx.staged")
|
||||
);
|
||||
expect(unlinkCalls).not.toContainEqual(
|
||||
join("/tmp/test-bridge", "other_file.txt")
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing if temp dir does not exist", () => {
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
cleanupTempDir({ tempDir: "/tmp/nonexistent" });
|
||||
expect(mockedReaddirSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles errors gracefully", () => {
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedReaddirSync.mockImplementation(() => {
|
||||
throw new Error("permission denied");
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
expect(() => cleanupTempDir({ tempDir: "/tmp/test-bridge" })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildScript, escapeForExtendScript, buildToolScript, getHelpersSource, buildBootstrap, helpersFileName, HELPERS_VERSION } from "../../src/bridge/script-builder.js";
|
||||
import { runInNewContext } from "node:vm";
|
||||
|
||||
describe("buildScript", () => {
|
||||
it("wraps code in an IIFE with try/catch", () => {
|
||||
const result = buildScript("return __result({ ok: true });");
|
||||
expect(result).toContain("(function() {");
|
||||
expect(result).toContain("return __result({ ok: true });");
|
||||
expect(result).toContain("} catch(e) {");
|
||||
expect(result).toContain("return __error(e.toString());");
|
||||
expect(result).toContain("})();");
|
||||
});
|
||||
|
||||
it("defines helper functions in the helpers source", () => {
|
||||
const result = getHelpersSource();
|
||||
expect(result).toContain("var TICKS_PER_SECOND = 254016000000;");
|
||||
expect(result).toContain("function __ticksToSeconds(ticks)");
|
||||
expect(result).toContain("function __secondsToTicks(seconds)");
|
||||
expect(result).toContain("function __ticksToTimecode(ticks, fps)");
|
||||
expect(result).toContain("function __pad(n)");
|
||||
expect(result).toContain("function __findSequence(idOrName)");
|
||||
expect(result).toContain("function __findProjectItem(nodeIdOrName, rootItem)");
|
||||
expect(result).toContain("function __findClip(nodeId)");
|
||||
expect(result).toContain("function __getAllClips(seq)");
|
||||
expect(result).toContain("function __jsonStringify(obj)");
|
||||
expect(result).toContain("function __result(data)");
|
||||
expect(result).toContain("function __error(msg)");
|
||||
});
|
||||
|
||||
it("preserves multi-line code blocks", () => {
|
||||
const code = `var x = 1;
|
||||
var y = 2;
|
||||
return __result({ sum: x + y });`;
|
||||
const result = buildScript(code);
|
||||
expect(result).toContain("var x = 1;");
|
||||
expect(result).toContain("var y = 2;");
|
||||
expect(result).toContain("return __result({ sum: x + y });");
|
||||
});
|
||||
|
||||
it("handles empty code", () => {
|
||||
const result = buildScript("");
|
||||
expect(result).toContain("(function() {");
|
||||
expect(result).toContain("})();");
|
||||
});
|
||||
|
||||
it("returns a string", () => {
|
||||
expect(typeof buildScript("")).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildToolScript (alias)", () => {
|
||||
it("is the same function as buildScript", () => {
|
||||
expect(buildToolScript).toBe(buildScript);
|
||||
});
|
||||
|
||||
it("produces identical output to buildScript", () => {
|
||||
const code = "return __result({ test: true });";
|
||||
expect(buildToolScript(code)).toBe(buildScript(code));
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeForExtendScript", () => {
|
||||
it("escapes backslashes", () => {
|
||||
expect(escapeForExtendScript("C:\\Users\\test")).toBe("C:\\\\Users\\\\test");
|
||||
});
|
||||
|
||||
it("escapes double quotes", () => {
|
||||
expect(escapeForExtendScript('say "hello"')).toBe('say \\"hello\\"');
|
||||
});
|
||||
|
||||
it("escapes single quotes", () => {
|
||||
expect(escapeForExtendScript("it's")).toBe("it\\'s");
|
||||
});
|
||||
|
||||
it("escapes newlines", () => {
|
||||
expect(escapeForExtendScript("line1\nline2")).toBe("line1\\nline2");
|
||||
});
|
||||
|
||||
it("escapes carriage returns", () => {
|
||||
expect(escapeForExtendScript("line1\rline2")).toBe("line1\\rline2");
|
||||
});
|
||||
|
||||
it("escapes tabs", () => {
|
||||
expect(escapeForExtendScript("col1\tcol2")).toBe("col1\\tcol2");
|
||||
});
|
||||
|
||||
it("handles empty strings", () => {
|
||||
expect(escapeForExtendScript("")).toBe("");
|
||||
});
|
||||
|
||||
it("handles strings with no special characters", () => {
|
||||
expect(escapeForExtendScript("hello world")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("handles multiple escape characters in one string", () => {
|
||||
const input = 'C:\\path\\to\n"file"\t\'test\'';
|
||||
const result = escapeForExtendScript(input);
|
||||
expect(result).toBe('C:\\\\path\\\\to\\n\\"file\\"\\t\\\'test\\\'');
|
||||
});
|
||||
|
||||
it("handles unicode characters (passes through)", () => {
|
||||
expect(escapeForExtendScript("日本語")).toBe("日本語");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated script structure", () => {
|
||||
it("bootstrap loads this exact helpers version via $.evalFile", () => {
|
||||
const bootstrap = buildBootstrap("/tmp/x/" + helpersFileName());
|
||||
expect(bootstrap).toContain(`__HELPERS_V !== "${HELPERS_VERSION}"`);
|
||||
expect(bootstrap).toContain(`$.evalFile("/tmp/x/helpers_${HELPERS_VERSION}.jsx")`);
|
||||
expect(getHelpersSource()).toContain(`var __HELPERS_V = "${HELPERS_VERSION}";`);
|
||||
});
|
||||
|
||||
it("bootstrap escapes quotes and backslashes in the helpers path", () => {
|
||||
const bootstrap = buildBootstrap('C:\\temp\\he"rs.jsx');
|
||||
expect(bootstrap).toContain('$.evalFile("C:\\\\temp\\\\he\\"rs.jsx")');
|
||||
});
|
||||
|
||||
it("__findProjectItem recursively searches bins", () => {
|
||||
const result = getHelpersSource();
|
||||
expect(result).toContain("if (item.type === 2)");
|
||||
expect(result).toContain("var found = __findProjectItem(nodeIdOrName, item);");
|
||||
});
|
||||
|
||||
it("normalizes numeric host IDs without changing exact name matching", () => {
|
||||
const result = getHelpersSource();
|
||||
expect(result).toContain("var wantedId = String(idOrName);");
|
||||
expect(result).toContain("String(seq.sequenceID) === wantedId || seq.name === idOrName");
|
||||
expect(result).toContain("var wantedId = String(nodeIdOrName);");
|
||||
expect(result).toContain("String(item.nodeId) === wantedId || item.name === nodeIdOrName");
|
||||
expect(result).toContain("var wantedId = String(nodeId);");
|
||||
expect(result).toContain("String(clip.nodeId) === wantedId");
|
||||
});
|
||||
|
||||
it("__findClip searches both video and audio tracks", () => {
|
||||
const result = getHelpersSource();
|
||||
expect(result).toContain("seq.videoTracks.numTracks");
|
||||
expect(result).toContain("seq.audioTracks.numTracks");
|
||||
expect(result).toContain('trackType: "video"');
|
||||
expect(result).toContain('trackType: "audio"');
|
||||
});
|
||||
|
||||
it("__jsonStringify handles all types", () => {
|
||||
const result = getHelpersSource();
|
||||
expect(result).toContain('if (obj === null) return "null"');
|
||||
expect(result).toContain('if (typeof obj === "string")');
|
||||
expect(result).toContain('if (typeof obj === "number"');
|
||||
expect(result).toContain("if (obj instanceof Array)");
|
||||
expect(result).toContain('if (typeof obj === "object")');
|
||||
});
|
||||
});
|
||||
|
||||
describe("helpers execute correctly in an ES3-like engine", () => {
|
||||
it("__result works when JSON is undefined (polyfill must not recurse into itself)", () => {
|
||||
// Regression: __jsonStringify once delegated to JSON.stringify while the JSON
|
||||
// polyfill delegated back to __jsonStringify — infinite mutual recursion that
|
||||
// stack-overran the shared ExtendScript engine on every tool response.
|
||||
const sandbox: Record<string, unknown> = { JSON: undefined };
|
||||
const out = runInNewContext(
|
||||
getHelpersSource() + '\n__result({ connected: true, nested: { n: 1, arr: [1, "a", false, null] } });',
|
||||
sandbox
|
||||
);
|
||||
expect(out).toBe('{"success":true,"data":{"connected":true,"nested":{"n":1,"arr":[1,"a",false,null]}}}');
|
||||
});
|
||||
|
||||
it("a stale wrapper from an older helpers version gets replaced", () => {
|
||||
// Simulate a polluted long-lived engine: JSON.stringify is our old-style wrapper.
|
||||
const stale = { stringify: function badWrapper(o: unknown) { return "__jsonStringify" + String(o); } };
|
||||
const sandbox: Record<string, unknown> = { JSON: stale };
|
||||
runInNewContext(getHelpersSource(), sandbox);
|
||||
const json = sandbox.JSON as { stringify: (o: unknown) => string; __mcpPolyfill?: boolean };
|
||||
expect(json.__mcpPolyfill).toBe(true);
|
||||
expect(json.stringify({ ok: 1 })).toBe('{"ok":1}');
|
||||
});
|
||||
});
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildCaptionTimingPlan, parseCaptionArtifact } from "../src/ai/caption-timing.js";
|
||||
|
||||
vi.mock("../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getCaptionTools } from "../src/tools/captions.js";
|
||||
|
||||
const SRT = `1
|
||||
00:00:00,000 --> 00:00:02,000
|
||||
Opening
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:07,000
|
||||
Middle
|
||||
|
||||
3
|
||||
00:00:08,000 --> 00:00:10,000
|
||||
Ending`;
|
||||
|
||||
describe("caption timing plans", () => {
|
||||
it("parses bounded SRT and VTT artifacts without exposing cue text in the plan", () => {
|
||||
const srt = parseCaptionArtifact(SRT, "srt");
|
||||
const vtt = parseCaptionArtifact(`WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:02.000 align:start
|
||||
Opening`, "vtt");
|
||||
expect(srt.cues).toHaveLength(3);
|
||||
expect(vtt.cues).toEqual([{ startSeconds: 0, endSeconds: 2 }]);
|
||||
|
||||
const plan = buildCaptionTimingPlan(SRT, "srt", { targetDurationSeconds: 10 });
|
||||
expect(plan).toMatchObject({
|
||||
status: "aligned",
|
||||
applied: false,
|
||||
cueCount: 3,
|
||||
correction: { proposed: false, kind: "none" },
|
||||
});
|
||||
expect(JSON.stringify(plan)).not.toContain("Opening");
|
||||
});
|
||||
|
||||
it("proposes only a safe constant-offset correction", () => {
|
||||
const plan = buildCaptionTimingPlan(SRT, "srt", {
|
||||
observedOffsetSeconds: -1,
|
||||
targetDurationSeconds: 11,
|
||||
});
|
||||
expect(plan).toMatchObject({
|
||||
status: "constant_offset",
|
||||
correction: { kind: "shift", shiftSeconds: 1, scale: 1, proposed: true },
|
||||
});
|
||||
expect(plan.samples.at(-1)).toMatchObject({
|
||||
before: { endSeconds: 10 },
|
||||
after: { endSeconds: 11 },
|
||||
});
|
||||
});
|
||||
|
||||
it("requires explicit authorization before proposing proportional drift correction", () => {
|
||||
const withheld = buildCaptionTimingPlan(SRT, "srt", { targetDurationSeconds: 12 });
|
||||
expect(withheld).toMatchObject({ status: "review_required", correction: { proposed: false, kind: "none" } });
|
||||
|
||||
const planned = buildCaptionTimingPlan(SRT, "srt", {
|
||||
targetDurationSeconds: 12,
|
||||
allowProportionalScaling: true,
|
||||
});
|
||||
expect(planned).toMatchObject({
|
||||
status: "proportional_drift",
|
||||
correction: { kind: "scale", proposed: true, scale: 1.2 },
|
||||
});
|
||||
expect(planned.samples.at(-1)).toMatchObject({ after: { endSeconds: 12 } });
|
||||
});
|
||||
|
||||
it("rejects invalid timing, overlap, and unsafe leading negative correction", () => {
|
||||
expect(() => parseCaptionArtifact("1\n00:00:02,000 --> 00:00:01,000\nBad", "srt"))
|
||||
.toThrow("must end after");
|
||||
expect(() => parseCaptionArtifact("1\n00:00:00,000 --> 00:00:02,000\nA\n\n2\n00:00:01,000 --> 00:00:03,000\nB", "srt"))
|
||||
.toThrow("overlaps");
|
||||
expect(() => parseCaptionArtifact("00:00:00,000 --> later\nBad", "srt"))
|
||||
.toThrow("invalid timecode");
|
||||
|
||||
const unsafe = buildCaptionTimingPlan(SRT, "srt", { observedOffsetSeconds: 1 });
|
||||
expect(unsafe).toMatchObject({ status: "review_required", correction: { proposed: false, shiftSeconds: 0 } });
|
||||
});
|
||||
|
||||
it("rejects malformed caller input and withholds ambiguous duration scaling", () => {
|
||||
expect(() => parseCaptionArtifact("", "srt")).toThrow("caption_content is required");
|
||||
expect(() => parseCaptionArtifact(SRT, "txt")).toThrow("artifact_format");
|
||||
expect(() => parseCaptionArtifact("00:00:00,000 --> 00:00:01,000\nNo header", "vtt"))
|
||||
.toThrow("begin with WEBVTT");
|
||||
expect(() => parseCaptionArtifact("NOTE an ignored note", "srt")).toThrow("no timed cues");
|
||||
expect(() => buildCaptionTimingPlan(SRT, "srt", { allowProportionalScaling: "yes" as unknown as boolean }))
|
||||
.toThrow("allow_proportional_scaling");
|
||||
|
||||
const leadIn = "1\n00:00:02,000 --> 00:00:04,000\nOpening";
|
||||
expect(buildCaptionTimingPlan(leadIn, "srt", {
|
||||
targetDurationSeconds: 8,
|
||||
allowProportionalScaling: true,
|
||||
})).toMatchObject({ status: "review_required", correction: { proposed: false, kind: "none" } });
|
||||
|
||||
expect(buildCaptionTimingPlan(SRT, "srt", {
|
||||
targetDurationSeconds: 30,
|
||||
allowProportionalScaling: true,
|
||||
})).toMatchObject({ status: "review_required", correction: { proposed: false, kind: "none" } });
|
||||
});
|
||||
|
||||
it("keeps a caller-owned one-cue artifact aligned without optional timing inputs", () => {
|
||||
const plan = buildCaptionTimingPlan("1\n00:00:01,000 --> 00:00:02,000\nOnly cue", "srt");
|
||||
expect(plan).toMatchObject({
|
||||
status: "aligned",
|
||||
samples: [{ position: "beginning", cueNumber: 1 }],
|
||||
});
|
||||
expect(plan).not.toHaveProperty("targetDurationSeconds");
|
||||
expect(plan).not.toHaveProperty("observedOffsetSeconds");
|
||||
expect(plan.samples[0]).not.toHaveProperty("after");
|
||||
});
|
||||
});
|
||||
|
||||
describe("guided lecture-caption action", () => {
|
||||
it("returns a local review plan with separate structural, playback, and rendered-output states", async () => {
|
||||
const tool = getCaptionTools({}).create_caption_track as any;
|
||||
const result = await tool.handler({
|
||||
action: "plan_lecture_workflow",
|
||||
caption_content: SRT,
|
||||
artifact_format: "srt",
|
||||
target_duration_seconds: 10,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
applied: false,
|
||||
workflow: {
|
||||
id: "guided_lecture_caption_workflow",
|
||||
appliesCaptionArtifact: false,
|
||||
verification: {
|
||||
structuralReadback: "not_run",
|
||||
playbackReview: "not_run",
|
||||
renderedOutputReview: "not_run",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.data.workflow.steps.map((step: { route: string }) => step.route)).toEqual([
|
||||
"manage_sequences_uxp",
|
||||
"create_caption_track",
|
||||
"create_caption_track",
|
||||
"read_sequence_captions",
|
||||
"export_sequence_review_frames",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves import behavior and rejects an import without its project item", async () => {
|
||||
const tool = getCaptionTools({}).create_caption_track as any;
|
||||
await expect(tool.handler({})).resolves.toMatchObject({ success: false, error: expect.stringContaining("item_id") });
|
||||
});
|
||||
});
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
|
||||
describe("CEP installation metadata", () => {
|
||||
it("keeps the CEP bundle and extension versions aligned with package.json", () => {
|
||||
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
const manifest = readFileSync(join(root, "cep-plugin", "CSXS", "manifest.xml"), "utf8");
|
||||
const updater = readFileSync(join(root, "cep-plugin", "updater.cjs"), "utf8");
|
||||
|
||||
expect(manifest).toContain(`ExtensionBundleVersion="${pkg.version}"`);
|
||||
expect(manifest.match(new RegExp(`Version="${pkg.version.replaceAll(".", "\\.")}"`, "g"))).toHaveLength(3);
|
||||
expect(updater).toContain(`CURRENT_VERSION = "${pkg.version}"`);
|
||||
});
|
||||
|
||||
it("declares Premiere compatibility with a Marketplace-safe minimum only", () => {
|
||||
const manifest = readFileSync(join(root, "cep-plugin", "CSXS", "manifest.xml"), "utf8");
|
||||
|
||||
expect(manifest).toContain('<Host Name="PPRO" Version="14.0"/>');
|
||||
expect(manifest).not.toMatch(/<Host Name="PPRO" Version="[[(]/);
|
||||
});
|
||||
|
||||
it("ships a separately addressed After Effects authoring connector", () => {
|
||||
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
const manifest = readFileSync(join(root, "after-effects-cep-plugin", "CSXS", "manifest.xml"), "utf8");
|
||||
const panel = readFileSync(join(root, "after-effects-cep-plugin", "main.js"), "utf8");
|
||||
const cli = readFileSync(join(root, "src", "index.ts"), "utf8");
|
||||
|
||||
expect(pkg.files).toContain("after-effects-cep-plugin");
|
||||
expect(manifest).toContain(`ExtensionBundleVersion="${pkg.version}"`);
|
||||
expect(manifest).toContain('<Host Name="AEFT" Version="15.0"/>');
|
||||
expect(panel).toContain("after-effects-mcp-bridge");
|
||||
expect(panel).toContain("AFTER_EFFECTS_MCP_TEMP_DIR");
|
||||
expect(cli).toContain("--install-after-effects-cep");
|
||||
expect(cli).toContain('powershellArgs.push("-ConnectorHost", "AfterEffects")');
|
||||
});
|
||||
|
||||
it("documents the Windows unsigned-extension value as REG_SZ", () => {
|
||||
const readme = readFileSync(join(root, "README.md"), "utf8");
|
||||
const installer = readFileSync(join(root, "scripts", "install-cep.ps1"), "utf8");
|
||||
|
||||
expect(readme).toContain("String (`REG_SZ`)");
|
||||
expect(readme).not.toContain("set these DWORD values");
|
||||
expect(installer).toContain('-PropertyType String -Value "1"');
|
||||
expect(installer).toContain("artifacts\\MCPBridgeCEP.zxp");
|
||||
expect(installer).toContain('ExtensionBundleVersion="');
|
||||
expect(installer).toContain("signedPackageMatchesRelease");
|
||||
expect(installer).toContain("embedded connector version does not match package version");
|
||||
expect(installer).toContain("Signature verification failed");
|
||||
});
|
||||
|
||||
it("builds and verifies a signed Windows CEP release package", () => {
|
||||
const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
const signer = readFileSync(join(root, "scripts", "build-signed-cep.ps1"), "utf8");
|
||||
const workflow = readFileSync(join(root, ".github", "workflows", "npm-publish.yml"), "utf8");
|
||||
|
||||
expect(packageJson.files).toContain("artifacts/MCPBridgeCEP.zxp");
|
||||
expect(signer).toContain("-selfSignedCert");
|
||||
expect(signer).toContain("-verify");
|
||||
expect(workflow).toContain("build-signed-cep");
|
||||
expect(workflow).toContain("signed-cep");
|
||||
});
|
||||
|
||||
it("publishes connector updates and exposes the updater in the panel", () => {
|
||||
const workflow = readFileSync(join(root, ".github", "workflows", "cep-release.yml"), "utf8");
|
||||
const panel = readFileSync(join(root, "cep-plugin", "index.html"), "utf8");
|
||||
const panelLogic = readFileSync(join(root, "cep-plugin", "main.js"), "utf8");
|
||||
|
||||
expect(workflow).toContain("release:");
|
||||
expect(workflow).toContain("artifacts/MCPBridgeCEP.zxp");
|
||||
expect(panel).toContain('src="updater.cjs"');
|
||||
expect(panel).toContain("MCP updates");
|
||||
expect(panel).toContain('aria-describedby="updateDetail"');
|
||||
expect(panelLogic).toContain("Update after quit");
|
||||
expect(panelLogic).toContain("scheduleWindowsGlobalUpdate");
|
||||
expect(panelLogic).toContain("window.confirm");
|
||||
expect(panelLogic).toContain("getPerUserGlobalInstall");
|
||||
expect(panelLogic).toContain("LATEST_PACKAGE_API");
|
||||
expect(panelLogic).toContain("This panel will not modify a source checkout.");
|
||||
expect(panelLogic).toContain("writeResponseFile(resFilePath, response)");
|
||||
expect(panelLogic).toContain("fs.renameSync(stagedPath, filePath)");
|
||||
expect(panelLogic).toContain('"bridge-heartbeat.json"');
|
||||
expect(panelLogic).toContain("protocolVersion: 1");
|
||||
expect(panelLogic).toContain("startBridgeHeartbeat()");
|
||||
expect(panelLogic).toContain("PREMIERE_TEMP_DIR");
|
||||
expect(panelLogic).toContain('nodeRequire("process")');
|
||||
expect(panelLogic).toContain('return "mcpstate:"');
|
||||
expect(panelLogic).toContain("/^mcpstate:([01]),([01])$/");
|
||||
expect(panelLogic).not.toContain("return JSON.stringify({projectOpen:");
|
||||
});
|
||||
|
||||
it("copies the macOS plugin for npm installs and supports diagnostics", () => {
|
||||
const cli = readFileSync(join(root, "src", "index.ts"), "utf8");
|
||||
const installer = readFileSync(join(root, "scripts", "install-cep.sh"), "utf8");
|
||||
expect(cli).toContain('diagnose ? "--diagnose" : "--copy"');
|
||||
expect(installer).toContain('HOST="Premiere"');
|
||||
expect(installer).toContain('for arg in "$@"; do');
|
||||
expect(installer).toContain("--after-effects");
|
||||
expect(installer).toContain('if [ "$MODE" = "--diagnose" ]');
|
||||
expect(installer).toContain("Installation verified");
|
||||
});
|
||||
|
||||
it("rejects CEP installation on unsupported host operating systems", () => {
|
||||
const cli = readFileSync(join(root, "src", "index.ts"), "utf8");
|
||||
expect(cli).toContain("supported only on Windows and macOS");
|
||||
});
|
||||
|
||||
it("offers an idempotent connector-only uninstall path on both supported operating systems", () => {
|
||||
const cli = readFileSync(join(root, "src", "index.ts"), "utf8");
|
||||
const windows = readFileSync(join(root, "scripts", "uninstall-cep.ps1"), "utf8");
|
||||
const macos = readFileSync(join(root, "scripts", "uninstall-cep.sh"), "utf8");
|
||||
|
||||
expect(cli).toContain("--uninstall-cep");
|
||||
expect(windows).toContain("Refusing to uninstall outside the CEP extensions directory");
|
||||
expect(windows).toContain("PlayerDebugMode settings were left unchanged");
|
||||
expect(macos).toContain("--uninstall-system");
|
||||
expect(macos).toContain("PlayerDebugMode setting was left unchanged");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const inventory = JSON.parse(readFileSync("src/resources/cep-reference-inventory.json", "utf8"));
|
||||
|
||||
const fixtureNames = [
|
||||
"Adobe-CEP__CEP-Resources.json",
|
||||
"Adobe-CEP__Samples.json",
|
||||
"docsforadobe__premiere-scripting-guide.json",
|
||||
];
|
||||
|
||||
function runWithFixtures(overrides: Record<string, string>) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "cep-reference-inventory-"));
|
||||
const output = join(directory, "inventory.json");
|
||||
const validTrees = [
|
||||
{ tree: [{ type: "blob", path: "CSInterface.js", sha: "a".repeat(40), size: 1 }] },
|
||||
{ tree: [{ type: "blob", path: "PProPanel/ReadMe.md", sha: "b".repeat(40), size: 2 }] },
|
||||
{ tree: [{ type: "blob", path: "docs/index.md", sha: "c".repeat(40), size: 3 }] },
|
||||
];
|
||||
fixtureNames.forEach((name, index) => writeFileSync(join(directory, name), JSON.stringify(validTrees[index])));
|
||||
for (const [name, contents] of Object.entries(overrides)) writeFileSync(join(directory, name), contents);
|
||||
const result = spawnSync(process.execPath, ["scripts/generate-cep-reference-inventory.mjs"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, CEP_INVENTORY_FIXTURE_DIRECTORY: directory, CEP_INVENTORY_OUTPUT_PATH: output },
|
||||
});
|
||||
return { directory, output, result };
|
||||
}
|
||||
|
||||
describe("CEP and Premiere scripting reference inventory", () => {
|
||||
it("pins and accounts for every file without mixing authority classes", () => {
|
||||
expect(inventory.schemaVersion).toBe(1);
|
||||
expect(inventory.sources).toEqual([
|
||||
expect.objectContaining({
|
||||
repository: "Adobe-CEP/CEP-Resources",
|
||||
commit: "ab5e4e3e53a42fad08e1225a22a991bb1ffe73f6",
|
||||
authority: "adobe",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
repository: "Adobe-CEP/Samples",
|
||||
commit: "e4946b73ac1e566dced8e95dba10811c31036927",
|
||||
authority: "adobe",
|
||||
pathPrefix: "PProPanel/",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
repository: "docsforadobe/premiere-scripting-guide",
|
||||
commit: "4253cea094e84d43590b77012b33bd1c140f72ea",
|
||||
authority: "community",
|
||||
}),
|
||||
]);
|
||||
expect(inventory.stats.total).toBe(inventory.entries.length);
|
||||
expect(Object.values(inventory.stats.byRepository).reduce((sum: number, count) => sum + Number(count), 0))
|
||||
.toBe(inventory.stats.total);
|
||||
expect(new Set(inventory.entries.map((entry: { repository: string; path: string }) => `${entry.repository}:${entry.path}`)).size)
|
||||
.toBe(inventory.entries.length);
|
||||
});
|
||||
|
||||
it("retains exact blob identities and bounded categories", () => {
|
||||
const categories = new Set([
|
||||
"asset", "cep-runtime-library", "configuration", "documentation", "documentation-tooling",
|
||||
"sample", "signing-tool", "site-asset", "source",
|
||||
]);
|
||||
for (const entry of inventory.entries) {
|
||||
expect(entry.commit).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(entry.blobSha).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(entry.size).toBeGreaterThanOrEqual(0);
|
||||
expect(categories.has(entry.category)).toBe(true);
|
||||
expect(["adobe", "community"]).toContain(entry.authority);
|
||||
if (entry.repository === "Adobe-CEP/Samples") expect(entry.path).toMatch(/^PProPanel\//);
|
||||
}
|
||||
expect(inventory.entries).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ repository: "Adobe-CEP/Samples", path: "PProPanel/ReadMe.md", category: "sample" }),
|
||||
expect.objectContaining({ repository: "docsforadobe/premiere-scripting-guide", authority: "community", category: "documentation" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["malformed", "Adobe-CEP__CEP-Resources.json", "{", "SyntaxError"],
|
||||
["empty", "Adobe-CEP__CEP-Resources.json", JSON.stringify({ tree: [] }), "No files matched"],
|
||||
["truncated", "Adobe-CEP__CEP-Resources.json", JSON.stringify({ truncated: true, tree: [] }), "truncated tree"],
|
||||
["invalid metadata", "Adobe-CEP__CEP-Resources.json", JSON.stringify({ tree: [{ type: "blob", path: "x.js", sha: "bad", size: 1 }] }), "Invalid Git blob metadata"],
|
||||
["duplicate", "Adobe-CEP__CEP-Resources.json", JSON.stringify({ tree: [
|
||||
{ type: "blob", path: "x.js", sha: "d".repeat(40), size: 1 },
|
||||
{ type: "blob", path: "x.js", sha: "e".repeat(40), size: 2 },
|
||||
] }), "duplicate repository paths"],
|
||||
])("fails closed for a %s fixture tree", (_label, name, contents, expectedError) => {
|
||||
const { directory, result } = runWithFixtures({ [name]: contents });
|
||||
try {
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain(expectedError);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("executes the generator against fixtures and preserves classification boundaries", () => {
|
||||
const { directory, output, result } = runWithFixtures({});
|
||||
try {
|
||||
expect(result.status).toBe(0);
|
||||
const generated = JSON.parse(readFileSync(output, "utf8"));
|
||||
expect(generated.entries).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ path: "CSInterface.js", category: "cep-runtime-library", authority: "adobe" }),
|
||||
expect.objectContaining({ path: "PProPanel/ReadMe.md", category: "sample", authority: "adobe" }),
|
||||
expect.objectContaining({ path: "docs/index.md", category: "documentation", authority: "community" }),
|
||||
]));
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { join, win32 } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const updater = require(join(process.cwd(), "cep-plugin", "updater.cjs"));
|
||||
|
||||
describe("CEP connector updater", () => {
|
||||
it("compares release versions numerically", () => {
|
||||
expect(updater.compareVersions("v1.10.0", "1.9.9")).toBe(1);
|
||||
expect(updater.compareVersions("1.3.1", "1.3.1")).toBe(0);
|
||||
expect(updater.compareVersions("1.3.0", "1.3.1")).toBe(-1);
|
||||
});
|
||||
|
||||
it("prefers the signed connector package", () => {
|
||||
const release = {
|
||||
html_url: "https://github.com/leancoderkavy/premiere-pro-mcp/releases/tag/v1.4.0",
|
||||
assets: [
|
||||
{
|
||||
name: "source.zip",
|
||||
browser_download_url: "https://github.com/example/source.zip",
|
||||
},
|
||||
{
|
||||
name: "MCPBridgeCEP.zxp",
|
||||
browser_download_url:
|
||||
"https://github.com/leancoderkavy/premiere-pro-mcp/releases/download/v1.4.0/MCPBridgeCEP.zxp",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(updater.chooseDownloadUrl(release)).toContain("MCPBridgeCEP.zxp");
|
||||
});
|
||||
|
||||
it("rejects untrusted download hosts", () => {
|
||||
expect(updater.isTrustedDownloadUrl("https://github.com/example/file.zxp")).toBe(true);
|
||||
expect(updater.isTrustedDownloadUrl("https://evil.example/file.zxp")).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the latest global package version from the npm registry record", () => {
|
||||
const update = updater.updateStateFromPackageRecord("1.14.7", {
|
||||
"dist-tags": { latest: "v1.14.8" },
|
||||
});
|
||||
|
||||
expect(updater.LATEST_PACKAGE_API).toBe("https://registry.npmjs.org/premiere-pro-mcp");
|
||||
expect(update).toEqual({
|
||||
currentVersion: "1.14.7",
|
||||
latestVersion: "1.14.8",
|
||||
updateAvailable: true,
|
||||
});
|
||||
expect(() => updater.latestPackageVersion({ "dist-tags": { latest: "nightly" } }))
|
||||
.toThrow("valid latest version");
|
||||
});
|
||||
|
||||
it("creates a detached Windows updater that waits for Premiere instead of closing it", () => {
|
||||
const scriptPath = "C:\\Temp\\premiere-pro-mcp-update-abc.ps1";
|
||||
const statusPath = "C:\\Temp\\premiere-pro-mcp-update-abc.json";
|
||||
const cliPath = "C:\\Users\\editor\\AppData\\Roaming\\npm\\premiere-pro-mcp.cmd";
|
||||
const script = updater.buildWindowsGlobalUpdateScript(cliPath, statusPath, scriptPath);
|
||||
|
||||
expect(script).toContain("while (Get-Process -Name $premiereProcesses");
|
||||
expect(script).toContain("Start-Sleep -Seconds 2");
|
||||
expect(script).toContain("Get-Command npm.cmd");
|
||||
expect(script).toContain("& $npmCommand install --global 'premiere-pro-mcp@latest'");
|
||||
expect(script).toContain("& $cliPath --install-cep");
|
||||
expect(script).toContain("premiere-pro-mcp.desktop-update.v1");
|
||||
expect(script).not.toContain("Stop-Process");
|
||||
});
|
||||
|
||||
it("schedules only an existing absolute per-user command and cleans up if launch fails", () => {
|
||||
const writes = new Map<string, string>();
|
||||
const unref = vi.fn();
|
||||
const spawn = vi.fn(() => ({ unref }));
|
||||
const runtime = {
|
||||
fs: {
|
||||
existsSync: vi.fn(() => true),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn((file: string, value: string) => writes.set(file, value)),
|
||||
unlinkSync: vi.fn(),
|
||||
},
|
||||
path: win32,
|
||||
os: { tmpdir: () => "C:\\Temp" },
|
||||
childProcess: { spawn },
|
||||
crypto: { randomBytes: () => Buffer.from("abcdef", "hex") },
|
||||
};
|
||||
const cliPath = "C:\\Users\\editor\\AppData\\Roaming\\npm\\premiere-pro-mcp.cmd";
|
||||
const scheduled = updater.scheduleWindowsGlobalUpdate({ cliPath, runtime });
|
||||
|
||||
expect(scheduled.statusPath).toBe("C:\\Temp\\premiere-pro-mcp-update-abcdef.json");
|
||||
expect(writes.get("C:\\Temp\\premiere-pro-mcp-update-abcdef.ps1"))
|
||||
.toContain("& $npmCommand install --global 'premiere-pro-mcp@latest'");
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
"powershell.exe",
|
||||
["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "C:\\Temp\\premiere-pro-mcp-update-abcdef.ps1"],
|
||||
{ detached: true, windowsHide: true, stdio: "ignore" },
|
||||
);
|
||||
expect(unref).toHaveBeenCalledOnce();
|
||||
|
||||
expect(() => updater.scheduleWindowsGlobalUpdate({ cliPath: "relative.cmd", runtime }))
|
||||
.toThrow("could not be resolved");
|
||||
|
||||
const failingRuntime = {
|
||||
...runtime,
|
||||
childProcess: { spawn: vi.fn(() => { throw new Error("launch failed"); }) },
|
||||
};
|
||||
expect(() => updater.scheduleWindowsGlobalUpdate({ cliPath, runtime: failingRuntime }))
|
||||
.toThrow("launch failed");
|
||||
expect(runtime.fs.unlinkSync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (path: string) => readFileSync(join(root, path), "utf8");
|
||||
const readJson = (path: string) => JSON.parse(read(path)) as Record<string, unknown>;
|
||||
|
||||
const release = readJson("release-metadata.json");
|
||||
const registry = readJson("docs/claims-registry.json") as {
|
||||
schemaVersion: number;
|
||||
authoritativeReleaseMetadata: string;
|
||||
claims: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
claim: string;
|
||||
fields?: string[];
|
||||
boundary: string;
|
||||
}>;
|
||||
prohibitedUntilEvidenceExists: string[];
|
||||
};
|
||||
|
||||
const renderReleaseClaim = (claim: string) =>
|
||||
claim.replaceAll(/\{([^}]+)\}/g, (_match, field: string) => String(release[field]));
|
||||
|
||||
describe("product claims registry", () => {
|
||||
it("is a unique, versioned registry with explicit boundaries", () => {
|
||||
expect(registry.schemaVersion).toBe(1);
|
||||
expect(registry.authoritativeReleaseMetadata).toBe("release-metadata.json");
|
||||
expect(registry.claims.map((claim) => claim.id)).toHaveLength(
|
||||
new Set(registry.claims.map((claim) => claim.id)).size,
|
||||
);
|
||||
expect(registry.claims.every((claim) => claim.boundary.trim().length > 0)).toBe(true);
|
||||
expect(registry.prohibitedUntilEvidenceExists.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("derives every release-backed claim from canonical release metadata", () => {
|
||||
const releaseClaims = registry.claims.filter((claim) => claim.status === "release_metadata");
|
||||
expect(releaseClaims).toHaveLength(2);
|
||||
|
||||
for (const claim of releaseClaims) {
|
||||
expect(claim.fields?.length).toBeGreaterThan(0);
|
||||
for (const field of claim.fields ?? []) {
|
||||
expect(release).toHaveProperty(field);
|
||||
expect(claim.claim).toContain(`{${field}}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the product-marketing context aligned with the rendered release facts", () => {
|
||||
const marketingContext = read(".agents/product-marketing.md");
|
||||
const releaseSurface = registry.claims.find((claim) => claim.id === "release-capability-surface");
|
||||
const compatibility = registry.claims.find((claim) => claim.id === "release-compatibility");
|
||||
|
||||
expect(releaseSurface).toBeDefined();
|
||||
expect(compatibility).toBeDefined();
|
||||
expect(marketingContext).toContain(renderReleaseClaim(releaseSurface!.claim));
|
||||
expect(marketingContext).toContain(renderReleaseClaim(compatibility!.claim));
|
||||
});
|
||||
|
||||
it("labels commercial pricing as a hypothesis and records evidence-gated boundaries", () => {
|
||||
const pricing = registry.claims.find((claim) => claim.id === "commercial-companion-pricing");
|
||||
const marketingContext = read(".agents/product-marketing.md");
|
||||
|
||||
expect(pricing?.status).toBe("hypothesis");
|
||||
expect(pricing?.claim.toLowerCase()).toContain("hypotheses");
|
||||
expect(marketingContext).toContain("unvalidated pricing hypotheses");
|
||||
expect(marketingContext).toContain("No approved customer-logo claims, adoption claims, case studies, or public testimonials are currently documented.");
|
||||
expect(marketingContext).toContain("Current production activation, retention, support, conversion, and revenue metrics have not been queried");
|
||||
expect(marketingContext).toContain("Do not claim current Adobe Marketplace approval");
|
||||
});
|
||||
|
||||
it("rejects known stale or unsupported marketing phrases on governed public surfaces", () => {
|
||||
const governedSurfaces = [
|
||||
".agents/product-marketing.md",
|
||||
"README.md",
|
||||
"landing/lib/articles.ts",
|
||||
].map(read);
|
||||
const governedContent = governedSurfaces.join("\n");
|
||||
|
||||
expect(governedContent).not.toMatch(/49 (?:documented, )?capability-gated tools/i);
|
||||
expect(read("landing/components/sections/hero.tsx")).not.toMatch(/editor approved/i);
|
||||
});
|
||||
});
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
dbToPremiereLevel,
|
||||
premiereLevelToDb,
|
||||
PREMIERE_MAX_LEVEL_DB,
|
||||
} from "../src/tools/track-targeting.js";
|
||||
|
||||
/**
|
||||
* Premiere's `Volume > Level` is a normalised 0..1 value, not decibels.
|
||||
* Measured against Premiere Pro 26.3.2: a clip left untouched (0 dB in the
|
||||
* UI) reads back 0.17782794, which is exactly 10^(-15/20). That fixes the
|
||||
* scale - 1.0 is +15 dB.
|
||||
*/
|
||||
describe("Premiere audio level conversion", () => {
|
||||
it("maps unity gain to Premiere's observed 0 dB value", () => {
|
||||
expect(dbToPremiereLevel(0)).toBeCloseTo(0.17782794, 7);
|
||||
});
|
||||
|
||||
it("maps the top of the range to 1.0", () => {
|
||||
expect(dbToPremiereLevel(PREMIERE_MAX_LEVEL_DB)).toBeCloseTo(1.0, 10);
|
||||
});
|
||||
|
||||
it("keeps every level inside the range Premiere accepts", () => {
|
||||
for (const db of [-60, -18, -11, -5.8, 0, 6, 15]) {
|
||||
const level = dbToPremiereLevel(db);
|
||||
expect(level).toBeGreaterThanOrEqual(0);
|
||||
expect(level).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps above +15 dB rather than exceeding 1.0", () => {
|
||||
expect(dbToPremiereLevel(40)).toBeCloseTo(1.0, 10);
|
||||
});
|
||||
|
||||
it("round-trips dB -> level -> dB", () => {
|
||||
for (const db of [-24, -17.7, -11, -5.8, 0, 7.5]) {
|
||||
const back = premiereLevelToDb(dbToPremiereLevel(db));
|
||||
expect(back).not.toBeNull();
|
||||
expect(back as number).toBeCloseTo(db, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports silence as null rather than -Infinity", () => {
|
||||
expect(premiereLevelToDb(0)).toBeNull();
|
||||
expect(premiereLevelToDb(-1)).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression guard. Before the fix, volume_db was passed straight to
|
||||
* setValue(): -11 dB fell outside 0..1 and clamped to 0 (silence), while
|
||||
* +7 dB clamped to 1.0 (+15 dB). Both failed without raising an error.
|
||||
*/
|
||||
it("does not pass raw decibels through as a level", () => {
|
||||
expect(dbToPremiereLevel(-11)).not.toBe(-11);
|
||||
expect(dbToPremiereLevel(-11)).toBeGreaterThan(0);
|
||||
expect(dbToPremiereLevel(7)).not.toBe(7);
|
||||
expect(dbToPremiereLevel(7)).toBeLessThan(1);
|
||||
});
|
||||
});
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const readJson = (path: string) =>
|
||||
JSON.parse(readFileSync(join(root, path), "utf8"));
|
||||
|
||||
describe("Codex plugin package", () => {
|
||||
it("keeps the plugin and MCP package versions aligned", () => {
|
||||
const pkg = readJson("package.json");
|
||||
const plugin = readJson("plugins/premiere-pro/.codex-plugin/plugin.json");
|
||||
const mcp = readJson("plugins/premiere-pro/.mcp.json");
|
||||
|
||||
expect(plugin.name).toBe("premiere-pro");
|
||||
expect(plugin.version).toBe(pkg.version);
|
||||
expect(plugin.skills).toBe("./skills/");
|
||||
expect(plugin.mcpServers).toBe("./.mcp.json");
|
||||
expect(mcp.mcpServers["premiere-pro"].args).toContain(
|
||||
`premiere-pro-mcp@${pkg.version}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("publishes the plugin through the repository marketplace", () => {
|
||||
const marketplace = readJson(".agents/plugins/marketplace.json");
|
||||
const entry = marketplace.plugins.find(
|
||||
(plugin: { name: string }) => plugin.name === "premiere-pro",
|
||||
);
|
||||
|
||||
expect(marketplace.name).toBe("premiere-pro-mcp");
|
||||
expect(entry).toMatchObject({
|
||||
source: { source: "local", path: "./plugins/premiere-pro" },
|
||||
policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" },
|
||||
category: "Creativity",
|
||||
});
|
||||
});
|
||||
|
||||
it("ships a complete Premiere editing skill without placeholders", () => {
|
||||
const skill = readFileSync(
|
||||
join(
|
||||
root,
|
||||
"plugins",
|
||||
"premiere-pro",
|
||||
"skills",
|
||||
"edit-premiere-project",
|
||||
"SKILL.md",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(skill).toContain("name: edit-premiere-project");
|
||||
expect(skill).toContain("Call `ping`");
|
||||
expect(skill).toContain("preview_edit_plan");
|
||||
expect(skill).toContain("export_sequence");
|
||||
expect(skill).not.toContain("TODO");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude distributions", () => {
|
||||
it("keeps Claude Code metadata aligned with the npm package", () => {
|
||||
const pkg = readJson("package.json");
|
||||
const marketplace = readJson(".claude-plugin/marketplace.json");
|
||||
const plugin = readJson(
|
||||
"claude-plugins/premiere-pro/.claude-plugin/plugin.json",
|
||||
);
|
||||
const mcp = readJson("claude-plugins/premiere-pro/.mcp.json");
|
||||
|
||||
expect(marketplace.name).toBe("premiere-pro-mcp");
|
||||
expect(marketplace.plugins[0]).toMatchObject({
|
||||
name: "premiere-pro",
|
||||
source: "./claude-plugins/premiere-pro",
|
||||
version: pkg.version,
|
||||
});
|
||||
expect(plugin.version).toBe(pkg.version);
|
||||
expect(plugin.mcpServers).toBe("./.mcp.json");
|
||||
expect(mcp.mcpServers["premiere-pro"].args).toContain(
|
||||
`premiere-pro-mcp@${pkg.version}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the Claude and Codex editing skills identical", () => {
|
||||
const codexSkill = readFileSync(
|
||||
join(
|
||||
root,
|
||||
"plugins",
|
||||
"premiere-pro",
|
||||
"skills",
|
||||
"edit-premiere-project",
|
||||
"SKILL.md",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const claudeSkill = readFileSync(
|
||||
join(
|
||||
root,
|
||||
"claude-plugins",
|
||||
"premiere-pro",
|
||||
"skills",
|
||||
"edit-premiere-project",
|
||||
"SKILL.md",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(claudeSkill).toBe(codexSkill);
|
||||
});
|
||||
|
||||
it("defines a self-contained Claude Desktop MCP bundle", () => {
|
||||
const pkg = readJson("package.json");
|
||||
const manifest = readJson("claude-desktop/manifest.json");
|
||||
|
||||
expect(manifest).toMatchObject({
|
||||
manifest_version: "0.4",
|
||||
name: "premiere-pro-mcp",
|
||||
version: pkg.version,
|
||||
server: {
|
||||
type: "node",
|
||||
entry_point: "server/dist/index.js",
|
||||
},
|
||||
compatibility: {
|
||||
platforms: ["darwin", "win32"],
|
||||
},
|
||||
});
|
||||
expect(manifest.server.mcp_config.args).toContain(
|
||||
"${__dirname}/server/dist/index.js",
|
||||
);
|
||||
expect(manifest.user_config.premiere_uxp_token).toMatchObject({
|
||||
type: "string",
|
||||
sensitive: true,
|
||||
required: true,
|
||||
});
|
||||
expect(manifest.user_config.premiere_mcp_protocol_mode).toMatchObject({
|
||||
type: "string",
|
||||
required: false,
|
||||
});
|
||||
expect(manifest.server.mcp_config.env).toEqual({
|
||||
PREMIERE_UXP_TOKEN: "${user_config.premiere_uxp_token}",
|
||||
PREMIERE_MCP_PROTOCOL_MODE: "${user_config.premiere_mcp_protocol_mode}",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (path: string) => readFileSync(join(root, path), "utf8");
|
||||
|
||||
describe("native connector installers", () => {
|
||||
it("keeps Windows installation inside the current user's CEP directory", () => {
|
||||
const source = read("installer/windows/Program.cs");
|
||||
expect(source).toContain("Environment.SpecialFolder.ApplicationData");
|
||||
expect(source).toContain("EnsureInsideCepRoot");
|
||||
expect(source).toContain("RegistryValueKind.String");
|
||||
expect(source).toContain("GetManifestResourceStream");
|
||||
});
|
||||
|
||||
it("rejects unsafe ZIP paths and an invalid connector payload", () => {
|
||||
const source = read("installer/windows/Program.cs");
|
||||
expect(source).toContain("contains an unsafe path");
|
||||
expect(source).toContain("CSXS");
|
||||
expect(source).toContain("manifest.xml");
|
||||
});
|
||||
|
||||
it("verifies the shipped Windows installer payload without installing it", () => {
|
||||
const source = read("installer/windows/Program.cs");
|
||||
const workflow = read(".github/workflows/connector-installers.yml");
|
||||
expect(source).toContain('"--verify-only"');
|
||||
expect(source).toContain("VerifyEmbeddedPackage");
|
||||
expect(source).toContain("premiere-connector-validate");
|
||||
expect(source).toContain("GetManifestResourceStream");
|
||||
expect(workflow).toContain("--verify-only");
|
||||
expect(workflow).toContain("Start-Process");
|
||||
expect(workflow).toContain("without installing it");
|
||||
});
|
||||
|
||||
it("makes production signing an explicit fail-closed gate", () => {
|
||||
const windows = read("scripts/build-connector-installer.ps1");
|
||||
const macos = read("scripts/build-connector-installer.sh");
|
||||
const workflow = read(".github/workflows/connector-installers.yml");
|
||||
expect(windows).toContain("RequireSigning");
|
||||
expect(windows).toContain("Authenticode verification failed");
|
||||
expect(macos).toContain("REQUIRE_SIGNING");
|
||||
expect(workflow).toContain("require_production_signing");
|
||||
});
|
||||
|
||||
it("ships a guarded macOS companion uninstaller and blocks Windows removal while Premiere is open", () => {
|
||||
const windows = read("installer/windows/Program.cs");
|
||||
const macos = read("scripts/build-connector-installer.sh");
|
||||
const workflow = read(".github/workflows/connector-installers.yml");
|
||||
expect(windows).toContain("Close it before removing the Connector");
|
||||
expect(macos).toContain("Premiere-Connector-Uninstall-$VERSION-macos.command");
|
||||
expect(macos).toContain("uninstall-cep.sh");
|
||||
expect(workflow).toContain("*.command");
|
||||
});
|
||||
});
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFirstRunReport,
|
||||
collectLocalDoctor,
|
||||
createDoctorRepairPlan,
|
||||
createSupportBundle,
|
||||
renderDoctorHuman,
|
||||
} from "../src/diagnostics.js";
|
||||
|
||||
describe("non-technical diagnostics", () => {
|
||||
it("keeps local doctor states separate from a live Premiere verification", () => {
|
||||
const report = collectLocalDoctor({
|
||||
platform: "win32",
|
||||
nodeVersion: "v22.12.0",
|
||||
environment: { APPDATA: "C:\\Users\\Example\\AppData" },
|
||||
exists: (file) => file.endsWith("manifest.xml"),
|
||||
now: () => new Date("2026-08-01T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(report.overall).toBe("ready");
|
||||
expect(report.runtime).toEqual({ platform: "win32", nodeMajor: 22 });
|
||||
expect(report.components).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: "premiere_connector", code: "CEP_CONNECTOR_READY", boundary: "installed", state: "ready" }),
|
||||
expect.objectContaining({ id: "premiere_host", code: "PREMIERE_HOST_NOT_CHECKED", boundary: "live_verified", state: "not_checked" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("creates a privacy-safe, no-write repair plan with stable diagnostic codes", () => {
|
||||
const report = collectLocalDoctor({
|
||||
platform: "win32",
|
||||
nodeVersion: "v22.12.0",
|
||||
environment: { APPDATA: "C:\\Users\\Example\\AppData" },
|
||||
exists: () => false,
|
||||
now: () => new Date("2026-09-04T00:00:00.000Z"),
|
||||
});
|
||||
const plan = createDoctorRepairPlan(report);
|
||||
expect(plan).toMatchObject({
|
||||
schemaVersion: "premiere-pro-mcp.doctor-repair-plan.v1",
|
||||
actions: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "install_cep_connector", diagnosticCode: "CEP_CONNECTOR_MISSING", canApplyLocally: true, createsBackup: true }),
|
||||
expect.objectContaining({ id: "verify_live_connection", diagnosticCode: "PREMIERE_HOST_NOT_CHECKED", canApplyLocally: false }),
|
||||
]),
|
||||
});
|
||||
const serialized = JSON.stringify(plan);
|
||||
expect(serialized).not.toContain("C:\\Users\\Example");
|
||||
expect(serialized).not.toContain("APPDATA");
|
||||
});
|
||||
|
||||
it("proves the complete first-run lane with booleans only", () => {
|
||||
const report = buildFirstRunReport("cep", {
|
||||
reachable: true,
|
||||
projectOpen: true,
|
||||
sequenceOpen: true,
|
||||
});
|
||||
|
||||
expect(report).toMatchObject({
|
||||
overall: "ready",
|
||||
safeCheck: { readOnly: true, mutatesProject: false },
|
||||
});
|
||||
expect(report.components).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: "mcp_process", boundary: "connected", state: "ready" }),
|
||||
expect.objectContaining({ id: "premiere_connector", boundary: "connected", state: "ready" }),
|
||||
expect.objectContaining({ id: "active_project", boundary: "live_verified", state: "ready" }),
|
||||
expect.objectContaining({ id: "active_sequence", boundary: "live_verified", state: "ready" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("gives a repairable outcome when the connector cannot answer", () => {
|
||||
const report = buildFirstRunReport("uxp", { reachable: false });
|
||||
|
||||
expect(report.overall).toBe("needs_attention");
|
||||
expect(report.safeCheck.mutatesProject).toBe(false);
|
||||
expect(report.components.find((component) => component.id === "premiere_connector"))
|
||||
.toMatchObject({ boundary: "connected", state: "needs_attention" });
|
||||
expect(report.repair).toContain("diagnose-cep");
|
||||
});
|
||||
|
||||
it("creates a support snapshot without credentials, paths, or project data", () => {
|
||||
const bundle = createSupportBundle({
|
||||
version: "1.7.0",
|
||||
platform: "win32",
|
||||
architecture: "x64",
|
||||
nodeVersion: "v22.12.0",
|
||||
environment: {
|
||||
APPDATA: "C:\\Users\\Private\\AppData",
|
||||
PREMIERE_UXP_TOKEN: "very-secret-token-value",
|
||||
POSTHOG_API_KEY: "another-secret",
|
||||
},
|
||||
exists: () => true,
|
||||
now: () => new Date("2026-08-01T12:00:00.000Z"),
|
||||
});
|
||||
const serialized = JSON.stringify(bundle);
|
||||
|
||||
expect(serialized).not.toContain("very-secret-token-value");
|
||||
expect(serialized).not.toContain("another-secret");
|
||||
expect(serialized).not.toContain("C:\\Users\\Private");
|
||||
expect(serialized).not.toMatch(/"(?:projectName|mediaName|outputDirectory|arguments|results)"\s*:/);
|
||||
});
|
||||
|
||||
it("reports unsupported local layouts and malformed Node versions conservatively", () => {
|
||||
const report = collectLocalDoctor({
|
||||
platform: "linux", nodeVersion: "not-a-version", environment: {},
|
||||
exists: () => { throw new Error("must not inspect an unknown path"); },
|
||||
now: () => new Date("2026-08-02T00:00:00.000Z"),
|
||||
});
|
||||
expect(report.overall).toBe("needs_attention");
|
||||
expect(report.runtime.nodeMajor).toBeNull();
|
||||
expect(report.components).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: "node_runtime", code: "NODE_RUNTIME_UNSUPPORTED", state: "needs_attention" }),
|
||||
expect.objectContaining({ id: "premiere_connector", state: "needs_attention", repair: expect.any(String) }),
|
||||
expect.objectContaining({ id: "uxp_bridge", state: "not_checked" }),
|
||||
]));
|
||||
const human = renderDoctorHuman(report);
|
||||
expect(human).toContain("Premiere MCP local check: needs attention");
|
||||
expect(human).toContain("Next: Install the Connector");
|
||||
expect(human).toContain("Not checked: UXP connection");
|
||||
});
|
||||
|
||||
it("describes each reachable but incomplete first-run state", () => {
|
||||
const noProject = buildFirstRunReport("cep", { reachable: true });
|
||||
expect(noProject.overall).toBe("needs_attention");
|
||||
expect(noProject.components.find((item) => item.id === "active_project"))
|
||||
.toMatchObject({ state: "needs_attention", repair: expect.any(String) });
|
||||
expect(noProject.components.find((item) => item.id === "active_sequence"))
|
||||
.toMatchObject({ state: "needs_attention", repair: expect.any(String) });
|
||||
|
||||
const noSequence = buildFirstRunReport("uxp", { reachable: true, projectOpen: true });
|
||||
expect(noSequence.components.find((item) => item.id === "active_project"))
|
||||
.toMatchObject({ state: "ready" });
|
||||
expect(noSequence.components.find((item) => item.id === "active_sequence"))
|
||||
.toMatchObject({ state: "needs_attention" });
|
||||
});
|
||||
|
||||
it("recognizes the macOS connector path and configured UXP without exposing the token", () => {
|
||||
let inspected = "";
|
||||
const report = collectLocalDoctor({
|
||||
platform: "darwin", environment: { HOME: "/Users/example", PREMIERE_UXP_TOKEN: "secret" },
|
||||
exists: (file) => { inspected = file; return true; },
|
||||
});
|
||||
expect(inspected).toContain("Library");
|
||||
expect(report.components.find((item) => item.id === "uxp_bridge")).toMatchObject({ state: "ready" });
|
||||
expect(JSON.stringify(report)).not.toContain("secret");
|
||||
expect(renderDoctorHuman(report)).toContain("Premiere MCP local check: ready");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Docker release build context", () => {
|
||||
it("copies every repository script required by the package build", () => {
|
||||
const dockerfile = readFileSync("Dockerfile", "utf8");
|
||||
const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
|
||||
const buildScripts = [...packageJson.scripts.build.matchAll(/scripts\/[\w-]+\.mjs/g)]
|
||||
.map((match) => match[0]);
|
||||
expect(buildScripts).toEqual([
|
||||
"scripts/generate-adobe-api-inventory.mjs",
|
||||
"scripts/generate-uxp-js-api-inventory.mjs",
|
||||
"scripts/copy-adobe-uxp-coverage.mjs",
|
||||
]);
|
||||
for (const script of buildScripts) {
|
||||
expect(dockerfile).toContain(`COPY ${script} ./${script}`);
|
||||
}
|
||||
for (const generator of buildScripts.filter((script) => script.includes("/generate-"))) {
|
||||
expect(packageJson.scripts.build).toContain(`${generator} --check`);
|
||||
}
|
||||
});
|
||||
});
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { applyDoctorRepairPlan } from "../src/doctor-repairs.js";
|
||||
import { collectLocalDoctor, createDoctorRepairPlan } from "../src/diagnostics.js";
|
||||
|
||||
function missingConnectorReport() {
|
||||
return collectLocalDoctor({
|
||||
platform: "win32",
|
||||
nodeVersion: "v22.12.0",
|
||||
environment: { APPDATA: "C:\\Users\\Example\\AppData" },
|
||||
exists: () => false,
|
||||
now: () => new Date("2026-09-04T00:00:00.000Z"),
|
||||
});
|
||||
}
|
||||
|
||||
describe("doctor repair application", () => {
|
||||
it("withholds a connector write until Premiere closure is explicitly confirmed", () => {
|
||||
const runInstaller = vi.fn();
|
||||
const result = applyDoctorRepairPlan(createDoctorRepairPlan(missingConnectorReport()), {
|
||||
projectRoot: "D:\\fixture",
|
||||
platform: "win32",
|
||||
environment: { APPDATA: "C:\\Users\\Example\\AppData" },
|
||||
runInstaller,
|
||||
collect: missingConnectorReport,
|
||||
});
|
||||
|
||||
expect(runInstaller).not.toHaveBeenCalled();
|
||||
expect(result.actions).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: "install_cep_connector", status: "withheld", backupCreated: false }),
|
||||
]));
|
||||
expect(JSON.stringify(result)).not.toContain("C:\\Users\\Example");
|
||||
});
|
||||
|
||||
it("backs up an incomplete connector before an explicit installer run and rechecks local readiness", () => {
|
||||
const rename = vi.fn();
|
||||
const runInstaller = vi.fn();
|
||||
const readyReport = collectLocalDoctor({
|
||||
platform: "win32",
|
||||
nodeVersion: "v22.12.0",
|
||||
environment: { APPDATA: "C:\\Users\\Example\\AppData" },
|
||||
exists: () => true,
|
||||
now: () => new Date("2026-09-04T00:00:01.000Z"),
|
||||
});
|
||||
const result = applyDoctorRepairPlan(createDoctorRepairPlan(missingConnectorReport()), {
|
||||
projectRoot: "D:\\fixture",
|
||||
platform: "win32",
|
||||
environment: { APPDATA: "C:\\Users\\Example\\AppData" },
|
||||
confirmPremiereClosed: true,
|
||||
exists: () => true,
|
||||
rename,
|
||||
runInstaller,
|
||||
collect: () => readyReport,
|
||||
now: () => new Date("2026-09-04T00:00:01.000Z"),
|
||||
});
|
||||
|
||||
expect(rename).toHaveBeenCalledOnce();
|
||||
expect(runInstaller).toHaveBeenCalledWith("win32", "D:\\fixture");
|
||||
expect(result).toMatchObject({
|
||||
applied: true,
|
||||
actions: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "install_cep_connector", status: "applied", backupCreated: true }),
|
||||
]),
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("C:\\Users\\Example");
|
||||
});
|
||||
|
||||
it("keeps non-connector and non-applicable repair guidance manual", () => {
|
||||
const plan = createDoctorRepairPlan(missingConnectorReport());
|
||||
const connector = plan.actions.find((action) => action.id === "install_cep_connector")!;
|
||||
const runInstaller = vi.fn();
|
||||
const result = applyDoctorRepairPlan({
|
||||
...plan,
|
||||
actions: [
|
||||
{ ...connector, id: "upgrade_node_runtime", canApplyLocally: false },
|
||||
{ ...connector, canApplyLocally: false },
|
||||
],
|
||||
}, {
|
||||
projectRoot: "D:\\fixture",
|
||||
platform: "win32",
|
||||
environment: { APPDATA: "C:\\Users\\Example\\AppData" },
|
||||
confirmPremiereClosed: true,
|
||||
runInstaller,
|
||||
collect: missingConnectorReport,
|
||||
});
|
||||
|
||||
expect(runInstaller).not.toHaveBeenCalled();
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.actions).toHaveLength(2);
|
||||
expect(result.actions.every((action) => action.status === "manual_required")).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed when no supported connector directory is available or a post-install recheck is not ready", () => {
|
||||
const plan = createDoctorRepairPlan(missingConnectorReport());
|
||||
const unavailable = applyDoctorRepairPlan(plan, {
|
||||
projectRoot: "D:\\fixture",
|
||||
platform: "linux",
|
||||
environment: {},
|
||||
confirmPremiereClosed: true,
|
||||
runInstaller: vi.fn(),
|
||||
collect: missingConnectorReport,
|
||||
});
|
||||
expect(unavailable.actions).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: "install_cep_connector", status: "manual_required" }),
|
||||
]));
|
||||
|
||||
const runInstaller = vi.fn();
|
||||
const incomplete = applyDoctorRepairPlan(plan, {
|
||||
projectRoot: "D:\\fixture",
|
||||
platform: "win32",
|
||||
environment: { APPDATA: "C:\\Users\\Example\\AppData" },
|
||||
confirmPremiereClosed: true,
|
||||
exists: () => false,
|
||||
runInstaller,
|
||||
collect: missingConnectorReport,
|
||||
});
|
||||
expect(runInstaller).toHaveBeenCalledOnce();
|
||||
expect(incomplete.actions).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: "install_cep_connector", status: "failed", backupCreated: false }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("retains a backup and reports a failed result when connector installation throws", () => {
|
||||
const rename = vi.fn();
|
||||
const result = applyDoctorRepairPlan(createDoctorRepairPlan(missingConnectorReport()), {
|
||||
projectRoot: "D:\\fixture",
|
||||
platform: "win32",
|
||||
environment: { APPDATA: "C:\\Users\\Example\\AppData" },
|
||||
confirmPremiereClosed: true,
|
||||
exists: () => true,
|
||||
rename,
|
||||
runInstaller: () => { throw new Error("fixture installer failure"); },
|
||||
collect: missingConnectorReport,
|
||||
now: () => new Date("2026-09-04T00:00:01.000Z"),
|
||||
});
|
||||
|
||||
expect(rename).toHaveBeenCalledOnce();
|
||||
expect(result.actions).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: "install_cep_connector", status: "failed", backupCreated: true }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../src/bridge/file-bridge.js", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("../src/bridge/file-bridge.js")>();
|
||||
return { ...original, sendCommand: vi.fn(async () => ({ success: true, data: { applied: true } })) };
|
||||
});
|
||||
|
||||
import { sendCommand } from "../src/bridge/file-bridge.js";
|
||||
import { confirmationToken, getEditPlanTools, validateEditPlan } from "../src/tools/edit-plans.js";
|
||||
|
||||
const plan = { operations: [{ type: "insert_clip" as const, item_id: "clip-1", start_seconds: 2 }] };
|
||||
|
||||
describe("edit plans", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("previews without contacting Premiere", async () => {
|
||||
const tools = getEditPlanTools({}, { capabilities: { capabilities: new Set(["inspect"]), source: "explicit" }, operationIdFactory: () => "preview-1" });
|
||||
const result = await tools.preview_edit_plan.handler({ plan });
|
||||
expect(result.data).toMatchObject({ operationId: "preview-1", applied: false, confirmationToken: confirmationToken(plan) });
|
||||
expect(sendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires the edit capability before apply", async () => {
|
||||
const tools = getEditPlanTools({}, { capabilities: { capabilities: new Set(["inspect"]), source: "explicit" }, auditSink: vi.fn(), operationIdFactory: () => "apply-1" });
|
||||
await expect(tools.apply_edit_plan.handler({ plan, confirmation_token: confirmationToken(plan) })).rejects.toMatchObject({ code: "CAPABILITY_DENIED" });
|
||||
expect(sendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects changed plans and applies an exact previewed plan in one command", async () => {
|
||||
const auditSink = vi.fn();
|
||||
const tools = getEditPlanTools({}, { capabilities: { capabilities: new Set(["inspect", "edit"]), source: "explicit" }, auditSink, operationIdFactory: () => "apply-2" });
|
||||
await expect(tools.apply_edit_plan.handler({ plan: { ...plan, sequence_id: "different" }, confirmation_token: confirmationToken(plan) })).rejects.toThrow("does not match");
|
||||
const result = await tools.apply_edit_plan.handler({ plan, confirmation_token: confirmationToken(plan) });
|
||||
expect(result).toMatchObject({ success: true, data: { applied: true, operationId: "apply-2" } });
|
||||
expect(sendCommand).toHaveBeenCalledOnce();
|
||||
expect(auditSink).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("rejects unsupported and unsafe operations", () => {
|
||||
expect(() => validateEditPlan({ operations: [{ type: "raw_script", code: "app.quit()" }] })).toThrow("unsupported type");
|
||||
expect(() => validateEditPlan({ operations: [{ type: "insert_clip", item_id: "x", start_seconds: -1 }] })).toThrow("non-negative");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildEditorialContextPack,
|
||||
MAX_EDITORIAL_CONTEXT_PACK_CHARACTERS,
|
||||
MIN_EDITORIAL_CONTEXT_PACK_CHARACTERS,
|
||||
} from "../src/ai/editorial-context-pack.js";
|
||||
import {
|
||||
ProjectContextRepository,
|
||||
searchProjectContext,
|
||||
type ProjectContextDocument,
|
||||
} from "../src/context/project-context-store.js";
|
||||
import { getEditorialContextPackTools } from "../src/tools/editorial-context-pack.js";
|
||||
import { createServer } from "../src/server.js";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
|
||||
function document(): ProjectContextDocument {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
projectId: "project-1",
|
||||
projectName: "Launch interview",
|
||||
revision: "context-r1",
|
||||
sourceRevision: "source-r1",
|
||||
timelineRevision: "timeline-r1",
|
||||
updatedAt: "2026-09-02T00:00:00.000Z",
|
||||
records: [
|
||||
{
|
||||
id: "source-1",
|
||||
kind: "source",
|
||||
name: "Founder interview",
|
||||
text: "Source Founder interview. Media type mov.",
|
||||
keywords: ["interview", "mov"],
|
||||
sourceId: "source-item-1",
|
||||
sourceRevision: "source-r1",
|
||||
indexedAt: "2026-09-02T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "transcript-1",
|
||||
kind: "transcript",
|
||||
name: "Founder on the launch budget",
|
||||
text: "We made the launch budget work by simplifying the onboarding flow.",
|
||||
keywords: ["launch", "budget", "onboarding"],
|
||||
sequenceId: "sequence-1",
|
||||
sourceId: "source-item-1",
|
||||
startSeconds: 12.5,
|
||||
endSeconds: 17.25,
|
||||
sourceRevision: "source-r1",
|
||||
timelineRevision: "timeline-r1",
|
||||
metadata: { secret: "must-not-appear" },
|
||||
indexedAt: "2026-09-02T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "shot-1",
|
||||
kind: "shot",
|
||||
name: "Product close-up",
|
||||
text: "Product close-up during the onboarding explanation.",
|
||||
keywords: ["product", "onboarding"],
|
||||
sequenceId: "sequence-1",
|
||||
sourceId: "source-item-2",
|
||||
startSeconds: 30,
|
||||
endSeconds: 35,
|
||||
sourceRevision: "source-r1",
|
||||
indexedAt: "2026-09-02T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("editorial context pack", () => {
|
||||
it("renders stable, compact evidence without leaking metadata", () => {
|
||||
const source = document();
|
||||
const results = searchProjectContext(source, { query: "launch budget onboarding", limit: 12 });
|
||||
const first = buildEditorialContextPack(source, { intent: "launch budget onboarding", results });
|
||||
const second = buildEditorialContextPack(source, { intent: "launch budget onboarding", results });
|
||||
|
||||
expect(first).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
projectId: "project-1",
|
||||
expectedContextRevision: "context-r1",
|
||||
expectedSourceRevision: "source-r1",
|
||||
expectedTimelineRevision: "timeline-r1",
|
||||
applied: false,
|
||||
truncated: false,
|
||||
});
|
||||
expect(first.markdown).toContain("# Premiere editorial context pack");
|
||||
expect(first.markdown).toContain("evidence transcript-1");
|
||||
expect(first.markdown).toContain("12.500s–17.250s");
|
||||
expect(first.markdown).toContain("We made the launch budget work");
|
||||
expect(first.markdown).not.toContain("must-not-appear");
|
||||
expect(first.evidence[0]).toMatchObject({
|
||||
evidenceId: "transcript-1",
|
||||
kind: "transcript",
|
||||
sourceId: "source-item-1",
|
||||
startSeconds: 12.5,
|
||||
endSeconds: 17.25,
|
||||
textTruncated: false,
|
||||
});
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
it("enforces a strict Markdown cap and labels partial evidence", () => {
|
||||
const source = document();
|
||||
source.records[1].text = `Budget evidence ${"with supporting detail ".repeat(3_000)}`;
|
||||
const results = searchProjectContext(source, { query: "budget", limit: 12 });
|
||||
const pack = buildEditorialContextPack(source, {
|
||||
intent: "budget",
|
||||
results,
|
||||
maxCharacters: MIN_EDITORIAL_CONTEXT_PACK_CHARACTERS,
|
||||
});
|
||||
|
||||
expect(pack.markdown.length).toBeLessThanOrEqual(MIN_EDITORIAL_CONTEXT_PACK_CHARACTERS);
|
||||
expect(pack.truncated).toBe(true);
|
||||
expect(pack.evidence[0]).toMatchObject({ evidenceId: "transcript-1", textTruncated: true });
|
||||
expect(pack.markdown).toContain("[excerpt truncated]");
|
||||
});
|
||||
|
||||
it("preserves the Markdown cap when project labels and revisions are long", () => {
|
||||
const source = document();
|
||||
source.projectName = "Project ".repeat(80);
|
||||
source.revision = "context-".repeat(100);
|
||||
source.sourceRevision = "source-".repeat(100);
|
||||
source.timelineRevision = "timeline-".repeat(100);
|
||||
const intent = `budget ${"review ".repeat(140)}`;
|
||||
const results = searchProjectContext(source, { query: "budget", limit: 12 });
|
||||
const pack = buildEditorialContextPack(source, {
|
||||
intent,
|
||||
results,
|
||||
maxCharacters: MIN_EDITORIAL_CONTEXT_PACK_CHARACTERS,
|
||||
});
|
||||
|
||||
expect(pack.intent).toBe(intent.trim());
|
||||
expect(pack.markdown.length).toBeLessThanOrEqual(MIN_EDITORIAL_CONTEXT_PACK_CHARACTERS);
|
||||
});
|
||||
|
||||
it("validates pack bounds before producing output", () => {
|
||||
const source = document();
|
||||
const results = searchProjectContext(source, { query: "budget" });
|
||||
expect(() => buildEditorialContextPack(source, { intent: " ", results })).toThrow("intent must not be empty");
|
||||
expect(() => buildEditorialContextPack(source, { intent: "budget", results, maxEntries: 0 })).toThrow("max_entries");
|
||||
expect(() => buildEditorialContextPack(source, { intent: "budget", results, maxCharacters: MIN_EDITORIAL_CONTEXT_PACK_CHARACTERS - 1 })).toThrow("max_characters");
|
||||
expect(() => buildEditorialContextPack(source, { intent: "budget", results, maxCharacters: MAX_EDITORIAL_CONTEXT_PACK_CHARACTERS + 1 })).toThrow("max_characters");
|
||||
});
|
||||
|
||||
it("omits unavailable record metadata from compact evidence", () => {
|
||||
const source = document();
|
||||
const record = source.records[0];
|
||||
delete record.sourceId;
|
||||
delete record.sourceRevision;
|
||||
const results = searchProjectContext(source, { query: "interview" }).filter((result) => result.record.id === record.id);
|
||||
|
||||
const pack = buildEditorialContextPack(source, { intent: "interview", results });
|
||||
|
||||
expect(pack.evidence).toHaveLength(1);
|
||||
expect(pack.evidence[0]).not.toHaveProperty("sequenceId");
|
||||
expect(pack.evidence[0]).not.toHaveProperty("sourceId");
|
||||
expect(pack.evidence[0]).not.toHaveProperty("timelineItemId");
|
||||
expect(pack.evidence[0]).not.toHaveProperty("startSeconds");
|
||||
expect(pack.evidence[0]).not.toHaveProperty("endSeconds");
|
||||
expect(pack.evidence[0]).not.toHaveProperty("sourceRevision");
|
||||
expect(pack.evidence[0]).not.toHaveProperty("timelineRevision");
|
||||
expect(pack.markdown).not.toContain("source source-item-1");
|
||||
});
|
||||
|
||||
it("reads only stored local context and rejects invalid tool input", async () => {
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
const tools = getEditorialContextPackTools({ repository });
|
||||
await repository.put(document());
|
||||
|
||||
await expect(tools.create_editorial_context_pack.handler({
|
||||
project_id: "project-1",
|
||||
intent: "launch budget",
|
||||
kinds: ["transcript"],
|
||||
max_entries: 4,
|
||||
max_characters: 2_000,
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
applied: false,
|
||||
evidence: [expect.objectContaining({ kind: "transcript" })],
|
||||
},
|
||||
});
|
||||
await expect(tools.create_editorial_context_pack.handler({
|
||||
project_id: "missing",
|
||||
intent: "launch budget",
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("not found") });
|
||||
await expect(tools.create_editorial_context_pack.handler({
|
||||
project_id: "project-1",
|
||||
intent: "launch budget",
|
||||
kinds: ["transcript", "transcript"],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("duplicates") });
|
||||
await expect(tools.create_editorial_context_pack.handler({
|
||||
project_id: "project-1",
|
||||
intent: "launch budget",
|
||||
max_characters: 1,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("max_characters") });
|
||||
await expect(tools.create_editorial_context_pack.handler({
|
||||
project_id: "p".repeat(513),
|
||||
intent: "launch budget",
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("at most 512") });
|
||||
});
|
||||
|
||||
it("rejects malformed optional filters before searching context", async () => {
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
const tool = getEditorialContextPackTools({ repository }).create_editorial_context_pack;
|
||||
await repository.put(document());
|
||||
|
||||
await expect(tool.handler({ project_id: "project-1", intent: "budget", sequence_id: " " }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("sequence_id") });
|
||||
await expect(tool.handler({ project_id: "project-1", intent: "budget", kinds: [] }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("between 1 and 8") });
|
||||
await expect(tool.handler({ project_id: "project-1", intent: "budget", kinds: ["unsupported"] as any }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("supported project-context kind") });
|
||||
await expect(tool.handler({ project_id: "project-1", intent: "budget", kinds: [1] as any }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("supported project-context kind") });
|
||||
});
|
||||
|
||||
it("reports entry overflow and excludes records with no matched intent term", async () => {
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
const source = document();
|
||||
for (let index = 2; index <= 100; index++) {
|
||||
source.records.push({
|
||||
...source.records[1], id: `transcript-${index}`, name: `Budget answer ${index}`,
|
||||
text: `The launch budget answer ${index}.`,
|
||||
});
|
||||
}
|
||||
await repository.put(source);
|
||||
const tool = getEditorialContextPackTools({ repository }).create_editorial_context_pack;
|
||||
|
||||
await expect(tool.handler({ project_id: "project-1", intent: "budget", max_entries: 12 }))
|
||||
.resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { evidence: expect.any(Array), omittedEvidenceCount: 88, truncated: true },
|
||||
});
|
||||
await expect(tool.handler({ project_id: "project-1", intent: "unrelated", kinds: ["transcript"] }))
|
||||
.resolves.toMatchObject({ success: true, data: { evidence: [], omittedEvidenceCount: 0, truncated: false } });
|
||||
});
|
||||
|
||||
it("shares the injected server context repository with the reading tool", async () => {
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
await repository.put(document());
|
||||
const server = createServer({}, { contextRepository: repository });
|
||||
const client = new Client({ name: "editorial-context-pack-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
try {
|
||||
const result = await client.callTool({
|
||||
name: "create_editorial_context_pack",
|
||||
arguments: { project_id: "project-1", intent: "launch budget" },
|
||||
});
|
||||
expect((result.structuredContent as any).data).toMatchObject({ projectId: "project-1", applied: false });
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
+808
@@ -0,0 +1,808 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildEditorialPlan,
|
||||
validateEditorialPlan,
|
||||
} from "../src/ai/editorial-plan.js";
|
||||
import { ProjectContextRepository } from "../src/context/project-context-store.js";
|
||||
import { buildContextDocumentFromSnapshot, enrichContextDocument, type PremiereContextSnapshot } from "../src/tools/project-context.js";
|
||||
import { getEditorialPlanTools } from "../src/tools/editorial-plans.js";
|
||||
import type { UxpWebSocketBridge } from "../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
async function context() {
|
||||
const snapshot: PremiereContextSnapshot = {
|
||||
projectName: "Documentary",
|
||||
projectPath: "D:/Projects/documentary.prproj",
|
||||
sequence: {
|
||||
id: "sequence-1",
|
||||
name: "Assembly",
|
||||
durationSeconds: 120,
|
||||
truncated: false,
|
||||
clips: [{
|
||||
nodeId: "timeline-1",
|
||||
name: "Interview A",
|
||||
startSeconds: 10,
|
||||
endSeconds: 20,
|
||||
inPointSeconds: 30,
|
||||
outPointSeconds: 40,
|
||||
speed: 1,
|
||||
trackType: "video",
|
||||
trackIndex: 0,
|
||||
sourceId: "source-1",
|
||||
sourceName: "Interview A.mov",
|
||||
mediaPath: "D:/Media/interview-a.mov",
|
||||
offline: false,
|
||||
}],
|
||||
},
|
||||
};
|
||||
const built = await buildContextDocumentFromSnapshot(snapshot, undefined, async () => ({ mediaPathHash: "source-hash" }));
|
||||
const source = built.document.records.find((record) => record.kind === "source")!;
|
||||
return enrichContextDocument(built.document, [{
|
||||
kind: "transcript",
|
||||
source_id: "source-1",
|
||||
source_revision: source.sourceRevision,
|
||||
start_seconds: 31,
|
||||
end_seconds: 35,
|
||||
text: "The budget needs approval by Friday.",
|
||||
keywords: ["budget", "approval", "interview"],
|
||||
metadata: { confidence: 0.97 },
|
||||
}]).document;
|
||||
}
|
||||
|
||||
function verifiedUxpReadback(boundary: string, result: Record<string, unknown>) {
|
||||
return {
|
||||
...result,
|
||||
outcome: "verified",
|
||||
verified: true,
|
||||
verificationBoundary: boundary,
|
||||
operation: {
|
||||
verification: {
|
||||
status: "verified",
|
||||
boundary,
|
||||
evidence: [{ type: boundary, verified: true }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createAndPreviewOrganizationPlan(
|
||||
tools: any,
|
||||
document: Awaited<ReturnType<typeof context>>,
|
||||
organizationRules: Array<{ name: string; keywords: string[]; colorIndex?: number }>,
|
||||
) {
|
||||
const created = await tools.create_editorial_plan.handler({
|
||||
project_id: document.projectId,
|
||||
workflow: "organize",
|
||||
intent: "interview budget",
|
||||
organization_rules: organizationRules.map((rule) => ({
|
||||
name: rule.name,
|
||||
keywords: rule.keywords,
|
||||
...(rule.colorIndex === undefined ? {} : { color_index: rule.colorIndex }),
|
||||
})),
|
||||
});
|
||||
expect(created).toMatchObject({ success: true, data: { workflow: "organize", applied: false } });
|
||||
const plan = created.data;
|
||||
const preview = await tools.preview_editorial_plan.handler({ plan });
|
||||
expect(preview).toMatchObject({ success: true, data: { applied: false, workflow: "organize" } });
|
||||
return { plan, confirmationToken: preview.data.confirmationToken as string };
|
||||
}
|
||||
|
||||
describe("editorial workflow plans", () => {
|
||||
it("creates a review-only organization plan from explicit rules", async () => {
|
||||
const document = await context();
|
||||
const plan = buildEditorialPlan(document, {
|
||||
workflow: "organize",
|
||||
intent: "interview budget approval",
|
||||
organizationRules: [{ name: "Interviews", keywords: ["interview"], colorIndex: 3 }],
|
||||
});
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
workflow: "organize",
|
||||
applied: false,
|
||||
expectedContextRevision: document.revision,
|
||||
expectedTimelineRevision: document.timelineRevision,
|
||||
recommendations: [expect.objectContaining({
|
||||
route: "apply_editorial_organization_plan",
|
||||
mutatesProject: false,
|
||||
requiresReview: true,
|
||||
})],
|
||||
});
|
||||
expect(plan.limitations.join(" ")).toContain("does not call an LLM");
|
||||
});
|
||||
|
||||
it("routes rough cuts and caption review without claiming automatic application", async () => {
|
||||
const document = await context();
|
||||
const roughCut = buildEditorialPlan(document, { workflow: "rough_cut", intent: "budget approval" });
|
||||
const caption = buildEditorialPlan(document, { workflow: "caption_review", intent: "budget approval" });
|
||||
|
||||
expect(roughCut.recommendations[0]).toMatchObject({ route: "preview_transcript_edit_uxp", mutatesProject: false });
|
||||
expect(roughCut.limitations.join(" ")).toContain("licensed Premiere host");
|
||||
expect(caption.recommendations[0]).toMatchObject({ route: "create_caption_track", mutatesProject: false });
|
||||
expect(caption.limitations.join(" ")).toContain("paid-provider request");
|
||||
});
|
||||
|
||||
it("plans bounded platform cutdowns without creating, reframing, or exporting a sequence", async () => {
|
||||
const document = await context();
|
||||
const plan = buildEditorialPlan(document, {
|
||||
workflow: "platform_cutdown",
|
||||
intent: "interview budget",
|
||||
platformTargets: [{ name: "Vertical short", width: 1080, height: 1920, includeCaptions: true }],
|
||||
});
|
||||
|
||||
expect(plan).toMatchObject({ workflow: "platform_cutdown", applied: false });
|
||||
expect(plan.recommendations[0]).toMatchObject({
|
||||
kind: "create_platform_cutdown",
|
||||
route: "manage_sequences_uxp",
|
||||
details: expect.objectContaining({
|
||||
sourceSequenceId: "sequence-1",
|
||||
target: { width: 1080, height: 1920 },
|
||||
includeCaptions: true,
|
||||
}),
|
||||
});
|
||||
expect((plan.recommendations[0].details.nextRoutes as Array<{ tool: string }>).map((route) => route.tool))
|
||||
.toEqual(["manage_sequences_uxp", "auto_reframe_sequence", "create_caption_track", "get_sequence_structure", "export_sequence"]);
|
||||
expect(plan.limitations.join(" ")).toContain("does not create a sequence");
|
||||
|
||||
expect(() => buildEditorialPlan(document, { workflow: "platform_cutdown", intent: "cutdown" }))
|
||||
.toThrow("platform_targets are required");
|
||||
expect(() => buildEditorialPlan(document, {
|
||||
workflow: "platform_cutdown",
|
||||
intent: "cutdown",
|
||||
sequenceId: "missing-sequence",
|
||||
platformTargets: [{ name: "Square", width: 1080, height: 1080 }],
|
||||
})).toThrow("matching sequence_id");
|
||||
expect(() => buildEditorialPlan(document, {
|
||||
workflow: "platform_cutdown",
|
||||
intent: "cutdown",
|
||||
platformTargets: [{ name: "Bad dimensions", width: 0, height: 1080 }],
|
||||
})).toThrow("width must be an integer");
|
||||
});
|
||||
|
||||
it("requires explicit categories and rejects plans that claim they were applied", async () => {
|
||||
const document = await context();
|
||||
expect(() => buildEditorialPlan(document, { workflow: "organize", intent: "interviews" }))
|
||||
.toThrow("organization_rules are required");
|
||||
|
||||
const plan = buildEditorialPlan(document, { workflow: "stringout", intent: "interview" });
|
||||
expect(() => validateEditorialPlan({ ...plan, applied: true })).toThrow("preview-only");
|
||||
expect(() => validateEditorialPlan({ ...plan, recommendations: [] })).toThrow("recommendations must contain");
|
||||
expect(() => buildEditorialPlan(document, { workflow: "stringout", intent: "interview", maxCandidates: Number.POSITIVE_INFINITY }))
|
||||
.toThrow("max_candidates must be a finite number");
|
||||
});
|
||||
|
||||
it("bounds malformed organization, cutdown, and serialized-plan inputs", async () => {
|
||||
const document = await context();
|
||||
for (const rules of [
|
||||
{} as any,
|
||||
Array.from({ length: 17 }, () => ({ name: "Bin", keywords: ["interview"] })),
|
||||
[null],
|
||||
[{ name: "", keywords: ["interview"] }],
|
||||
[{ name: "Bin", keywords: [] }],
|
||||
[{ name: "Bin", keywords: ["interview"] }, { name: "bin", keywords: ["budget"] }],
|
||||
[{ name: "Bin", keywords: ["interview"], color_index: 15 }],
|
||||
]) {
|
||||
expect(() => buildEditorialPlan(document, { workflow: "organize", intent: "interview", organizationRules: rules }))
|
||||
.toThrow();
|
||||
}
|
||||
for (const targets of [
|
||||
[],
|
||||
[null],
|
||||
[{ name: "", width: 1080, height: 1920 }],
|
||||
[{ name: "Square", width: 1080, height: 1080 }, { name: "square", width: 1080, height: 1080 }],
|
||||
[{ name: "Missing width", height: 1080 }],
|
||||
[{ name: "Bad height", width: 1080, height: 0 }],
|
||||
[{ name: "Bad captions", width: 1080, height: 1920, include_captions: "yes" }],
|
||||
]) {
|
||||
expect(() => buildEditorialPlan(document, { workflow: "platform_cutdown", intent: "cutdown", platformTargets: targets as any }))
|
||||
.toThrow();
|
||||
}
|
||||
|
||||
const plan = buildEditorialPlan(document, { workflow: "stringout", intent: "interview" });
|
||||
for (const malformed of [
|
||||
null,
|
||||
{ ...plan, schemaVersion: 2 },
|
||||
{ ...plan, workflow: "unknown" },
|
||||
{ ...plan, projectId: "" },
|
||||
{ ...plan, candidates: null },
|
||||
{ ...plan, candidates: [{}] },
|
||||
{ ...plan, recommendations: [] },
|
||||
{ ...plan, recommendations: [null] },
|
||||
{ ...plan, recommendations: [{ ...plan.recommendations[0], route: "" }] },
|
||||
{ ...plan, recommendations: [{ ...plan.recommendations[0], mutatesProject: true }] },
|
||||
{ ...plan, recommendations: [{ ...plan.recommendations[0], candidateEvidenceIds: ["unknown-evidence"] }] },
|
||||
{ ...plan, limitations: [] },
|
||||
]) {
|
||||
expect(() => validateEditorialPlan(malformed)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates and previews plans only while the captured revision remains current", async () => {
|
||||
const document = await context();
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
await repository.put(document);
|
||||
const tools = getEditorialPlanTools({ repository });
|
||||
|
||||
const created = await tools.create_editorial_plan.handler({
|
||||
project_id: document.projectId,
|
||||
workflow: "stringout",
|
||||
intent: "interview budget",
|
||||
});
|
||||
expect(created).toMatchObject({ success: true, data: { applied: false, workflow: "stringout" } });
|
||||
|
||||
const plan = created.data as any;
|
||||
const preview = await tools.preview_editorial_plan.handler({ plan });
|
||||
expect(preview).toMatchObject({ success: true, data: { applied: false, workflow: "stringout" } });
|
||||
expect((preview as any).data.confirmationToken).toMatch(/^[A-Za-z0-9_-]{32,}$/);
|
||||
|
||||
await repository.put({
|
||||
...document,
|
||||
revision: "changed-context-revision",
|
||||
timelineRevision: "changed-timeline-revision",
|
||||
});
|
||||
await expect(tools.preview_editorial_plan.handler({ plan }))
|
||||
.resolves.toEqual(expect.objectContaining({ success: false, error: expect.stringContaining("stale") }));
|
||||
});
|
||||
|
||||
it("rejects client-modified server-issued plans during preview and apply", async () => {
|
||||
const document = await context();
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
await repository.put(document);
|
||||
const request = vi.fn();
|
||||
const tools = getEditorialPlanTools({ repository, uxpBridge: { request } as unknown as UxpWebSocketBridge }) as any;
|
||||
const { plan, confirmationToken } = await createAndPreviewOrganizationPlan(
|
||||
tools,
|
||||
document,
|
||||
[{ name: "Interviews", keywords: ["interview"] }],
|
||||
);
|
||||
const sourceIndex = plan.candidates.findIndex((candidate: any) => candidate.kind === "source");
|
||||
const source = plan.candidates[sourceIndex];
|
||||
const recommendation = plan.recommendations[0];
|
||||
const sourceGuards = [{ evidence_id: source.evidenceId, project_item_id: source.sourceId, expected_parent_id: "root-bin" }];
|
||||
const forgedPlans = [
|
||||
{
|
||||
...plan,
|
||||
candidates: plan.candidates.map((candidate: any, index: number) => index === sourceIndex
|
||||
? { ...candidate, sourceId: "attacker-source" }
|
||||
: candidate),
|
||||
},
|
||||
{
|
||||
...plan,
|
||||
recommendations: [{ ...recommendation, details: { ...recommendation.details, proposedBinName: "Attacker Bin" } }],
|
||||
},
|
||||
{
|
||||
...plan,
|
||||
recommendations: [{ ...recommendation, route: "organize_project_items_uxp" }],
|
||||
},
|
||||
{
|
||||
...plan,
|
||||
recommendations: [{ ...recommendation, candidateEvidenceIds: [] }],
|
||||
},
|
||||
];
|
||||
|
||||
for (const forgedPlan of forgedPlans) {
|
||||
await expect(tools.preview_editorial_plan.handler({ plan: forgedPlan }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("was not issued") });
|
||||
await expect(tools.apply_editorial_organization_plan.handler({
|
||||
plan: forgedPlan,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "forged-plan",
|
||||
organization_operations: [{ recommendation_id: recommendation.id, source_guards: sourceGuards }],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("was not issued") });
|
||||
}
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects duplicate source guards across the complete organization batch before UXP mutation", async () => {
|
||||
const document = await context();
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
await repository.put(document);
|
||||
const request = vi.fn();
|
||||
const tools = getEditorialPlanTools({ repository, uxpBridge: { request } as unknown as UxpWebSocketBridge }) as any;
|
||||
const { plan, confirmationToken } = await createAndPreviewOrganizationPlan(
|
||||
tools,
|
||||
document,
|
||||
[
|
||||
{ name: "Interviews", keywords: ["interview"] },
|
||||
{ name: "Interviews again", keywords: ["interview"] },
|
||||
],
|
||||
);
|
||||
const source = plan.candidates.find((candidate: any) => candidate.kind === "source")!;
|
||||
const sourceGuard = { evidence_id: source.evidenceId, project_item_id: source.sourceId, expected_parent_id: "root-bin" };
|
||||
|
||||
await expect(tools.apply_editorial_organization_plan.handler({
|
||||
plan,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "duplicate-evidence",
|
||||
organization_operations: plan.recommendations.map((recommendation: any) => ({
|
||||
recommendation_id: recommendation.id,
|
||||
source_guards: [sourceGuard],
|
||||
})),
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("duplicate evidence_id") });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
|
||||
const duplicateSourceDocument = structuredClone(document);
|
||||
const originalSource = duplicateSourceDocument.records.find((record) => record.kind === "source")!;
|
||||
duplicateSourceDocument.records.push({
|
||||
...originalSource,
|
||||
id: "duplicate-source-evidence",
|
||||
name: "Interview alternate record.mov",
|
||||
text: "Interview alternate record.",
|
||||
keywords: ["interview"],
|
||||
});
|
||||
await repository.put(duplicateSourceDocument);
|
||||
const duplicateSourceTools = getEditorialPlanTools({ repository, uxpBridge: { request } as unknown as UxpWebSocketBridge }) as any;
|
||||
const duplicateSourcePlan = await createAndPreviewOrganizationPlan(
|
||||
duplicateSourceTools,
|
||||
duplicateSourceDocument,
|
||||
[
|
||||
{ name: "Interviews", keywords: ["interview"] },
|
||||
{ name: "Interview copies", keywords: ["interview"] },
|
||||
],
|
||||
);
|
||||
const [firstEvidence, secondEvidence] = duplicateSourcePlan.plan.candidates
|
||||
.filter((candidate: any) => candidate.kind === "source")
|
||||
.map((candidate: any) => candidate.evidenceId);
|
||||
|
||||
await expect(duplicateSourceTools.apply_editorial_organization_plan.handler({
|
||||
plan: duplicateSourcePlan.plan,
|
||||
confirmation_token: duplicateSourcePlan.confirmationToken,
|
||||
operation_id: "duplicate-project-item",
|
||||
organization_operations: duplicateSourcePlan.plan.recommendations.map((recommendation: any, index: number) => ({
|
||||
recommendation_id: recommendation.id,
|
||||
source_guards: [{
|
||||
evidence_id: index === 0 ? firstEvidence : secondEvidence,
|
||||
project_item_id: "source-1",
|
||||
expected_parent_id: "root-bin",
|
||||
}],
|
||||
})),
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("duplicate project_item_id") });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps platform-cutdown creation and preview local even when a UXP bridge is available", async () => {
|
||||
const document = await context();
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
await repository.put(document);
|
||||
const request = vi.fn().mockRejectedValue(new Error("A plan must not call the host"));
|
||||
const tools = getEditorialPlanTools({ repository, uxpBridge: { request } as unknown as UxpWebSocketBridge });
|
||||
|
||||
const created = await tools.create_editorial_plan.handler({
|
||||
project_id: document.projectId,
|
||||
workflow: "platform_cutdown",
|
||||
intent: "interview budget",
|
||||
platform_targets: [{ name: "Vertical", width: 1080, height: 1920, include_captions: true }],
|
||||
});
|
||||
expect(created).toMatchObject({
|
||||
success: true,
|
||||
data: { workflow: "platform_cutdown", applied: false },
|
||||
});
|
||||
await expect(tools.preview_editorial_plan.handler({ plan: created.data }))
|
||||
.resolves.toMatchObject({ success: true, data: { applied: false, workflow: "platform_cutdown" } });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns clear errors when a project context is missing", async () => {
|
||||
const tools = getEditorialPlanTools({ repository: new ProjectContextRepository({ backend: "memory" }) });
|
||||
await expect(tools.create_editorial_plan.handler({
|
||||
project_id: "missing",
|
||||
workflow: "stringout",
|
||||
intent: "interview",
|
||||
})).resolves.toEqual({ success: false, error: "Project context not found; capture it before creating an editorial plan" });
|
||||
});
|
||||
|
||||
it("registers organization application only with the UXP bridge and applies each guarded host transaction", async () => {
|
||||
const document = await context();
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
await repository.put(document);
|
||||
const request = vi.fn()
|
||||
.mockResolvedValueOnce(verifiedUxpReadback("bin_child_id_readback", { created: true, item: { id: "bin-interviews" } }))
|
||||
.mockResolvedValueOnce(verifiedUxpReadback("project_item_parent_readback", {
|
||||
moved: true,
|
||||
destinationBinId: "bin-interviews",
|
||||
after: { parentId: "bin-interviews" },
|
||||
}))
|
||||
.mockResolvedValueOnce(verifiedUxpReadback("project_item_color_readback", {
|
||||
updated: true,
|
||||
after: { colorLabelIndex: 3 },
|
||||
}));
|
||||
const bridge = { request } as unknown as UxpWebSocketBridge;
|
||||
const withoutUxp = getEditorialPlanTools({ repository });
|
||||
expect(withoutUxp).not.toHaveProperty("apply_editorial_organization_plan");
|
||||
const tools = getEditorialPlanTools({ repository, uxpBridge: bridge }) as any;
|
||||
expect(tools.apply_editorial_organization_plan.operationalCapability).toMatchObject({
|
||||
verificationBoundary: "structured_uxp_readback",
|
||||
hostVerificationRequired: true,
|
||||
});
|
||||
const { plan, confirmationToken } = await createAndPreviewOrganizationPlan(
|
||||
tools,
|
||||
document,
|
||||
[{ name: "Interviews", keywords: ["interview"], colorIndex: 3 }],
|
||||
);
|
||||
const source = plan.candidates.find((candidate: any) => candidate.kind === "source")!;
|
||||
expect(source).toMatchObject({ sourceId: "source-1" });
|
||||
|
||||
const result = await tools.apply_editorial_organization_plan.handler({
|
||||
plan,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "organize-2026-08-22",
|
||||
organization_operations: [{
|
||||
recommendation_id: "organization-1",
|
||||
source_guards: [{ evidence_id: source.evidenceId, project_item_id: "source-1", expected_parent_id: "root-bin" }],
|
||||
}],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: { applied: true, outcome: "verified", verified: true, committedActions: [
|
||||
{ action: "create_bin", verified: true },
|
||||
{ action: "move", projectItemId: "source-1", verified: true },
|
||||
{ action: "set_color", projectItemId: "source-1", verified: true },
|
||||
] },
|
||||
});
|
||||
expect(request.mock.calls).toEqual([
|
||||
["bins.create", expect.objectContaining({ name: "Interviews", makeUnique: true, operationId: expect.stringMatching(/^organize-2026-08-22:create:/) })],
|
||||
["bins.move", expect.objectContaining({ projectItemId: "source-1", destinationBinId: "bin-interviews", expectedParentId: "root-bin", operationId: expect.stringMatching(/^organize-2026-08-22:move:/) })],
|
||||
["bins.color", expect.objectContaining({ projectItemId: "source-1", colorIndex: 3, operationId: expect.stringMatching(/^organize-2026-08-22:color:/) })],
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts an unchanged editorial plan after an MCP JSON round trip", async () => {
|
||||
const document = await context();
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
await repository.put(document);
|
||||
const request = vi.fn()
|
||||
.mockResolvedValueOnce(verifiedUxpReadback("bin_child_id_readback", { created: true, item: { id: "bin-interviews" } }))
|
||||
.mockResolvedValueOnce(verifiedUxpReadback("project_item_parent_readback", {
|
||||
moved: true,
|
||||
destinationBinId: "bin-interviews",
|
||||
after: { parentId: "bin-interviews" },
|
||||
}));
|
||||
const tools = getEditorialPlanTools({ repository, uxpBridge: { request } as unknown as UxpWebSocketBridge }) as any;
|
||||
const { plan, confirmationToken } = await createAndPreviewOrganizationPlan(
|
||||
tools,
|
||||
document,
|
||||
[{ name: "Interviews", keywords: ["interview"] }],
|
||||
);
|
||||
const roundTrippedPlan = JSON.parse(JSON.stringify(plan));
|
||||
const source = roundTrippedPlan.candidates.find((candidate: any) => candidate.kind === "source")!;
|
||||
|
||||
await expect(tools.apply_editorial_organization_plan.handler({
|
||||
plan: roundTrippedPlan,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "organize-json-roundtrip",
|
||||
organization_operations: [{
|
||||
recommendation_id: "organization-1",
|
||||
source_guards: [{ evidence_id: source.evidenceId, project_item_id: "source-1", expected_parent_id: "root-bin" }],
|
||||
}],
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { applied: true, outcome: "verified", verified: true },
|
||||
});
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fails closed before UXP mutation for stale, unconfirmed, or unplanned organization input", async () => {
|
||||
const document = await context();
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
await repository.put(document);
|
||||
const request = vi.fn();
|
||||
const tools = getEditorialPlanTools({ repository, uxpBridge: { request } as unknown as UxpWebSocketBridge }) as any;
|
||||
const { plan, confirmationToken } = await createAndPreviewOrganizationPlan(
|
||||
tools,
|
||||
document,
|
||||
[{ name: "Interviews", keywords: ["interview"] }],
|
||||
);
|
||||
const source = plan.candidates.find((candidate: any) => candidate.kind === "source")!;
|
||||
const unpreviewed = await tools.create_editorial_plan.handler({
|
||||
project_id: document.projectId,
|
||||
workflow: "organize",
|
||||
intent: "interview budget",
|
||||
organization_rules: [{ name: "Unpreviewed", keywords: ["interview"] }],
|
||||
});
|
||||
const unpreviewedPlan = unpreviewed.data;
|
||||
const unpreviewedSource = unpreviewedPlan.candidates.find((candidate: any) => candidate.kind === "source")!;
|
||||
const validOperations = [{
|
||||
recommendation_id: "organization-1",
|
||||
source_guards: [{ evidence_id: source.evidenceId, project_item_id: "source-1", expected_parent_id: "root-bin" }],
|
||||
}];
|
||||
|
||||
await expect(tools.apply_editorial_organization_plan.handler({
|
||||
plan: unpreviewedPlan,
|
||||
confirmation_token: "not-previewed",
|
||||
operation_id: "organize-unpreviewed",
|
||||
organization_operations: [{
|
||||
recommendation_id: "organization-1",
|
||||
source_guards: [{ evidence_id: unpreviewedSource.evidenceId, project_item_id: "source-1", expected_parent_id: "root-bin" }],
|
||||
}],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("opaque token") });
|
||||
await expect(tools.apply_editorial_organization_plan.handler({
|
||||
plan,
|
||||
confirmation_token: "wrong-token",
|
||||
operation_id: "organize-invalid",
|
||||
organization_operations: validOperations,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("confirmation_token") });
|
||||
await expect(tools.apply_editorial_organization_plan.handler({
|
||||
plan,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "organize-invalid",
|
||||
organization_operations: [{ ...validOperations[0], source_guards: [{ ...validOperations[0].source_guards[0], project_item_id: "unplanned-source" }] }],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("must match the planned source ID") });
|
||||
|
||||
await repository.put({ ...document, revision: "new-revision", timelineRevision: "new-timeline-revision" });
|
||||
await expect(tools.apply_editorial_organization_plan.handler({
|
||||
plan,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "organize-stale",
|
||||
organization_operations: validOperations,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("stale") });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports completed UXP transactions as partial without claiming rollback or verification", async () => {
|
||||
const document = await context();
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
await repository.put(document);
|
||||
const request = vi.fn()
|
||||
.mockResolvedValueOnce(verifiedUxpReadback("bin_child_id_readback", { created: true, item: { id: "bin-interviews" } }))
|
||||
.mockRejectedValueOnce(new Error("expected parent no longer matches"));
|
||||
const tools = getEditorialPlanTools({ repository, uxpBridge: { request } as unknown as UxpWebSocketBridge }) as any;
|
||||
const { plan, confirmationToken } = await createAndPreviewOrganizationPlan(
|
||||
tools,
|
||||
document,
|
||||
[{ name: "Interviews", keywords: ["interview"], colorIndex: 3 }],
|
||||
);
|
||||
const source = plan.candidates.find((candidate: any) => candidate.kind === "source")!;
|
||||
|
||||
await expect(tools.apply_editorial_organization_plan.handler({
|
||||
plan,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "organize-partial",
|
||||
organization_operations: [{
|
||||
recommendation_id: "organization-1",
|
||||
source_guards: [{ evidence_id: source.evidenceId, project_item_id: "source-1", expected_parent_id: "root-bin" }],
|
||||
}],
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
applied: true,
|
||||
outcome: "partial",
|
||||
verified: false,
|
||||
committedActions: [{ action: "create_bin" }],
|
||||
unverifiedAttempts: [{ action: "move", verified: false }],
|
||||
nextSteps: expect.arrayContaining([expect.stringContaining("undo the verified completed host transactions")]),
|
||||
},
|
||||
});
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("halts before a later mutation when a verified UXP reply contradicts its readback", async () => {
|
||||
const document = await context();
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
await repository.put(document);
|
||||
const request = vi.fn()
|
||||
.mockResolvedValueOnce(verifiedUxpReadback("bin_child_id_readback", { created: true, item: { id: "bin-interviews" } }))
|
||||
.mockResolvedValueOnce(verifiedUxpReadback("project_item_parent_readback", {
|
||||
moved: true,
|
||||
destinationBinId: "bin-interviews",
|
||||
after: { parentId: "wrong-bin" },
|
||||
}))
|
||||
.mockRejectedValueOnce(new Error("Color should not be attempted after a contradictory move readback"));
|
||||
const tools = getEditorialPlanTools({ repository, uxpBridge: { request } as unknown as UxpWebSocketBridge }) as any;
|
||||
const { plan, confirmationToken } = await createAndPreviewOrganizationPlan(
|
||||
tools,
|
||||
document,
|
||||
[{ name: "Interviews", keywords: ["interview"], colorIndex: 3 }],
|
||||
);
|
||||
const source = plan.candidates.find((candidate: any) => candidate.kind === "source")!;
|
||||
|
||||
await expect(tools.apply_editorial_organization_plan.handler({
|
||||
plan,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "organize-contradictory-readback",
|
||||
organization_operations: [{
|
||||
recommendation_id: "organization-1",
|
||||
source_guards: [{ evidence_id: source.evidenceId, project_item_id: "source-1", expected_parent_id: "root-bin" }],
|
||||
}],
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
outcome: "partial",
|
||||
verified: false,
|
||||
committedActions: [
|
||||
{ action: "create_bin", verified: true },
|
||||
],
|
||||
unverifiedAttempts: [{ action: "move", verified: false }],
|
||||
error: expect.stringContaining("destination-parent readback"),
|
||||
},
|
||||
});
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports a color response with the wrong observed label as partial", async () => {
|
||||
const document = await context();
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
await repository.put(document);
|
||||
const request = vi.fn()
|
||||
.mockResolvedValueOnce(verifiedUxpReadback("bin_child_id_readback", { created: true, item: { id: "bin-interviews" } }))
|
||||
.mockResolvedValueOnce(verifiedUxpReadback("project_item_parent_readback", {
|
||||
moved: true,
|
||||
destinationBinId: "bin-interviews",
|
||||
after: { parentId: "bin-interviews" },
|
||||
}))
|
||||
.mockResolvedValueOnce(verifiedUxpReadback("project_item_color_readback", {
|
||||
updated: true,
|
||||
after: { colorLabelIndex: 2 },
|
||||
}));
|
||||
const tools = getEditorialPlanTools({ repository, uxpBridge: { request } as unknown as UxpWebSocketBridge }) as any;
|
||||
const { plan, confirmationToken } = await createAndPreviewOrganizationPlan(
|
||||
tools,
|
||||
document,
|
||||
[{ name: "Interviews", keywords: ["interview"], colorIndex: 3 }],
|
||||
);
|
||||
const source = plan.candidates.find((candidate: any) => candidate.kind === "source")!;
|
||||
|
||||
await expect(tools.apply_editorial_organization_plan.handler({
|
||||
plan,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "organize-color-readback",
|
||||
organization_operations: [{
|
||||
recommendation_id: "organization-1",
|
||||
source_guards: [{ evidence_id: source.evidenceId, project_item_id: "source-1", expected_parent_id: "root-bin" }],
|
||||
}],
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
outcome: "partial",
|
||||
verified: false,
|
||||
committedActions: [
|
||||
{ action: "create_bin", verified: true },
|
||||
{ action: "move", verified: true },
|
||||
],
|
||||
unverifiedAttempts: [{ action: "set_color", verified: false }],
|
||||
error: expect.stringContaining("color-label readback"),
|
||||
},
|
||||
});
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("fails a malformed first UXP response without claiming an applied plan", async () => {
|
||||
const document = await context();
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
await repository.put(document);
|
||||
const existingBinRequest = vi.fn().mockResolvedValue({ moved: true });
|
||||
const existingBinTools = getEditorialPlanTools({ repository, uxpBridge: { request: existingBinRequest } as unknown as UxpWebSocketBridge }) as any;
|
||||
const { plan, confirmationToken } = await createAndPreviewOrganizationPlan(
|
||||
existingBinTools,
|
||||
document,
|
||||
[{ name: "Interviews", keywords: ["interview"] }],
|
||||
);
|
||||
const source = plan.candidates.find((candidate: any) => candidate.kind === "source")!;
|
||||
const malformedResult = await existingBinTools.apply_editorial_organization_plan.handler({
|
||||
plan,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "organize-existing-bin",
|
||||
organization_operations: [{
|
||||
recommendation_id: "organization-1",
|
||||
destination_bin_id: "existing-bin",
|
||||
source_guards: [{ evidence_id: source.evidenceId, project_item_id: "source-1", expected_parent_id: "root-bin" }],
|
||||
}],
|
||||
});
|
||||
expect(malformedResult).toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("destination-parent readback"),
|
||||
});
|
||||
expect(malformedResult.error).toContain("inspect Premiere before retrying");
|
||||
expect(existingBinRequest).toHaveBeenCalledTimes(1);
|
||||
expect(existingBinRequest).toHaveBeenCalledWith("bins.move", expect.objectContaining({ destinationBinId: "existing-bin" }));
|
||||
|
||||
const incompleteCreateRequest = vi.fn().mockResolvedValue({ item: {}, verified: false });
|
||||
const incompleteCreateTools = getEditorialPlanTools({ repository, uxpBridge: { request: incompleteCreateRequest } as unknown as UxpWebSocketBridge }) as any;
|
||||
const incomplete = await createAndPreviewOrganizationPlan(
|
||||
incompleteCreateTools,
|
||||
document,
|
||||
[{ name: "Interviews", keywords: ["interview"] }],
|
||||
);
|
||||
await expect(incompleteCreateTools.apply_editorial_organization_plan.handler({
|
||||
plan: incomplete.plan,
|
||||
confirmation_token: incomplete.confirmationToken,
|
||||
operation_id: "organize-incomplete-create",
|
||||
organization_operations: [{
|
||||
recommendation_id: "organization-1",
|
||||
source_guards: [{ evidence_id: source.evidenceId, project_item_id: "source-1", expected_parent_id: "root-bin" }],
|
||||
}],
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("stable-bin readback"),
|
||||
});
|
||||
expect(incompleteCreateRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails a first UXP request rejection and warns that host state is unknown", async () => {
|
||||
const document = await context();
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
await repository.put(document);
|
||||
const request = vi.fn().mockRejectedValue(new Error("UXP bridge disconnected"));
|
||||
const tools = getEditorialPlanTools({ repository, uxpBridge: { request } as unknown as UxpWebSocketBridge }) as any;
|
||||
const { plan, confirmationToken } = await createAndPreviewOrganizationPlan(
|
||||
tools,
|
||||
document,
|
||||
[{ name: "Interviews", keywords: ["interview"] }],
|
||||
);
|
||||
const source = plan.candidates.find((candidate: any) => candidate.kind === "source")!;
|
||||
|
||||
const rejectedResult = await tools.apply_editorial_organization_plan.handler({
|
||||
plan,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "organize-rejected-first-request",
|
||||
organization_operations: [{
|
||||
recommendation_id: "organization-1",
|
||||
destination_bin_id: "existing-bin",
|
||||
source_guards: [{ evidence_id: source.evidenceId, project_item_id: "source-1", expected_parent_id: "root-bin" }],
|
||||
}],
|
||||
});
|
||||
expect(rejectedResult).toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("UXP bridge disconnected"),
|
||||
});
|
||||
expect(rejectedResult.error).toContain("may have reached the host");
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns local validation failures without starting a UXP transaction", async () => {
|
||||
const document = await context();
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
await repository.put(document);
|
||||
const request = vi.fn().mockRejectedValue(new Error("host unavailable"));
|
||||
const tools = getEditorialPlanTools({ repository, uxpBridge: { request } as unknown as UxpWebSocketBridge }) as any;
|
||||
const { plan, confirmationToken } = await createAndPreviewOrganizationPlan(
|
||||
tools,
|
||||
document,
|
||||
[{ name: "Interviews", keywords: ["interview"] }],
|
||||
);
|
||||
await expect(tools.create_editorial_plan.handler({
|
||||
project_id: document.projectId,
|
||||
workflow: "platform_cutdown",
|
||||
intent: "cutdown",
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("platform_targets are required") });
|
||||
await expect(tools.preview_editorial_plan.handler({ plan: null })).resolves.toMatchObject({ success: false, error: expect.stringContaining("plan must be an object") });
|
||||
await expect(tools.apply_editorial_organization_plan.handler({
|
||||
plan,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "organize-host-failure",
|
||||
organization_operations: [],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("organization_operations") });
|
||||
await expect(tools.apply_editorial_organization_plan.handler({
|
||||
plan,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "organize-host-failure",
|
||||
organization_operations: [{ recommendation_id: "unknown", source_guards: [{ evidence_id: "missing", project_item_id: "source-1", expected_parent_id: "root" }] }],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("organize_source recommendation") });
|
||||
await expect(tools.apply_editorial_organization_plan.handler({
|
||||
plan,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "organize-host-failure",
|
||||
organization_operations: [{ recommendation_id: "organization-1", source_guards: [] }],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("source_guards") });
|
||||
await expect(tools.apply_editorial_organization_plan.handler({
|
||||
plan,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "organize-host-failure",
|
||||
organization_operations: [{ recommendation_id: "organization-1", source_guards: [null] }],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("must be an object") });
|
||||
const transcript = plan.candidates.find((candidate) => candidate.kind === "transcript")!;
|
||||
await expect(tools.apply_editorial_organization_plan.handler({
|
||||
plan,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "organize-host-failure",
|
||||
organization_operations: [{ recommendation_id: "organization-1", source_guards: [{ evidence_id: transcript.evidenceId, project_item_id: "source-1", expected_parent_id: "root" }] }],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("must reference a source") });
|
||||
const stringout = buildEditorialPlan(document, { workflow: "stringout", intent: "interview" });
|
||||
await expect(tools.apply_editorial_organization_plan.handler({
|
||||
plan: stringout,
|
||||
confirmation_token: confirmationToken,
|
||||
operation_id: "organize-host-failure",
|
||||
organization_operations: [],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("only accepts an organize") });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+771
@@ -0,0 +1,771 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { dirname } from "node:path";
|
||||
import { RequestBodyTooLargeError } from "../src/http-admission.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requestHandler: undefined as undefined | ((req: any, res: any) => Promise<void>),
|
||||
listen: vi.fn((_port: number, _host: string, callback: () => void) => callback()),
|
||||
closeHttp: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
shutdown: vi.fn(async () => {}),
|
||||
cleanup: vi.fn(),
|
||||
connect: vi.fn(async () => {}),
|
||||
serveStdio: vi.fn(),
|
||||
stdioServerTransport: vi.fn(),
|
||||
closeMcp: vi.fn(async () => {}),
|
||||
handleRequest: vi.fn(async (_req: any, res: any) => { res.statusCode = 204; }),
|
||||
closeTransport: vi.fn(async () => {}),
|
||||
uxpStart: vi.fn(async () => {}),
|
||||
uxpStop: vi.fn(async () => {}),
|
||||
execFileSync: vi.fn(),
|
||||
spawnSync: vi.fn(),
|
||||
fetchLatestNpmVersion: vi.fn(async () => "1.14.8"),
|
||||
fsExists: vi.fn(() => false),
|
||||
fsStat: vi.fn(() => ({ isDirectory: () => false, isFile: () => true })),
|
||||
fsCreateReadStream: vi.fn(),
|
||||
fsReadFileSync: vi.fn(),
|
||||
streamOnce: vi.fn(),
|
||||
pipe: vi.fn(),
|
||||
readBoundedBody: vi.fn(async () => Buffer.from("{}")),
|
||||
oauthAuthenticate: vi.fn(async () => ({
|
||||
authenticated: true,
|
||||
principal: { subject: "user-1", scopes: ["premiere:mcp"], rateLimitKey: "opaque-user-key" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("node:http", () => ({
|
||||
default: {
|
||||
createServer: vi.fn((handler: any) => {
|
||||
mocks.requestHandler = handler;
|
||||
return { listen: mocks.listen, close: mocks.closeHttp };
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../src/server.js", () => ({
|
||||
createServer: vi.fn(() => ({ connect: mocks.connect, close: mocks.closeMcp })),
|
||||
}));
|
||||
vi.mock("../src/bridge/file-bridge.js", () => ({
|
||||
cleanupTempDir: mocks.cleanup,
|
||||
getTempDir: vi.fn(() => "C:\\temp\\premiere"),
|
||||
}));
|
||||
vi.mock("../src/telemetry.js", async (original) => {
|
||||
const actual = await original<typeof import("../src/telemetry.js")>();
|
||||
return { ...actual, getTelemetry: () => ({ enabled: true, capture: mocks.capture, shutdown: mocks.shutdown }) };
|
||||
});
|
||||
vi.mock("@modelcontextprotocol/server", () => ({
|
||||
createMcpHandler: vi.fn((factory: any) => ({
|
||||
factory,
|
||||
close: mocks.closeTransport,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@modelcontextprotocol/node", () => ({
|
||||
toNodeHandler: vi.fn((handler: any) => async (req: any, res: any, body: unknown) => {
|
||||
handler.factory();
|
||||
await mocks.connect();
|
||||
res.on?.("close", () => {
|
||||
void mocks.closeMcp();
|
||||
void mocks.closeTransport();
|
||||
});
|
||||
await mocks.handleRequest(req, res, body);
|
||||
}),
|
||||
}));
|
||||
vi.mock("@modelcontextprotocol/server/stdio", () => ({
|
||||
serveStdio: (...args: any[]) => mocks.serveStdio(...args),
|
||||
StdioServerTransport: class {
|
||||
constructor() { mocks.stdioServerTransport(); }
|
||||
close = mocks.closeTransport;
|
||||
},
|
||||
}));
|
||||
vi.mock("../src/http-security.js", () => ({ applyHttpSecurityHeaders: vi.fn() }));
|
||||
vi.mock("../src/http-admission.js", async (original) => {
|
||||
const actual = await original<typeof import("../src/http-admission.js")>();
|
||||
return { ...actual, readBoundedRequestBody: mocks.readBoundedBody };
|
||||
});
|
||||
vi.mock("../src/oauth-resource-server.js", () => ({
|
||||
OAuthResourceServer: class {
|
||||
authenticate = mocks.oauthAuthenticate;
|
||||
metadata() {
|
||||
return {
|
||||
resource: "https://premiere.example.com/mcp",
|
||||
authorization_servers: ["https://identity.example.com"],
|
||||
bearer_methods_supported: ["header"],
|
||||
scopes_supported: ["premiere:mcp"],
|
||||
};
|
||||
}
|
||||
challenge(error?: string) {
|
||||
return `Bearer resource_metadata="https://premiere.example.com/.well-known/oauth-protected-resource/mcp", scope="premiere:mcp"${error && error !== "missing_token" ? `, error="${error}"` : ""}`;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("../src/bridge/uxp-websocket-bridge.js", () => ({
|
||||
UxpWebSocketBridge: class {
|
||||
start = mocks.uxpStart;
|
||||
stop = mocks.uxpStop;
|
||||
address() { return { host: "127.0.0.1", port: 7788, path: "/premiere-uxp" }; }
|
||||
},
|
||||
}));
|
||||
vi.mock("../src/update.js", () => ({
|
||||
compareVersions: (left: string, right: string) => left.localeCompare(right),
|
||||
fetchLatestNpmVersion: mocks.fetchLatestNpmVersion,
|
||||
}));
|
||||
vi.mock("node:child_process", () => ({ execFileSync: mocks.execFileSync, spawnSync: mocks.spawnSync }));
|
||||
vi.mock("node:fs", async (original) => {
|
||||
const actual = await original<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
existsSync: mocks.fsExists,
|
||||
statSync: mocks.fsStat,
|
||||
createReadStream: mocks.fsCreateReadStream,
|
||||
readFileSync: mocks.fsReadFileSync,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const originalArgv = process.argv;
|
||||
const env = { ...process.env };
|
||||
const originalSignalListeners = {
|
||||
SIGINT: new Set(process.listeners("SIGINT")),
|
||||
SIGTERM: new Set(process.listeners("SIGTERM")),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mocks.requestHandler = undefined;
|
||||
mocks.fetchLatestNpmVersion.mockResolvedValue("1.14.8");
|
||||
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
mocks.fsExists.mockReturnValue(false);
|
||||
mocks.fsStat.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
||||
mocks.fsCreateReadStream.mockReturnValue({ once: mocks.streamOnce, pipe: mocks.pipe });
|
||||
mocks.fsReadFileSync.mockReturnValue("<html><head><script>bootstrap()</script></head></html>");
|
||||
mocks.readBoundedBody.mockResolvedValue(Buffer.from("{}"));
|
||||
mocks.serveStdio.mockImplementation((factory: () => unknown) => {
|
||||
factory();
|
||||
void mocks.connect();
|
||||
return { close: mocks.closeMcp };
|
||||
});
|
||||
process.env = { ...env };
|
||||
});
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
process.env = { ...env };
|
||||
vi.restoreAllMocks();
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
for (const listener of process.listeners(signal)) {
|
||||
if (!originalSignalListeners[signal].has(listener)) process.removeListener(signal, listener);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function response() {
|
||||
const res: any = {
|
||||
statusCode: 200, headersSent: false, body: "", closeHandler: undefined,
|
||||
writeHead: vi.fn((status: number) => { res.statusCode = status; res.headersSent = true; }),
|
||||
end: vi.fn((body = "") => { res.body = body; }),
|
||||
destroy: vi.fn(),
|
||||
on: vi.fn((name: string, handler: () => void) => { if (name === "close") res.closeHandler = handler; }),
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
async function importCli(args: string[]) {
|
||||
process.argv = [process.execPath, "index.js", ...args];
|
||||
const exit = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`EXIT:${code}`);
|
||||
}) as never);
|
||||
return { promise: import("../src/index.js"), exit };
|
||||
}
|
||||
|
||||
describe("stdio CLI entry point", () => {
|
||||
it("prints help and exits successfully", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const { promise, exit } = await importCli(["--help"]);
|
||||
await expect(promise).rejects.toThrow("EXIT:0");
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Usage:"));
|
||||
expect(exit).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("prints version and machine-readable doctor output", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
let loaded = await importCli(["--version"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:0");
|
||||
expect(log).toHaveBeenCalledWith(expect.stringMatching(/^\d+\.\d+\.\d+$/));
|
||||
vi.resetModules();
|
||||
log.mockClear();
|
||||
loaded = await importCli(["--doctor", "--json"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:0");
|
||||
expect(JSON.parse(String(log.mock.calls[0][0]))).toMatchObject({ schemaVersion: "premiere-pro-mcp.doctor.v1" });
|
||||
});
|
||||
|
||||
it("prints human doctor and privacy-safe support bundle output", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
let loaded = await importCli(["--doctor"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:0");
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Premiere MCP local check"));
|
||||
vi.resetModules();
|
||||
log.mockClear();
|
||||
loaded = await importCli(["--support-bundle"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:0");
|
||||
expect(JSON.parse(String(log.mock.calls[0][0]))).toMatchObject({
|
||||
schemaVersion: "premiere-pro-mcp.support-bundle.v1",
|
||||
});
|
||||
});
|
||||
|
||||
it("prints a no-write doctor repair plan and applies no writes without the closure confirmation", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
let loaded = await importCli(["--doctor", "--plan-fixes"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:0");
|
||||
expect(JSON.parse(String(log.mock.calls[0][0]))).toMatchObject({
|
||||
schemaVersion: "premiere-pro-mcp.doctor-repair-plan.v1",
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
log.mockClear();
|
||||
loaded = await importCli(["--doctor", "--apply-fixes"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:0");
|
||||
expect(JSON.parse(String(log.mock.calls[0][0]))).toMatchObject({
|
||||
schemaVersion: "premiere-pro-mcp.doctor-repair-result.v1",
|
||||
});
|
||||
expect(JSON.parse(String(log.mock.calls[0][0])).applied).toBe(false);
|
||||
});
|
||||
|
||||
it("checks for a newer release and updates a global npm installation", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
mocks.fetchLatestNpmVersion.mockResolvedValueOnce("1.15.0");
|
||||
let loaded = await importCli(["--check-update"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:0");
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("1.14.9 → 1.15.0"));
|
||||
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
log.mockClear();
|
||||
mocks.fetchLatestNpmVersion.mockResolvedValueOnce("1.15.0");
|
||||
mocks.spawnSync.mockReturnValue({ status: 0, stdout: `${dirname(process.cwd())}\n` });
|
||||
loaded = await importCli(["--update"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:0");
|
||||
expect(mocks.execFileSync).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.arrayContaining(["install", "--global", "premiere-pro-mcp@latest"]),
|
||||
expect.objectContaining({ stdio: "inherit" }),
|
||||
);
|
||||
expect(mocks.execFileSync).toHaveBeenCalledWith(
|
||||
process.execPath,
|
||||
expect.arrayContaining(["--install-cep"]),
|
||||
expect.objectContaining({ cwd: process.cwd() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects conflicting update actions and leaves a current installation untouched", async () => {
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
let loaded = await importCli(["--check-update", "--update"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:1");
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("only one update action"));
|
||||
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
mocks.fetchLatestNpmVersion.mockResolvedValueOnce("1.14.9");
|
||||
loaded = await importCli(["--update"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:0");
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("is current"));
|
||||
expect(mocks.execFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects CEP installation on unsupported platforms", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const { promise, exit } = await importCli(["--install-cep"]);
|
||||
await expect(promise).rejects.toThrow("EXIT:1");
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("supported only"));
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("starts the stdio server with configured bridge options", async () => {
|
||||
process.argv = [process.execPath, "index.js"];
|
||||
process.env.PREMIERE_TEMP_DIR = "C:\\custom-temp";
|
||||
process.env.PREMIERE_TIMEOUT_MS = "4321";
|
||||
delete process.env.PREMIERE_UXP_TOKEN;
|
||||
await import("../src/index.js");
|
||||
await vi.waitFor(() => expect(mocks.connect).toHaveBeenCalledOnce());
|
||||
expect(mocks.cleanup).toHaveBeenCalledWith({ tempDir: "C:\\custom-temp", timeoutMs: 4321 });
|
||||
expect(process.env.PREMIERE_MCP_TRANSPORT).toBe("stdio");
|
||||
});
|
||||
|
||||
it("offers an explicit legacy stdio fallback for clients that cannot negotiate server/discover", async () => {
|
||||
process.argv = [process.execPath, "index.js"];
|
||||
process.env.PREMIERE_MCP_PROTOCOL_MODE = "legacy";
|
||||
|
||||
await import("../src/index.js");
|
||||
|
||||
await vi.waitFor(() => expect(mocks.connect).toHaveBeenCalledOnce());
|
||||
expect(mocks.stdioServerTransport).toHaveBeenCalledOnce();
|
||||
expect(mocks.serveStdio).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unknown MCP protocol mode before opening stdio", async () => {
|
||||
process.argv = [process.execPath, "index.js"];
|
||||
process.env.PREMIERE_MCP_PROTOCOL_MODE = "unsupported";
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
|
||||
await import("../src/index.js");
|
||||
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
|
||||
expect(mocks.serveStdio).not.toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
"[premiere-pro-mcp] Fatal error:",
|
||||
expect.objectContaining({ message: "PREMIERE_MCP_PROTOCOL_MODE must be either auto or legacy." }),
|
||||
);
|
||||
});
|
||||
|
||||
it("starts the authenticated UXP bridge and emits debug readiness details", async () => {
|
||||
process.argv = [process.execPath, "index.js"];
|
||||
process.env.PREMIERE_UXP_TOKEN = "a-secure-token-with-length";
|
||||
process.env.PREMIERE_UXP_PORT = "7788";
|
||||
process.env.PREMIERE_MCP_DEBUG = "true";
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
await import("../src/index.js");
|
||||
await vi.waitFor(() => expect(mocks.uxpStart).toHaveBeenCalledOnce());
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("UXP bridge listening"));
|
||||
});
|
||||
|
||||
it("continues with CEP-only tools when another MCP instance owns the UXP loopback port", async () => {
|
||||
process.argv = [process.execPath, "index.js"];
|
||||
process.env.PREMIERE_UXP_TOKEN = "a-secure-token-with-length";
|
||||
mocks.uxpStart.mockRejectedValueOnce(Object.assign(new Error("address already in use"), { code: "EADDRINUSE" }));
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await import("../src/index.js");
|
||||
|
||||
await vi.waitFor(() => expect(mocks.serveStdio).toHaveBeenCalledOnce());
|
||||
expect(mocks.connect).toHaveBeenCalledOnce();
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("continuing with CEP-only tools"));
|
||||
});
|
||||
|
||||
it("keeps non-port UXP startup failures fatal", async () => {
|
||||
process.argv = [process.execPath, "index.js"];
|
||||
process.env.PREMIERE_UXP_TOKEN = "a-secure-token-with-length";
|
||||
mocks.uxpStart.mockRejectedValueOnce(Object.assign(new Error("permission denied"), { code: "EACCES" }));
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
|
||||
await import("../src/index.js");
|
||||
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
|
||||
expect(mocks.serveStdio).not.toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
"[premiere-pro-mcp] Fatal error:",
|
||||
expect.objectContaining({ message: "permission denied" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a fatal stdio startup failure", async () => {
|
||||
process.argv = [process.execPath, "index.js"];
|
||||
mocks.serveStdio.mockImplementationOnce(() => { throw new Error("connect failed"); });
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
await import("../src/index.js");
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalledWith(
|
||||
"[premiere-pro-mcp] Fatal error:",
|
||||
expect.objectContaining({ message: "connect failed" }),
|
||||
));
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("runs and diagnoses the Windows CEP installer", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
let loaded = await importCli(["--install-cep"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:0");
|
||||
expect(mocks.execFileSync).toHaveBeenCalledWith(
|
||||
"powershell.exe",
|
||||
expect.arrayContaining(["-File"]),
|
||||
expect.objectContaining({ stdio: "inherit" }),
|
||||
);
|
||||
vi.resetModules();
|
||||
mocks.execFileSync.mockClear();
|
||||
log.mockClear();
|
||||
loaded = await importCli(["--diagnose-cep"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:0");
|
||||
expect(mocks.execFileSync.mock.calls[0][1]).toContain("-Diagnose");
|
||||
vi.resetModules();
|
||||
mocks.execFileSync.mockClear();
|
||||
loaded = await importCli(["--uninstall-cep"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:0");
|
||||
expect(mocks.execFileSync.mock.calls[0][1]).toEqual(expect.arrayContaining([
|
||||
expect.stringMatching(/uninstall-cep\.ps1$/),
|
||||
]));
|
||||
});
|
||||
|
||||
it("routes After Effects connector actions to the separate host target", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const loaded = await importCli(["--install-after-effects-cep"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:0");
|
||||
expect(mocks.execFileSync.mock.calls[0][1]).toEqual(expect.arrayContaining([
|
||||
"-ConnectorHost",
|
||||
"AfterEffects",
|
||||
]));
|
||||
});
|
||||
|
||||
it("runs the macOS CEP diagnostic script", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const loaded = await importCli(["--diagnose-cep"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:0");
|
||||
expect(mocks.execFileSync).toHaveBeenCalledWith(
|
||||
"bash",
|
||||
[expect.stringMatching(/install-cep\.sh$/), "--diagnose"],
|
||||
expect.objectContaining({ stdio: "inherit" }),
|
||||
);
|
||||
expect(loaded.exit).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("runs the macOS After Effects diagnostic script with an isolated host flag", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const loaded = await importCli(["--diagnose-after-effects-cep"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:0");
|
||||
expect(mocks.execFileSync).toHaveBeenCalledWith(
|
||||
"bash",
|
||||
[expect.stringMatching(/install-cep\.sh$/), "--diagnose", "--after-effects"],
|
||||
expect.objectContaining({ stdio: "inherit" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs the macOS CEP uninstaller and rejects conflicting CEP actions", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
let loaded = await importCli(["--uninstall-cep"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:0");
|
||||
expect(mocks.execFileSync).toHaveBeenCalledWith(
|
||||
"bash",
|
||||
[expect.stringMatching(/uninstall-cep\.sh$/), "--user"],
|
||||
expect.objectContaining({ stdio: "inherit" }),
|
||||
);
|
||||
vi.resetModules();
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
loaded = await importCli(["--install-cep", "--uninstall-cep"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:1");
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("only one CEP action"));
|
||||
});
|
||||
|
||||
it("reports a failed CEP installer command", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
mocks.execFileSync.mockImplementationOnce(() => { throw new Error("installer failed"); });
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const loaded = await importCli(["--install-cep"]);
|
||||
await expect(loaded.promise).rejects.toThrow("EXIT:1");
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("installation failed"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTP entry point", () => {
|
||||
async function loadHttp(auth = "strong-test-token") {
|
||||
process.env.MCP_AUTH_TOKEN = auth;
|
||||
delete process.env.ALLOW_UNAUTHENTICATED;
|
||||
process.env.NODE_ENV = "test";
|
||||
await import("../src/http-server.js");
|
||||
return mocks.requestHandler!;
|
||||
}
|
||||
|
||||
async function loadOAuth() {
|
||||
delete process.env.MCP_AUTH_TOKEN;
|
||||
delete process.env.ALLOW_UNAUTHENTICATED;
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.MCP_OAUTH_ISSUER = "https://identity.example.com";
|
||||
process.env.MCP_OAUTH_AUDIENCE = "https://premiere.example.com/mcp";
|
||||
process.env.MCP_OAUTH_JWKS_URI = "https://identity.example.com/.well-known/jwks.json";
|
||||
process.env.MCP_PUBLIC_URL = "https://premiere.example.com";
|
||||
process.env.MCP_OAUTH_REQUIRED_SCOPES = "premiere:mcp";
|
||||
process.env.MCP_OAUTH_ALLOWED_SUBJECTS = "user-1";
|
||||
await import("../src/http-server.js");
|
||||
return mocks.requestHandler!;
|
||||
}
|
||||
|
||||
it("serves health and rejects missing bearer credentials", async () => {
|
||||
const handler = await loadHttp();
|
||||
const health = response();
|
||||
await handler({ method: "GET", url: "/health", headers: {} }, health);
|
||||
expect(health.statusCode).toBe(200);
|
||||
expect(JSON.parse(health.body)).toMatchObject({ status: "ok" });
|
||||
|
||||
const denied = response();
|
||||
await handler({ method: "POST", url: "/mcp", headers: {} }, denied);
|
||||
expect(denied.statusCode).toBe(401);
|
||||
expect(mocks.capture).toHaveBeenCalledWith("mcp_connection_attempt", expect.objectContaining({ outcome: "unauthorized" }));
|
||||
});
|
||||
|
||||
it("rejects malformed and incorrect bearer credentials", async () => {
|
||||
const handler = await loadHttp();
|
||||
for (const authorization of ["Basic strong-test-token", "Bearer short", "Bearer xxxxxxxxxxxxxxxxx"]) {
|
||||
const denied = response();
|
||||
await handler({ method: "POST", url: "/mcp", headers: { authorization } }, denied);
|
||||
expect(denied.statusCode).toBe(401);
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes OAuth protected-resource metadata without authentication", async () => {
|
||||
const handler = await loadOAuth();
|
||||
const res = response();
|
||||
await handler({ method: "GET", url: "/.well-known/oauth-protected-resource/mcp", headers: {} }, res);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toMatchObject({
|
||||
resource: "https://premiere.example.com/mcp",
|
||||
authorization_servers: ["https://identity.example.com"],
|
||||
});
|
||||
expect(mocks.oauthAuthenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns discoverable OAuth challenges and separates invalid token from insufficient scope", async () => {
|
||||
let handler = await loadOAuth();
|
||||
mocks.oauthAuthenticate.mockResolvedValueOnce({ authenticated: false, error: "invalid_token" });
|
||||
const invalid = response();
|
||||
await handler({ method: "POST", url: "/mcp", headers: {} }, invalid);
|
||||
expect(invalid.statusCode).toBe(401);
|
||||
expect(invalid.writeHead).toHaveBeenCalledWith(401, expect.objectContaining({
|
||||
"WWW-Authenticate": expect.stringContaining("/.well-known/oauth-protected-resource/mcp"),
|
||||
"Cache-Control": "no-store",
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
handler = await loadOAuth();
|
||||
mocks.oauthAuthenticate.mockResolvedValueOnce({ authenticated: false, error: "insufficient_scope" });
|
||||
const insufficient = response();
|
||||
await handler({ method: "POST", url: "/mcp", headers: { authorization: "Bearer redacted" } }, insufficient);
|
||||
expect(insufficient.statusCode).toBe(403);
|
||||
expect(insufficient.writeHead).toHaveBeenCalledWith(403, expect.objectContaining({
|
||||
"WWW-Authenticate": expect.stringContaining('error="insufficient_scope"'),
|
||||
}));
|
||||
expect(mocks.handleRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rate-limits before OAuth verification work", async () => {
|
||||
process.env.MCP_RATE_LIMIT_PER_MINUTE = "1";
|
||||
process.env.MCP_RATE_LIMIT_BURST = "1";
|
||||
const handler = await loadOAuth();
|
||||
const request = { method: "POST", url: "/mcp", headers: {}, socket: { remoteAddress: "203.0.113.9" } };
|
||||
const first = response();
|
||||
await handler(request, first);
|
||||
const second = response();
|
||||
await handler(request, second);
|
||||
expect(mocks.oauthAuthenticate).toHaveBeenCalledOnce();
|
||||
expect(second.statusCode).toBe(429);
|
||||
});
|
||||
|
||||
it("allows an explicitly unauthenticated deployment", async () => {
|
||||
process.env.ALLOW_UNAUTHENTICATED = "1";
|
||||
delete process.env.MCP_AUTH_TOKEN;
|
||||
process.env.NODE_ENV = "test";
|
||||
await import("../src/http-server.js");
|
||||
const res = response();
|
||||
await mocks.requestHandler!({ method: "POST", url: "/mcp", headers: {} }, res);
|
||||
expect(mocks.handleRequest).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("refuses to start without authentication or an explicit override", async () => {
|
||||
delete process.env.ALLOW_UNAUTHENTICATED;
|
||||
delete process.env.MCP_AUTH_TOKEN;
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const exit = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`EXIT:${code}`);
|
||||
}) as never);
|
||||
await expect(import("../src/http-server.js")).rejects.toThrow("EXIT:1");
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("Refusing to start"), expect.stringContaining("MCP_AUTH_TOKEN"));
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("refuses an unauthenticated production HTTP deployment", async () => {
|
||||
process.env.ALLOW_UNAUTHENTICATED = "1";
|
||||
delete process.env.MCP_AUTH_TOKEN;
|
||||
process.env.NODE_ENV = "production";
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const exit = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`EXIT:${code}`);
|
||||
}) as never);
|
||||
await expect(import("../src/http-server.js")).rejects.toThrow("EXIT:1");
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("Refusing to start"), expect.stringContaining("MCP_AUTH_TOKEN"));
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("rejects non-exact MCP paths and unsupported methods before server construction", async () => {
|
||||
const handler = await loadHttp();
|
||||
const incorrectPath = response();
|
||||
await handler({ method: "POST", url: "/mcp-typo", headers: {} }, incorrectPath);
|
||||
expect(incorrectPath.statusCode).toBe(404);
|
||||
expect(mocks.connect).not.toHaveBeenCalled();
|
||||
|
||||
const wrongMethod = response();
|
||||
await handler({ method: "PUT", url: "/mcp?client=test", headers: {} }, wrongMethod);
|
||||
expect(wrongMethod.statusCode).toBe(405);
|
||||
expect(wrongMethod.writeHead).toHaveBeenCalledWith(405, expect.objectContaining({ Allow: "GET, POST, DELETE" }));
|
||||
expect(mocks.connect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects chunked over-limit and malformed POST bodies before transport construction", async () => {
|
||||
const handler = await loadHttp();
|
||||
mocks.readBoundedBody.mockRejectedValueOnce(new RequestBodyTooLargeError());
|
||||
const tooLarge = response();
|
||||
await handler({ method: "POST", url: "/mcp", headers: { authorization: "Bearer strong-test-token" } }, tooLarge);
|
||||
expect(tooLarge.statusCode).toBe(413);
|
||||
expect(tooLarge.writeHead).toHaveBeenCalledWith(413, expect.objectContaining({ Connection: "close" }));
|
||||
expect(mocks.connect).not.toHaveBeenCalled();
|
||||
|
||||
mocks.readBoundedBody.mockResolvedValueOnce(Buffer.from("not-json"));
|
||||
const malformed = response();
|
||||
await handler({ method: "POST", url: "/mcp", headers: { authorization: "Bearer strong-test-token" } }, malformed);
|
||||
expect(malformed.statusCode).toBe(400);
|
||||
expect(JSON.parse(malformed.body)).toMatchObject({ jsonrpc: "2.0", error: { code: -32700 }, id: null });
|
||||
expect(mocks.connect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles authorized MCP requests and closes request resources", async () => {
|
||||
const handler = await loadHttp();
|
||||
const res = response();
|
||||
const req = {
|
||||
method: "POST", url: "/mcp", headers: { authorization: "Bearer strong-test-token" },
|
||||
};
|
||||
await handler(req, res);
|
||||
expect(mocks.connect).toHaveBeenCalledOnce();
|
||||
expect(mocks.handleRequest).toHaveBeenCalledOnce();
|
||||
expect(mocks.capture).toHaveBeenCalledWith("mcp_request", expect.objectContaining({ outcome: "succeeded", status_code: 204 }));
|
||||
res.closeHandler();
|
||||
expect(mocks.closeTransport).toHaveBeenCalled();
|
||||
expect(mocks.closeMcp).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds DELETE request bodies before the MCP transport handles them", async () => {
|
||||
const handler = await loadHttp();
|
||||
const res = response();
|
||||
await handler({ method: "DELETE", url: "/mcp", headers: { authorization: "Bearer strong-test-token" } }, res);
|
||||
expect(mocks.readBoundedBody).toHaveBeenCalledOnce();
|
||||
expect(mocks.handleRequest).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns 500 when MCP request handling fails", async () => {
|
||||
const handler = await loadHttp();
|
||||
mocks.handleRequest.mockRejectedValueOnce(new TypeError("broken"));
|
||||
const res = response();
|
||||
await handler({ method: "POST", url: "/mcp", headers: { authorization: "Bearer strong-test-token" } }, res);
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(JSON.parse(res.body)).toEqual({ error: "Internal server error" });
|
||||
expect(mocks.capture).toHaveBeenCalledWith("mcp_request", expect.objectContaining({ outcome: "failed", error_type: "TypeError" }));
|
||||
});
|
||||
|
||||
it("records an MCP response status failure and preserves an already-started response", async () => {
|
||||
let handler = await loadHttp();
|
||||
mocks.handleRequest.mockImplementationOnce(async (_req, res) => { res.statusCode = 422; });
|
||||
const failedStatus = response();
|
||||
await handler({ method: "POST", url: "/mcp", headers: { authorization: "Bearer strong-test-token" } }, failedStatus);
|
||||
expect(mocks.capture).toHaveBeenCalledWith("mcp_request", expect.objectContaining({
|
||||
outcome: "failed", method: "POST", status_code: 422,
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
handler = await loadHttp();
|
||||
mocks.handleRequest.mockRejectedValueOnce("non-error failure");
|
||||
const started = response();
|
||||
started.headersSent = true;
|
||||
await handler({ method: "POST", url: "/mcp", headers: { authorization: "Bearer strong-test-token" } }, started);
|
||||
expect(started.writeHead).not.toHaveBeenCalled();
|
||||
expect(mocks.capture).toHaveBeenCalledWith("mcp_request", expect.objectContaining({ error_type: "UnknownError" }));
|
||||
});
|
||||
|
||||
it("serves a landing asset with its MIME type", async () => {
|
||||
mocks.fsExists.mockReturnValue(true);
|
||||
const handler = await loadHttp();
|
||||
const res = response();
|
||||
await handler({ method: "GET", url: "/docs/", headers: {} }, res);
|
||||
expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-cache, must-revalidate",
|
||||
}));
|
||||
expect(res.body).toContain("<script nonce=");
|
||||
});
|
||||
|
||||
it("serves landing document headers without a body for HEAD", async () => {
|
||||
mocks.fsExists.mockReturnValue(true);
|
||||
const handler = await loadHttp();
|
||||
const res = response();
|
||||
await handler({ method: "HEAD", url: "/docs/", headers: {} }, res);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("");
|
||||
expect(mocks.fsReadFileSync).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("serves extensionless landing routes from their index file", async () => {
|
||||
mocks.fsExists.mockReturnValue(true);
|
||||
mocks.fsStat
|
||||
.mockReturnValueOnce({ isDirectory: () => true, isFile: () => false })
|
||||
.mockReturnValueOnce({ isDirectory: () => false, isFile: () => true });
|
||||
const handler = await loadHttp();
|
||||
const res = response();
|
||||
await handler({ method: "GET", url: "/changelog", headers: {} }, res);
|
||||
expect(mocks.fsReadFileSync).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/[\\/]changelog[\\/]index\.html$/),
|
||||
"utf8",
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("marks hashed Next static assets immutable", async () => {
|
||||
mocks.fsExists.mockReturnValue(true);
|
||||
const handler = await loadHttp();
|
||||
const res = response();
|
||||
await handler({ method: "GET", url: "/_next/static/chunks/app.js", headers: {} }, res);
|
||||
expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
}));
|
||||
});
|
||||
|
||||
it("serves nested static-export Flight payloads requested with Next's flattened path", async () => {
|
||||
mocks.fsExists.mockImplementation((candidate) => (
|
||||
String(candidate).endsWith("landing-dist") ||
|
||||
/[\\/]blog[\\/]guide[\\/]__next\.blog[\\/]\$d\$slug[\\/]__PAGE__\.txt$/.test(String(candidate))
|
||||
));
|
||||
const handler = await loadHttp();
|
||||
const res = response();
|
||||
await handler({ method: "GET", url: "/blog/guide/__next.blog.$d$slug.__PAGE__.txt?_rsc=test", headers: {} }, res);
|
||||
expect(mocks.fsCreateReadStream).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/[\\/]blog[\\/]guide[\\/]__next\.blog[\\/]\$d\$slug[\\/]__PAGE__\.txt$/),
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("does not crash when a landing asset read fails after validation", async () => {
|
||||
mocks.fsExists.mockReturnValue(true);
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const handler = await loadHttp();
|
||||
const res = response();
|
||||
await handler({ method: "GET", url: "/analytics.js", headers: {} }, res);
|
||||
const streamError = mocks.streamOnce.mock.calls.find(([event]) => event === "error")?.[1];
|
||||
expect(streamError).toBeTypeOf("function");
|
||||
streamError(new Error("read failed"));
|
||||
expect(res.destroy).toHaveBeenCalledOnce();
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
"[premiere-pro-mcp] Landing asset read failed:",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects landing paths that escape the static root", async () => {
|
||||
mocks.fsExists.mockReturnValue(true);
|
||||
const handler = await loadHttp();
|
||||
for (const url of ["/../outside.txt", "/%2e%2e/outside.txt", "/..\\outside.txt"]) {
|
||||
const res = response();
|
||||
await handler({ method: "GET", url, headers: {} }, res);
|
||||
expect(res.statusCode).toBe(404);
|
||||
}
|
||||
expect(mocks.fsCreateReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 when no landing asset matches", async () => {
|
||||
const handler = await loadHttp();
|
||||
const res = response();
|
||||
await handler({ method: "GET", url: "/missing", headers: {} }, res);
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toBe("Not found");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const inventory = JSON.parse(readFileSync("src/resources/extendscript-api-inventory.json", "utf8"));
|
||||
|
||||
function runFixture(markdown: string, referenceOverrides: Record<string, string> = {}) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "extendscript-api-inventory-"));
|
||||
const sourcePath = "docs/example/example.md";
|
||||
const fixturePath = join(directory, sourcePath);
|
||||
mkdirSync(join(directory, "docs/example"), { recursive: true });
|
||||
writeFileSync(fixturePath, markdown);
|
||||
const referencePath = join(directory, "reference.json");
|
||||
writeFileSync(referencePath, JSON.stringify({ entries: [{
|
||||
repository: "docsforadobe/premiere-scripting-guide",
|
||||
commit: "4253cea094e84d43590b77012b33bd1c140f72ea",
|
||||
scope: "premiere-extendscript-guide",
|
||||
path: sourcePath,
|
||||
...referenceOverrides,
|
||||
}] }));
|
||||
const outputPath = join(directory, "output.json");
|
||||
const result = spawnSync(process.execPath, ["scripts/generate-extendscript-api-inventory.mjs"], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
EXTENDSCRIPT_INVENTORY_FIXTURE_DIRECTORY: directory,
|
||||
EXTENDSCRIPT_REFERENCE_PATH: referencePath,
|
||||
EXTENDSCRIPT_INVENTORY_OUTPUT_PATH: outputPath,
|
||||
},
|
||||
});
|
||||
return { directory, outputPath, result };
|
||||
}
|
||||
|
||||
describe("Premiere ExtendScript API inventory", () => {
|
||||
it("pins a community source without inflating its authority", () => {
|
||||
expect(inventory.source).toMatchObject({
|
||||
repository: "docsforadobe/premiere-scripting-guide",
|
||||
commit: "4253cea094e84d43590b77012b33bd1c140f72ea",
|
||||
authority: "community",
|
||||
});
|
||||
expect(inventory.source.authorityNote).toContain("not Adobe API authority");
|
||||
});
|
||||
|
||||
it("accounts for unique object members and representative APIs", () => {
|
||||
expect(inventory.stats).toEqual({ total: 328, objects: 26, attributes: 108, methods: 220 });
|
||||
expect(inventory.stats.total).toBe(inventory.symbols.length);
|
||||
expect(inventory.stats.attributes + inventory.stats.methods).toBe(inventory.stats.total);
|
||||
expect(inventory.stats.objects).toBe(inventory.objects.length);
|
||||
expect(new Set(inventory.symbols.map((symbol: { object: string; kind: string; name: string }) =>
|
||||
`${symbol.object}:${symbol.kind}:${symbol.name}`)).size).toBe(inventory.symbols.length);
|
||||
expect(inventory.symbols).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ object: "Application", name: "app.project", kind: "attribute", signature: "app.project" }),
|
||||
expect.objectContaining({ object: "Application", name: "app.openDocument()", kind: "method" }),
|
||||
expect.objectContaining({ object: "Sequence", name: "Sequence.importMGT()", kind: "method" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("executes the generator against a fixture page", () => {
|
||||
const fixture = runFixture("# Example object\n\n## Attributes\n\n### Example.value\n\n`app.value`\n\n## Methods\n\n### Example.run()\n\n`app.run(arg)`\n");
|
||||
try {
|
||||
expect(fixture.result.status).toBe(0);
|
||||
expect(JSON.parse(readFileSync(fixture.outputPath, "utf8")).symbols).toEqual([
|
||||
expect.objectContaining({ name: "Example.value", kind: "attribute", signature: "app.value" }),
|
||||
expect.objectContaining({ name: "Example.run()", kind: "method", signature: "app.run(arg)" }),
|
||||
]);
|
||||
} finally {
|
||||
rmSync(fixture.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("parses table-defined collection members without treating parameter tables as APIs", () => {
|
||||
const fixture = runFixture("# Collection object\n\n## Attributes\n\n| Attribute | Type |\n| --- | --- |\n| `length` | Integer |\n\n## Methods\n\n| Method | Return Type |\n| --- | --- |\n| `[]` | Object |\n\n### Collection.find()\n\n`collection.find(index)`\n\n#### Parameters\n\n| Parameter | Type |\n| --- | --- |\n| `index` | Integer |\n");
|
||||
try {
|
||||
expect(fixture.result.status).toBe(0);
|
||||
expect(JSON.parse(readFileSync(fixture.outputPath, "utf8")).symbols).toEqual([
|
||||
expect.objectContaining({ name: "Collection.length", kind: "attribute", signature: "length" }),
|
||||
expect.objectContaining({ name: "Collection.[]", kind: "method", signature: "[]" }),
|
||||
expect.objectContaining({ name: "Collection.find()", kind: "method", signature: "collection.find(index)" }),
|
||||
]);
|
||||
expect(JSON.parse(readFileSync(fixture.outputPath, "utf8")).symbols)
|
||||
.not.toEqual(expect.arrayContaining([expect.objectContaining({ name: "Collection.index" })]));
|
||||
} finally {
|
||||
rmSync(fixture.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing signature", "# Example object\n\n## Methods\n\n### Example.run()\n\nNo signature\n", "Missing inline signature"],
|
||||
["duplicate member", "# Example object\n\n## Methods\n\n### Example.run()\n\n`app.run()`\n\n### Example.run()\n\n`app.run()`\n", "duplicate object members"],
|
||||
["no symbols", "# Introduction\n\nNo object members.\n", "No ExtendScript symbols"],
|
||||
])("fails closed for %s", (_label, markdown, expectedError) => {
|
||||
const fixture = runFixture(markdown);
|
||||
try {
|
||||
expect(fixture.result.status).not.toBe(0);
|
||||
expect(fixture.result.stderr).toContain(expectedError);
|
||||
} finally {
|
||||
rmSync(fixture.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["commit", { commit: "0".repeat(40) }],
|
||||
["scope", { scope: "wrong-scope" }],
|
||||
])("fails closed for mismatched reference %s", (_label, overrides) => {
|
||||
const fixture = runFixture("# Example object\n\n## Attributes\n\n### Example.value\n\n`app.value`\n", overrides);
|
||||
try {
|
||||
expect(fixture.result.status).not.toBe(0);
|
||||
expect(fixture.result.stderr).toContain("do not match the pinned commit and scope");
|
||||
} finally {
|
||||
rmSync(fixture.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
HttpAdmissionController,
|
||||
RequestBodyTooLargeError,
|
||||
exceedsRequestBodyLimit,
|
||||
getRequestPathname,
|
||||
isAuthorizedBearer,
|
||||
isSupportedMcpMethod,
|
||||
rateLimitIdentity,
|
||||
readBoundedRequestBody,
|
||||
readHttpAdmissionSettings,
|
||||
readHttpAuthConfiguration,
|
||||
requestContentLength,
|
||||
} from "../src/http-admission.js";
|
||||
|
||||
describe("HTTP admission settings", () => {
|
||||
it("uses bounded secure defaults and rejects invalid limits", () => {
|
||||
expect(readHttpAdmissionSettings({})).toMatchObject({
|
||||
maxRequestBytes: 1_048_576,
|
||||
maxConcurrentRequests: 8,
|
||||
maxConcurrentStreams: 32,
|
||||
rateLimitPerMinute: 120,
|
||||
rateLimitBurst: 30,
|
||||
});
|
||||
expect(() => readHttpAdmissionSettings({ MCP_MAX_REQUEST_BYTES: "unlimited" })).toThrow("MCP_MAX_REQUEST_BYTES");
|
||||
expect(() => readHttpAdmissionSettings({ MCP_RATE_LIMIT_PER_MINUTE: "10", MCP_RATE_LIMIT_BURST: "11" })).toThrow("MCP_RATE_LIMIT_BURST");
|
||||
});
|
||||
|
||||
it("never permits unauthenticated HTTP in production", () => {
|
||||
expect(readHttpAuthConfiguration({ MCP_AUTH_TOKEN: "secret", NODE_ENV: "production" })).toEqual({
|
||||
mode: "shared-token", authToken: "secret", allowUnauthenticated: false,
|
||||
});
|
||||
expect(readHttpAuthConfiguration({ ALLOW_UNAUTHENTICATED: "1", NODE_ENV: "test" })).toEqual({
|
||||
mode: "unauthenticated", allowUnauthenticated: true,
|
||||
});
|
||||
expect(() => readHttpAuthConfiguration({ ALLOW_UNAUTHENTICATED: "1", NODE_ENV: "production" })).toThrow("MCP_AUTH_TOKEN");
|
||||
});
|
||||
|
||||
it("requires complete HTTPS OAuth configuration and rejects ambiguous auth modes", () => {
|
||||
const oauth = readHttpAuthConfiguration({
|
||||
NODE_ENV: "production",
|
||||
MCP_OAUTH_ISSUER: "https://identity.example.com",
|
||||
MCP_OAUTH_AUDIENCE: "https://premiere.example.com/mcp",
|
||||
MCP_OAUTH_JWKS_URI: "https://identity.example.com/.well-known/jwks.json",
|
||||
MCP_PUBLIC_URL: "https://premiere.example.com",
|
||||
MCP_OAUTH_REQUIRED_SCOPES: "premiere:mcp premiere:read",
|
||||
MCP_OAUTH_ALLOWED_SUBJECTS: "user-1,user-2",
|
||||
});
|
||||
expect(oauth).toEqual({
|
||||
mode: "oauth",
|
||||
allowUnauthenticated: false,
|
||||
oauth: {
|
||||
issuer: "https://identity.example.com",
|
||||
audience: "https://premiere.example.com/mcp",
|
||||
jwksUri: "https://identity.example.com/.well-known/jwks.json",
|
||||
publicUrl: "https://premiere.example.com",
|
||||
requiredScopes: ["premiere:mcp", "premiere:read"],
|
||||
allowedSubjects: ["user-1", "user-2"],
|
||||
},
|
||||
});
|
||||
expect(() => readHttpAuthConfiguration({
|
||||
NODE_ENV: "production", MCP_OAUTH_ISSUER: "https://identity.example.com",
|
||||
})).toThrow("all required");
|
||||
expect(() => readHttpAuthConfiguration({
|
||||
NODE_ENV: "production",
|
||||
MCP_OAUTH_ISSUER: "http://identity.example.com",
|
||||
MCP_OAUTH_AUDIENCE: "https://premiere.example.com/mcp",
|
||||
MCP_OAUTH_JWKS_URI: "https://identity.example.com/jwks.json",
|
||||
MCP_PUBLIC_URL: "https://premiere.example.com",
|
||||
MCP_OAUTH_ALLOWED_SUBJECTS: "user-1",
|
||||
})).toThrow("HTTPS");
|
||||
expect(() => readHttpAuthConfiguration({
|
||||
NODE_ENV: "production",
|
||||
MCP_AUTH_TOKEN: "legacy",
|
||||
MCP_OAUTH_ISSUER: "https://identity.example.com",
|
||||
})).toThrow("either MCP_AUTH_TOKEN or MCP_OAUTH_ISSUER");
|
||||
expect(() => readHttpAuthConfiguration({
|
||||
NODE_ENV: "production",
|
||||
MCP_AUTH_TOKEN: "legacy",
|
||||
MCP_OAUTH_REQUIRED_SCOPES: "premiere:mcp",
|
||||
})).toThrow("either MCP_AUTH_TOKEN or MCP_OAUTH_ISSUER");
|
||||
expect(() => readHttpAuthConfiguration({
|
||||
NODE_ENV: "production",
|
||||
MCP_OAUTH_ISSUER: "https://identity.example.com",
|
||||
MCP_OAUTH_AUDIENCE: "https://other.example.com/mcp",
|
||||
MCP_OAUTH_JWKS_URI: "https://identity.example.com/jwks.json",
|
||||
MCP_PUBLIC_URL: "https://premiere.example.com",
|
||||
MCP_OAUTH_ALLOWED_SUBJECTS: "user-1",
|
||||
})).toThrow("exactly equal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTP request classification", () => {
|
||||
it("accepts only the exact MCP pathname and supported methods", () => {
|
||||
expect(getRequestPathname("/mcp?client=desktop")).toBe("/mcp");
|
||||
expect(getRequestPathname("/mcp-typo")).not.toBe("/mcp");
|
||||
expect(getRequestPathname("/mcp%2Fprivate")).not.toBe("/mcp");
|
||||
expect(getRequestPathname(undefined)).toBeUndefined();
|
||||
expect(getRequestPathname("http://[")).toBeUndefined();
|
||||
expect(isSupportedMcpMethod("POST")).toBe(true);
|
||||
expect(isSupportedMcpMethod("PUT")).toBe(false);
|
||||
expect(isSupportedMcpMethod(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid or over-limit declared body sizes before parsing", () => {
|
||||
expect(exceedsRequestBodyLimit({ headers: { "content-length": "1024" } } as never, 1024)).toBe(false);
|
||||
expect(exceedsRequestBodyLimit({ headers: { "content-length": "1025" } } as never, 1024)).toBe(true);
|
||||
expect(exceedsRequestBodyLimit({ headers: { "content-length": "unknown" } } as never, 1024)).toBe(true);
|
||||
expect(requestContentLength({ headers: {} } as never)).toBeUndefined();
|
||||
expect(requestContentLength({ headers: { "content-length": ["1024", "2048"] } } as never)).toBe(1024);
|
||||
expect(Number.isNaN(requestContentLength({ headers: { "content-length": "9007199254740992" } } as never))).toBe(true);
|
||||
});
|
||||
|
||||
it("bounds chunked bodies before transport parsing", async () => {
|
||||
const request = Object.assign(new EventEmitter(), {
|
||||
resume: vi.fn(),
|
||||
});
|
||||
const body = readBoundedRequestBody(request as never, 4);
|
||||
request.emit("data", Buffer.from("four"));
|
||||
request.emit("data", Buffer.from("!"));
|
||||
await expect(body).rejects.toBeInstanceOf(RequestBodyTooLargeError);
|
||||
expect(request.resume).toHaveBeenCalledOnce();
|
||||
|
||||
const complete = Object.assign(new EventEmitter(), { resume: vi.fn() });
|
||||
const completeBody = readBoundedRequestBody(complete as never, 4);
|
||||
complete.emit("data", Buffer.from("ok"));
|
||||
complete.emit("data", "!");
|
||||
complete.emit("end");
|
||||
await expect(completeBody).resolves.toEqual(Buffer.from("ok!"));
|
||||
});
|
||||
|
||||
it("compares bearer credentials without treating malformed headers as authorized", () => {
|
||||
const request = (authorization?: string) => ({ headers: { authorization } } as never);
|
||||
expect(isAuthorizedBearer(request("Bearer a-strong-token"), "a-strong-token")).toBe(true);
|
||||
expect(isAuthorizedBearer(request("Bearer wrong"), "a-strong-token")).toBe(false);
|
||||
expect(isAuthorizedBearer(request("Basic a-strong-token"), "a-strong-token")).toBe(false);
|
||||
expect(isAuthorizedBearer(request(), "a-strong-token")).toBe(false);
|
||||
expect(isAuthorizedBearer({ headers: { authorization: ["Bearer a-strong-token"] } } as never, "a-strong-token")).toBe(true);
|
||||
expect(isAuthorizedBearer(request(), undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not trust forwarded addresses unless configured", () => {
|
||||
const request = {
|
||||
headers: { "x-forwarded-for": "198.51.100.4, 10.0.0.1" },
|
||||
socket: { remoteAddress: "10.0.0.1" },
|
||||
} as never;
|
||||
expect(rateLimitIdentity(request, false)).not.toBe(rateLimitIdentity(request, true));
|
||||
expect(rateLimitIdentity({ headers: { "x-forwarded-for": ["203.0.113.8"] }, socket: { remoteAddress: "10.0.0.1" } } as never, true)).toMatch(/^ip:/);
|
||||
expect(rateLimitIdentity({ headers: {}, socket: { remoteAddress: "" } } as never, false)).toMatch(/^ip:/);
|
||||
expect(rateLimitIdentity({ headers: {}, socket: {} } as never, true)).toMatch(/^ip:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTP admission controller", () => {
|
||||
it("bounds concurrent requests and makes release idempotent", () => {
|
||||
const controller = new HttpAdmissionController({
|
||||
maxConcurrentRequests: 1, maxConcurrentStreams: 2, rateLimitPerMinute: 100, rateLimitBurst: 10, maxRateLimitKeys: 16,
|
||||
});
|
||||
const first = controller.acquire("credential:a");
|
||||
expect(first.accepted).toBe(true);
|
||||
const second = controller.acquire("credential:b");
|
||||
expect(second).toMatchObject({ accepted: false, reason: "at_capacity", statusCode: 503 });
|
||||
if (first.accepted) {
|
||||
first.release();
|
||||
first.release();
|
||||
}
|
||||
expect(controller.metrics().activeRequests).toBe(0);
|
||||
expect(controller.acquire("credential:b").accepted).toBe(true);
|
||||
});
|
||||
|
||||
it("enforces a bounded token bucket with Retry-After", () => {
|
||||
let now = 0;
|
||||
const controller = new HttpAdmissionController({
|
||||
maxConcurrentRequests: 5, maxConcurrentStreams: 5, rateLimitPerMinute: 60, rateLimitBurst: 2, maxRateLimitKeys: 16,
|
||||
}, () => now);
|
||||
const first = controller.acquire("credential:a");
|
||||
const second = controller.acquire("credential:a");
|
||||
if (first.accepted) first.release();
|
||||
if (second.accepted) second.release();
|
||||
expect(controller.acquire("credential:a")).toMatchObject({ accepted: false, statusCode: 429, retryAfterSeconds: 1 });
|
||||
now = 1_000;
|
||||
expect(controller.acquire("credential:a").accepted).toBe(true);
|
||||
});
|
||||
|
||||
it("bounds tracked identities and prunes stale buckets", () => {
|
||||
let now = 0;
|
||||
const controller = new HttpAdmissionController({
|
||||
maxConcurrentRequests: 2, maxConcurrentStreams: 2, rateLimitPerMinute: 60, rateLimitBurst: 1, maxRateLimitKeys: 1,
|
||||
}, () => now);
|
||||
const first = controller.acquire("credential:a");
|
||||
if (first.accepted) first.release();
|
||||
expect(controller.acquire("credential:b")).toMatchObject({ accepted: false, reason: "rate_limited" });
|
||||
now = 120_001;
|
||||
const second = controller.acquire("credential:b");
|
||||
expect(second.accepted).toBe(true);
|
||||
expect(controller.metrics().trackedRateLimitKeys).toBe(1);
|
||||
if (second.accepted) second.release();
|
||||
});
|
||||
|
||||
it("reserves SSE capacity without consuming operation capacity", () => {
|
||||
const controller = new HttpAdmissionController({
|
||||
maxConcurrentRequests: 1, maxConcurrentStreams: 2, rateLimitPerMinute: 100, rateLimitBurst: 10, maxRateLimitKeys: 16,
|
||||
});
|
||||
const operation = controller.acquire("credential:a", "operation");
|
||||
const firstStream = controller.acquire("credential:a", "stream");
|
||||
const secondStream = controller.acquire("credential:b", "stream");
|
||||
expect(operation.accepted).toBe(true);
|
||||
expect(firstStream.accepted).toBe(true);
|
||||
expect(secondStream.accepted).toBe(true);
|
||||
expect(controller.acquire("credential:b", "operation")).toMatchObject({ accepted: false, reason: "at_capacity" });
|
||||
expect(controller.acquire("credential:c", "stream")).toMatchObject({ accepted: false, reason: "at_capacity" });
|
||||
expect(controller.metrics()).toMatchObject({
|
||||
activeRequests: 3,
|
||||
activeOperationRequests: 1,
|
||||
activeStreamRequests: 2,
|
||||
});
|
||||
if (operation.accepted) operation.release();
|
||||
if (firstStream.accepted) firstStream.release();
|
||||
if (secondStream.accepted) secondStream.release();
|
||||
});
|
||||
});
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { applyHttpSecurityHeaders, buildContentSecurityPolicy, HTTP_SECURITY_HEADERS } from "../src/http-security.js";
|
||||
|
||||
describe("HTTP security headers", () => {
|
||||
it("sets a restrictive baseline on every response", () => {
|
||||
const setHeader = vi.fn();
|
||||
applyHttpSecurityHeaders({ setHeader } as never);
|
||||
|
||||
expect(setHeader).toHaveBeenCalledWith("X-Content-Type-Options", "nosniff");
|
||||
expect(setHeader).toHaveBeenCalledWith("X-Frame-Options", "DENY");
|
||||
expect(setHeader).toHaveBeenCalledWith(
|
||||
"Strict-Transport-Security",
|
||||
"max-age=31536000; includeSubDomains",
|
||||
);
|
||||
expect(setHeader).toHaveBeenCalledWith(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=()",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks framing, plugins, and unlisted network destinations", () => {
|
||||
const policy = HTTP_SECURITY_HEADERS["Content-Security-Policy"];
|
||||
expect(policy).toContain("frame-ancestors 'none'");
|
||||
expect(policy).toContain("object-src 'none'");
|
||||
expect(policy).toContain("connect-src 'self'");
|
||||
expect(policy).toContain("https://www.google.com");
|
||||
expect(policy).not.toContain("connect-src *");
|
||||
expect(policy).not.toContain("script-src 'self' 'unsafe-inline'");
|
||||
});
|
||||
|
||||
it("uses a per-response nonce instead of allowing inline scripts", () => {
|
||||
const setHeader = vi.fn();
|
||||
applyHttpSecurityHeaders({ setHeader } as never, { scriptNonce: "nonce-value" });
|
||||
expect(setHeader).toHaveBeenCalledWith(
|
||||
"Content-Security-Policy",
|
||||
expect.stringContaining("script-src 'self' 'nonce-nonce-value' https://www.googletagmanager.com"),
|
||||
);
|
||||
expect(buildContentSecurityPolicy()).not.toContain("'unsafe-inline' https://www.googletagmanager.com");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
REQUIRED_TARGETS,
|
||||
verifyHybridBenchmarkEvidence,
|
||||
verifyHybridBenchmarkEvidenceWithLocalCcx,
|
||||
} from "../scripts/verify-uxp-hybrid-benchmark.mjs";
|
||||
import { NATIVE_SDK_HEADER_INVENTORY_SEMANTICS } from "../scripts/native-sdk-header-inventory-contract.mjs";
|
||||
import { canonicalNativeSdkHeaderInventorySha256 } from "../scripts/verify-native-sdk-header-inventory.mjs";
|
||||
import {
|
||||
UXP_HYBRID_ADDON_RECEIPT_SEMANTICS,
|
||||
} from "../scripts/uxp-hybrid-addon-receipt-contract.mjs";
|
||||
import {
|
||||
canonicalUxpHybridAddonReceiptSha256,
|
||||
} from "../scripts/verify-uxp-hybrid-addon-receipt.mjs";
|
||||
import {
|
||||
UXP_HYBRID_CCX_RECEIPT_LEGACY_SEMANTICS,
|
||||
} from "../scripts/uxp-hybrid-ccx-receipt-contract.mjs";
|
||||
import {
|
||||
canonicalUxpHybridCcxReceiptSha256,
|
||||
} from "../scripts/uxp-hybrid-ccx-receipt-core.mjs";
|
||||
|
||||
const SHA = "a".repeat(40);
|
||||
const ADDON_SHA = "b".repeat(64);
|
||||
const ADDON_SHA_BY_TARGET = Object.freeze({
|
||||
"mac-x64": "b".repeat(64),
|
||||
"mac-arm64": "c".repeat(64),
|
||||
"win-x64": "d".repeat(64),
|
||||
});
|
||||
|
||||
function sdkHeaderReceipt(sdkVersion = "UXP Hybrid SDK test fixture") {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: {
|
||||
sdk: "uxp-hybrid",
|
||||
sdkVersion,
|
||||
authorityUrl: "https://developer.adobe.com/premiere-pro/uxp/plugins/hybrid-plugins/",
|
||||
archiveSha256: "c".repeat(64),
|
||||
inventoryScope: "header_files_only",
|
||||
includeDirectories: ["src/api", "src/utilities"],
|
||||
},
|
||||
semantics: NATIVE_SDK_HEADER_INVENTORY_SEMANTICS,
|
||||
stats: { headers: 3, bytes: 24 },
|
||||
headers: [
|
||||
{ path: "src/api/UxpAddonShared.h", bytes: 7, sha256: "d".repeat(64) },
|
||||
{ path: "src/api/UxpAddonTypes.h", bytes: 8, sha256: "e".repeat(64) },
|
||||
{ path: "src/utilities/UxpAddon.h", bytes: 9, sha256: "f".repeat(64) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function prsdkHeaderReceipt() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: {
|
||||
sdk: "premiere-prsdk",
|
||||
sdkVersion: "UXP Hybrid SDK test fixture",
|
||||
authorityUrl: "https://developer.adobe.com/premiere-pro/",
|
||||
archiveSha256: "c".repeat(64),
|
||||
inventoryScope: "header_files_only",
|
||||
includeDirectories: ["Headers"],
|
||||
},
|
||||
semantics: NATIVE_SDK_HEADER_INVENTORY_SEMANTICS,
|
||||
stats: { headers: 1, bytes: 7 },
|
||||
headers: [
|
||||
{ path: "Headers/PrSDKFixture.h", bytes: 7, sha256: "d".repeat(64) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function evidence(headerReceipt = sdkHeaderReceipt()) {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
workloadId: "weighted-energy-v1",
|
||||
configuration: { sampleCount: 30, warmupCount: 3, iterations: 4, inputLength: 131072, seed: 1337 },
|
||||
memoryMeasurement: "fixture process monitor",
|
||||
sdkHeaderReceiptSha256: canonicalNativeSdkHeaderInventorySha256(headerReceipt),
|
||||
runs: REQUIRED_TARGETS.map((target) => {
|
||||
const [platform, arch] = target.split("-");
|
||||
return {
|
||||
platform,
|
||||
arch,
|
||||
hostVersion: "26.3.0",
|
||||
sdkVersion: "UXP Hybrid SDK test fixture",
|
||||
buildMode: "Release",
|
||||
addonLoaded: true,
|
||||
addonSha256: ADDON_SHA,
|
||||
sourceCommit: SHA,
|
||||
checksumMatch: true,
|
||||
codeSigned: platform === "mac",
|
||||
notarized: platform === "mac",
|
||||
javascript: { sampleCount: 30, p50Ms: 100, p95Ms: 140, peakWorkingSetBytes: 1000 },
|
||||
native: { sampleCount: 30, p50Ms: 60, p95Ms: 80, peakWorkingSetBytes: 1050 },
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function legacyEvidence() {
|
||||
const { sdkHeaderReceiptSha256, ...v1 } = evidence();
|
||||
return { ...v1, schemaVersion: 1 };
|
||||
}
|
||||
|
||||
function addonReceipt(headerReceipt = sdkHeaderReceipt()) {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
source: {
|
||||
sdk: "uxp-hybrid",
|
||||
sdkVersion: headerReceipt.source.sdkVersion,
|
||||
sdkHeaderReceiptSha256: canonicalNativeSdkHeaderInventorySha256(headerReceipt),
|
||||
authorityUrl: "https://developer.adobe.com/premiere-pro/uxp/plugins/hybrid-plugins/build",
|
||||
},
|
||||
manifest: {
|
||||
manifestVersion: 6,
|
||||
hostApp: "premierepro",
|
||||
hostMinVersion: "26.2.0",
|
||||
addonName: "fixture-addon.uxpaddon",
|
||||
enableAddon: true,
|
||||
},
|
||||
entrypoint: { path: "main.js", bytes: 3, sha256: "e".repeat(64) },
|
||||
semantics: UXP_HYBRID_ADDON_RECEIPT_SEMANTICS,
|
||||
stats: { artifacts: 3, addonBytes: 12, entrypoints: 1, entrypointBytes: 3 },
|
||||
artifacts: [
|
||||
{ target: "mac-x64", path: "mac/x64/fixture-addon.uxpaddon", bytes: 3, sha256: ADDON_SHA_BY_TARGET["mac-x64"] },
|
||||
{ target: "mac-arm64", path: "mac/arm64/fixture-addon.uxpaddon", bytes: 4, sha256: ADDON_SHA_BY_TARGET["mac-arm64"] },
|
||||
{ target: "win-x64", path: "win/x64/fixture-addon.uxpaddon", bytes: 5, sha256: ADDON_SHA_BY_TARGET["win-x64"] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function ccxReceipt(headerReceipt = sdkHeaderReceipt(), addon = addonReceipt(headerReceipt)) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: {
|
||||
sdk: "uxp-hybrid",
|
||||
sdkVersion: headerReceipt.source.sdkVersion,
|
||||
sdkHeaderReceiptSha256: canonicalNativeSdkHeaderInventorySha256(headerReceipt),
|
||||
addonReceiptSha256: canonicalUxpHybridAddonReceiptSha256(addon),
|
||||
authorityUrl: "https://developer.adobe.com/premiere-pro/uxp/plugins/distribution/package/",
|
||||
},
|
||||
archive: { format: "zip", bytes: 64, sha256: "f".repeat(64) },
|
||||
manifest: {
|
||||
bytes: 32,
|
||||
sha256: "1".repeat(64),
|
||||
idSha256: "2".repeat(64),
|
||||
idLength: 7,
|
||||
manifestVersion: 6,
|
||||
hostApp: "premierepro",
|
||||
hostMinVersion: "26.2.0",
|
||||
addonName: "fixture-addon.uxpaddon",
|
||||
enableAddon: true,
|
||||
},
|
||||
semantics: UXP_HYBRID_CCX_RECEIPT_LEGACY_SEMANTICS,
|
||||
stats: { artifacts: 3, addonBytes: 12, entrypoints: 1, entrypointBytes: 3 },
|
||||
};
|
||||
}
|
||||
|
||||
function packageEvidence(headerReceipt = sdkHeaderReceipt(), addon = addonReceipt(headerReceipt), ccx = ccxReceipt(headerReceipt, addon)) {
|
||||
return {
|
||||
...evidence(headerReceipt),
|
||||
schemaVersion: 3,
|
||||
addonReceiptSha256: canonicalUxpHybridAddonReceiptSha256(addon),
|
||||
ccxReceiptSha256: canonicalUxpHybridCcxReceiptSha256(ccx),
|
||||
runs: REQUIRED_TARGETS.map((target) => {
|
||||
const [platform, arch] = target.split("-");
|
||||
return {
|
||||
platform,
|
||||
arch,
|
||||
hostVersion: "26.3.0",
|
||||
sdkVersion: headerReceipt.source.sdkVersion,
|
||||
buildMode: "Release",
|
||||
addonLoaded: true,
|
||||
addonSha256: ADDON_SHA_BY_TARGET[target],
|
||||
sourceCommit: SHA,
|
||||
checksumMatch: true,
|
||||
codeSigned: platform === "mac",
|
||||
notarized: platform === "mac",
|
||||
javascript: { sampleCount: 30, p50Ms: 100, p95Ms: 140, peakWorkingSetBytes: 1000 },
|
||||
native: { sampleCount: 30, p50Ms: 60, p95Ms: 80, peakWorkingSetBytes: 1050 },
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("UXP hybrid benchmark evidence verifier", () => {
|
||||
it("keeps frozen v1/v2 schemas and verifier paths available while making package binding v3", () => {
|
||||
const v1Schema = JSON.parse(readFileSync("benchmarks/uxp-hybrid/evidence.v1.schema.json", "utf8"));
|
||||
const v2Schema = JSON.parse(readFileSync("benchmarks/uxp-hybrid/evidence.v2.schema.json", "utf8"));
|
||||
const v3Schema = JSON.parse(readFileSync("benchmarks/uxp-hybrid/evidence.schema.json", "utf8"));
|
||||
expect(v1Schema.$id).toBe("https://premiere-pro-mcp.com/schemas/uxp-hybrid-benchmark-evidence-v1.json");
|
||||
expect(v1Schema.properties.schemaVersion.const).toBe(1);
|
||||
expect(v1Schema.required).not.toContain("sdkHeaderReceiptSha256");
|
||||
expect(v2Schema.$id).toBe("https://premiere-pro-mcp.com/schemas/uxp-hybrid-benchmark-evidence-v2.json");
|
||||
expect(v2Schema.properties.schemaVersion.const).toBe(2);
|
||||
expect(v2Schema.required).toContain("sdkHeaderReceiptSha256");
|
||||
expect(v3Schema.$id).toBe("https://premiere-pro-mcp.com/schemas/uxp-hybrid-benchmark-evidence-v3.json");
|
||||
expect(v3Schema.properties.schemaVersion.const).toBe(3);
|
||||
expect(v3Schema.required).toEqual(expect.arrayContaining(["sdkHeaderReceiptSha256", "addonReceiptSha256", "ccxReceiptSha256"]));
|
||||
expect(verifyHybridBenchmarkEvidence(legacyEvidence())).toEqual({
|
||||
promotionEligible: true,
|
||||
errors: [],
|
||||
targets: ["mac-arm64", "mac-x64", "win-x64"],
|
||||
thresholds: { minimumSpeedupPercent: 30, maximumMemoryRegressionPercent: 10 },
|
||||
verificationBoundary: "submitted_cross_platform_release_build_evidence",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires all release targets, matching output, speed, memory, signing, and one commit", () => {
|
||||
const receipt = sdkHeaderReceipt();
|
||||
expect(verifyHybridBenchmarkEvidence(evidence(receipt), { sdkHeaderReceipt: receipt })).toEqual({
|
||||
promotionEligible: true,
|
||||
errors: [],
|
||||
targets: ["mac-arm64", "mac-x64", "win-x64"],
|
||||
thresholds: { minimumSpeedupPercent: 30, maximumMemoryRegressionPercent: 10 },
|
||||
verificationBoundary: "submitted_cross_platform_release_build_and_verified_sdk_receipt_evidence",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a target is missing or a percentile misses the threshold", () => {
|
||||
const receipt = sdkHeaderReceipt();
|
||||
const value = evidence(receipt);
|
||||
value.runs.pop();
|
||||
value.runs[0].native.p95Ms = 110;
|
||||
const result = verifyHybridBenchmarkEvidence(value, { sdkHeaderReceipt: receipt });
|
||||
expect(result.promotionEligible).toBe(false);
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining("p95 speedup"),
|
||||
expect.stringContaining("Missing required mac-arm64 evidence"),
|
||||
]));
|
||||
});
|
||||
|
||||
it("requires a structurally verified matching Hybrid receipt and rejects undocumented fields", () => {
|
||||
const receipt = sdkHeaderReceipt();
|
||||
const value = evidence(receipt);
|
||||
expect(verifyHybridBenchmarkEvidence(value).errors).toContain("A verified UXP Hybrid SDK header receipt is required.");
|
||||
|
||||
const mismatchedDigest = { ...value, sdkHeaderReceiptSha256: "0".repeat(64) };
|
||||
expect(verifyHybridBenchmarkEvidence(mismatchedDigest, { sdkHeaderReceipt: receipt }).errors)
|
||||
.toContain("sdkHeaderReceiptSha256 does not match the verified SDK header receipt.");
|
||||
|
||||
const mismatchedVersionReceipt = sdkHeaderReceipt("UXP Hybrid SDK other fixture");
|
||||
expect(verifyHybridBenchmarkEvidence(value, { sdkHeaderReceipt: mismatchedVersionReceipt }).errors)
|
||||
.toEqual(expect.arrayContaining([
|
||||
"SDK header receipt sdkVersion must match all benchmark runs.",
|
||||
"sdkHeaderReceiptSha256 does not match the verified SDK header receipt.",
|
||||
]));
|
||||
|
||||
const wrongSdkReceipt = prsdkHeaderReceipt();
|
||||
expect(verifyHybridBenchmarkEvidence(value, { sdkHeaderReceipt: wrongSdkReceipt }).errors)
|
||||
.toEqual(expect.arrayContaining([
|
||||
"SDK header receipt must identify uxp-hybrid.",
|
||||
"sdkHeaderReceiptSha256 does not match the verified SDK header receipt.",
|
||||
]));
|
||||
|
||||
const withExtraField = { ...value, privateSdkPath: "C:Headers" };
|
||||
expect(verifyHybridBenchmarkEvidence(withExtraField, { sdkHeaderReceipt: receipt }).errors)
|
||||
.toContain("Evidence must contain only the documented benchmark receipt fields.");
|
||||
|
||||
const withExtraRunField = {
|
||||
...value,
|
||||
runs: [{ ...value.runs[0], localAddonPath: "C:Headers" }, ...value.runs.slice(1)],
|
||||
};
|
||||
expect(verifyHybridBenchmarkEvidence(withExtraRunField, { sdkHeaderReceipt: receipt }).errors)
|
||||
.toContain("runs[0] must contain only the documented benchmark fields.");
|
||||
|
||||
const withExtraMetricField = {
|
||||
...value,
|
||||
runs: [{ ...value.runs[0], native: { ...value.runs[0].native, rawSamples: [60] } }, ...value.runs.slice(1)],
|
||||
};
|
||||
expect(verifyHybridBenchmarkEvidence(withExtraMetricField, { sdkHeaderReceipt: receipt }).errors)
|
||||
.toContain("runs[0].native must contain only the documented metric fields.");
|
||||
});
|
||||
|
||||
it("binds each target run to a verified current addon-layout and CCX receipt chain", async () => {
|
||||
const headerReceipt = sdkHeaderReceipt();
|
||||
const addon = addonReceipt(headerReceipt);
|
||||
const ccx = ccxReceipt(headerReceipt, addon);
|
||||
const value = packageEvidence(headerReceipt, addon, ccx);
|
||||
expect(verifyHybridBenchmarkEvidence(value, {
|
||||
sdkHeaderReceipt: headerReceipt,
|
||||
addonReceipt: addon,
|
||||
ccxReceipt: ccx,
|
||||
})).toEqual({
|
||||
promotionEligible: false,
|
||||
errors: ["A local CCX archive must be rechecked for schemaVersion 3."],
|
||||
targets: ["mac-arm64", "mac-x64", "win-x64"],
|
||||
thresholds: { minimumSpeedupPercent: 30, maximumMemoryRegressionPercent: 10 },
|
||||
verificationBoundary: "submitted_cross_platform_release_build_and_verified_sdk_addon_ccx_receipt_evidence",
|
||||
});
|
||||
|
||||
const mismatchedArtifact = packageEvidence(headerReceipt, addon, ccx);
|
||||
mismatchedArtifact.runs[0].addonSha256 = "0".repeat(64);
|
||||
expect(verifyHybridBenchmarkEvidence(mismatchedArtifact, {
|
||||
sdkHeaderReceipt: headerReceipt,
|
||||
addonReceipt: addon,
|
||||
ccxReceipt: ccx,
|
||||
}).errors).toContain("runs for win-x64 must match the corresponding addon-layout artifact SHA-256.");
|
||||
|
||||
const withoutArchive = await verifyHybridBenchmarkEvidenceWithLocalCcx(value, {
|
||||
sdkHeaderReceipt: headerReceipt,
|
||||
addonReceipt: addon,
|
||||
ccxReceipt: ccx,
|
||||
});
|
||||
expect(withoutArchive).toMatchObject({
|
||||
promotionEligible: false,
|
||||
errors: ["A local CCX archive path is required for schemaVersion 3."],
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a local receipt path for v2 without printing it while retaining v1 CLI compatibility", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-hybrid-benchmark-"));
|
||||
const input = join(directory, "evidence.json");
|
||||
const receiptPath = join(directory, "receipt.json");
|
||||
const receipt = sdkHeaderReceipt();
|
||||
try {
|
||||
writeFileSync(input, `${JSON.stringify(evidence(receipt), null, 2)}\n`);
|
||||
writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
||||
const missingReceipt = spawnSync(process.execPath, ["scripts/verify-uxp-hybrid-benchmark.mjs", "--input", input], { encoding: "utf8" });
|
||||
expect(missingReceipt.status).not.toBe(0);
|
||||
expect(missingReceipt.stdout).toContain("A verified UXP Hybrid SDK header receipt is required.");
|
||||
|
||||
writeFileSync(input, `${JSON.stringify(legacyEvidence(), null, 2)}\n`);
|
||||
const legacy = spawnSync(process.execPath, ["scripts/verify-uxp-hybrid-benchmark.mjs", "--input", input], { encoding: "utf8" });
|
||||
expect(legacy.status).toBe(0);
|
||||
expect(legacy.stdout).toContain('"verificationBoundary": "submitted_cross_platform_release_build_evidence"');
|
||||
|
||||
writeFileSync(input, `${JSON.stringify(evidence(receipt), null, 2)}\n`);
|
||||
|
||||
const verified = spawnSync(process.execPath, ["scripts/verify-uxp-hybrid-benchmark.mjs", "--input", input, "--sdk-header-receipt", receiptPath], { encoding: "utf8" });
|
||||
expect(verified.status).toBe(0);
|
||||
expect(verified.stdout).toContain('"promotionEligible": true');
|
||||
expect(verified.stdout).not.toContain("UxpAddonShared.h");
|
||||
|
||||
const unreadableReceipt = join(directory, "do-not-disclose-this-receipt.json");
|
||||
const unreadable = spawnSync(process.execPath, ["scripts/verify-uxp-hybrid-benchmark.mjs", "--input", input, "--sdk-header-receipt", unreadableReceipt], { encoding: "utf8" });
|
||||
expect(unreadable.status).not.toBe(0);
|
||||
expect(unreadable.stderr).toContain("SDK header receipt must be a readable JSON document.");
|
||||
expect(unreadable.stderr).not.toContain(unreadableReceipt);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "path";
|
||||
|
||||
const BIN = join(process.cwd(), "dist", "index.js");
|
||||
|
||||
describe("CLI flags", () => {
|
||||
it("--help prints usage and exits 0", () => {
|
||||
const output = execFileSync(process.execPath, [BIN, "--help"], { encoding: "utf-8" });
|
||||
expect(output).toContain("premiere-pro-mcp");
|
||||
expect(output).toContain("Usage:");
|
||||
expect(output).toContain("--install-cep");
|
||||
expect(output).toContain("--uninstall-cep");
|
||||
expect(output).toContain("--diagnose-cep");
|
||||
expect(output).toContain("--install-after-effects-cep");
|
||||
expect(output).toContain("--uninstall-after-effects-cep");
|
||||
expect(output).toContain("--diagnose-after-effects-cep");
|
||||
expect(output).toContain("PREMIERE_TEMP_DIR");
|
||||
expect(output).toContain("PREMIERE_TIMEOUT_MS");
|
||||
expect(output).toContain("PREMIERE_MCP_CAPABILITIES");
|
||||
});
|
||||
|
||||
it("-h is an alias for --help", () => {
|
||||
const output = execFileSync(process.execPath, [BIN, "-h"], { encoding: "utf-8" });
|
||||
expect(output).toContain("Usage:");
|
||||
});
|
||||
|
||||
it("--version prints a semver version and exits 0", () => {
|
||||
const output = execFileSync(process.execPath, [BIN, "--version"], { encoding: "utf-8" }).trim();
|
||||
expect(output).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
it("-v is an alias for --version", () => {
|
||||
const output = execFileSync(process.execPath, [BIN, "-v"], { encoding: "utf-8" }).trim();
|
||||
expect(output).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
it("--version matches package.json version", () => {
|
||||
const version = execFileSync(process.execPath, [BIN, "--version"], { encoding: "utf-8" }).trim();
|
||||
const pkg = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf-8"));
|
||||
expect(version).toBe(pkg.version);
|
||||
});
|
||||
|
||||
it("--doctor --json emits a privacy-safe machine-readable readiness report", () => {
|
||||
const output = execFileSync(process.execPath, [BIN, "--doctor", "--json"], {
|
||||
encoding: "utf-8",
|
||||
env: {
|
||||
...process.env,
|
||||
PREMIERE_UXP_TOKEN: "cli-test-token-that-must-not-appear",
|
||||
POSTHOG_API_KEY: "cli-test-key-that-must-not-appear",
|
||||
},
|
||||
});
|
||||
const report = JSON.parse(output);
|
||||
|
||||
expect(report.schemaVersion).toBe("premiere-pro-mcp.doctor.v1");
|
||||
expect(report.components).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ boundary: "installed" }),
|
||||
expect.objectContaining({ boundary: "live_verified", state: "not_checked" }),
|
||||
]));
|
||||
expect(output).not.toContain("cli-test-token-that-must-not-appear");
|
||||
expect(output).not.toContain("cli-test-key-that-must-not-appear");
|
||||
});
|
||||
|
||||
it("--support-bundle emits a status snapshot rather than logs or configuration", () => {
|
||||
const output = execFileSync(process.execPath, [BIN, "--support-bundle"], { encoding: "utf-8" });
|
||||
const bundle = JSON.parse(output);
|
||||
|
||||
expect(bundle.schemaVersion).toBe("premiere-pro-mcp.support-bundle.v1");
|
||||
expect(bundle.doctor.schemaVersion).toBe("premiere-pro-mcp.doctor.v1");
|
||||
expect(JSON.stringify(bundle)).not.toMatch(/"(?:projectName|mediaName|outputDirectory|arguments|results)"\s*:/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (path: string) => readFileSync(join(root, path), "utf8");
|
||||
|
||||
describe("landing AI and search discovery", () => {
|
||||
it("keeps machine-readable references discoverable and unambiguous", () => {
|
||||
const layout = read("landing/app/layout.tsx");
|
||||
const home = read("landing/app/page.tsx");
|
||||
const hero = read("landing/components/sections/hero.tsx");
|
||||
const llms = read("landing/public/llms.txt");
|
||||
const llmsFull = read("landing/public/llms-full.txt");
|
||||
const llmAlias = read("landing/public/llm.txt");
|
||||
|
||||
expect(layout).toContain('href="/llms.txt"');
|
||||
expect(layout).toContain('href="/llms-full.txt"');
|
||||
expect(layout).toContain("MCP for Adobe Premiere Pro | Reviewable Workflow Automation");
|
||||
expect(home).toContain('alternateName: ["Premiere Pro MCP", "premiere-pro-mcp"]');
|
||||
expect(hero).toContain("MCP for Adobe Premiere Pro:");
|
||||
expect(llms).toContain("Preferred product name: **MCP for Adobe Premiere Pro**");
|
||||
expect(llms).toContain("https://premiere-pro-mcp.com/facts/");
|
||||
expect(llmsFull).toContain("https://premiere-pro-mcp.com/llm.txt");
|
||||
expect(llmAlias).toContain("https://premiere-pro-mcp.com/llms.txt");
|
||||
});
|
||||
|
||||
it("allows representative AI crawlers and refreshes release-backed product pages", () => {
|
||||
const robots = read("landing/app/robots.ts");
|
||||
const sitemap = read("landing/app/sitemap.ts");
|
||||
const facts = read("landing/app/facts/page.tsx");
|
||||
const docs = read("landing/app/docs/page.tsx");
|
||||
|
||||
for (const userAgent of ["OAI-SearchBot", "GPTBot", "Claude-SearchBot", "PerplexityBot", "Google-Extended"]) {
|
||||
expect(robots).toContain(`"${userAgent}"`);
|
||||
}
|
||||
expect(sitemap).toContain("productContentDate");
|
||||
expect(facts).toContain("Premiere Pro MCP the same product");
|
||||
expect(facts).toContain("For accurate AI and search answers");
|
||||
expect(docs).toContain("dateModified: product.releaseDate");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const createScript = join(root, "scripts", "create-licensed-host-sweep.mjs");
|
||||
const validateScript = join(root, "scripts", "validate-licensed-host-report.mjs");
|
||||
const fixtureHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
function createReport() {
|
||||
return JSON.parse(execFileSync(process.execPath, [
|
||||
createScript,
|
||||
"--host-os", "Windows",
|
||||
"--premiere-version", "26.3.0",
|
||||
"--panel-build", "0123abcd",
|
||||
"--fixture-revision", "generated-fixture-v1",
|
||||
"--fixture-sha256", fixtureHash,
|
||||
], { cwd: root, encoding: "utf8" }));
|
||||
}
|
||||
|
||||
function validate(report: unknown) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-host-sweep-"));
|
||||
const reportPath = join(directory, "report.json");
|
||||
try {
|
||||
writeFileSync(reportPath, `${JSON.stringify(report)}\n`, "utf8");
|
||||
return execFileSync(process.execPath, [validateScript, reportPath], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("licensed-host sweep reporting", () => {
|
||||
it("keeps the checked-in report template aligned with the privacy-safe schema", () => {
|
||||
const schema = JSON.parse(readFileSync(join(root, "docs", "licensed-host-sweep.schema.json"), "utf8"));
|
||||
const matrix = JSON.parse(readFileSync(join(root, "docs", "licensed-host-sweep.matrix.json"), "utf8"));
|
||||
const template = JSON.parse(readFileSync(join(root, "docs", "licensed-host-sweep.template.json"), "utf8"));
|
||||
|
||||
expect(schema.properties.schemaVersion.const).toBe("premiere-pro-mcp.licensed-host-sweep.v1");
|
||||
expect(template.sweep.matrixId).toBe(matrix.id);
|
||||
expect(template.cases.map((entry: { id: string }) => entry.id)).toEqual(matrix.cases.map((entry: { id: string }) => entry.id));
|
||||
});
|
||||
|
||||
it("generates a not-run sweep skeleton and accepts it without private evidence", () => {
|
||||
const report = createReport();
|
||||
expect(report).toMatchObject({
|
||||
schemaVersion: "premiere-pro-mcp.licensed-host-sweep.v1",
|
||||
host: { os: "Windows", premiereVersion: "26.3.0" },
|
||||
fixture: { revision: "generated-fixture-v1", sha256: fixtureHash },
|
||||
});
|
||||
expect(report.cases).toHaveLength(4);
|
||||
expect(validate(report)).toContain('"not_run": 4');
|
||||
});
|
||||
|
||||
it("continues to accept the focused editorial report template", () => {
|
||||
const editorialTemplate = JSON.parse(readFileSync(join(root, "docs", "licensed-host-report.template.json"), "utf8"));
|
||||
expect(validate(editorialTemplate)).toContain('"schemaVersion": "legacy-editorial-report"');
|
||||
});
|
||||
|
||||
it("rejects raw paths and requires opaque evidence references", () => {
|
||||
const report = createReport();
|
||||
report.cases[0] = {
|
||||
...report.cases[0],
|
||||
status: "passed",
|
||||
evidence: [
|
||||
{ kind: "host_state", ref: "C:\\private\\before.png" },
|
||||
{ kind: "structured_response", ref: "connection-response-001" },
|
||||
],
|
||||
};
|
||||
expect(() => validate(report)).toThrow(/opaque non-sensitive IDs|local path/i);
|
||||
});
|
||||
|
||||
it("rejects extra fields that could turn a report into an unredacted evidence dump", () => {
|
||||
const report = createReport();
|
||||
report.host.machineName = "editing-workstation";
|
||||
expect(() => validate(report)).toThrow(/host must contain only/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { sendCommand } = vi.hoisted(() => ({ sendCommand: vi.fn() }));
|
||||
|
||||
vi.mock("../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand,
|
||||
}));
|
||||
|
||||
import { getLiveContextResources } from "../src/resources/live-context-resources.js";
|
||||
|
||||
describe("live context resources", () => {
|
||||
beforeEach(() => {
|
||||
sendCommand.mockReset();
|
||||
});
|
||||
|
||||
it("defines ten bounded, read-only resource endpoints", () => {
|
||||
const resources = getLiveContextResources({ timeoutMs: 10 });
|
||||
|
||||
expect(resources.map((resource) => resource.uri)).toEqual([
|
||||
"premiere://project/info",
|
||||
"premiere://project/sequences",
|
||||
"premiere://project/media",
|
||||
"premiere://project/bins",
|
||||
"premiere://timeline/active",
|
||||
"premiere://effects/available",
|
||||
"premiere://effects/applied",
|
||||
"premiere://transitions/available",
|
||||
"premiere://export/presets",
|
||||
"premiere://project/metadata",
|
||||
]);
|
||||
expect(resources.every((resource) => {
|
||||
const description = resource.description.toLowerCase();
|
||||
return description.includes("read-only") || description.includes("path-redacted");
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a revisioned, path-safe JSON snapshot", async () => {
|
||||
sendCommand.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: { project: { name: "Edit" }, privacy: { nativePaths: "not returned" } },
|
||||
});
|
||||
const resource = getLiveContextResources({ timeoutMs: 10 })[0];
|
||||
const output = await resource.read(new URL(resource.uri));
|
||||
const snapshot = JSON.parse(output.contents[0].text);
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
ok: true,
|
||||
resource: "premiere://project/info",
|
||||
resourceSchemaVersion: 1,
|
||||
backend: "cep",
|
||||
data: { project: { name: "Edit" } },
|
||||
});
|
||||
expect(snapshot.snapshotRevision).toMatch(/^[a-f0-9]{20}$/);
|
||||
expect(output.contents[0].uri).toBe("premiere://project/info");
|
||||
});
|
||||
|
||||
it("returns a structured resource failure without fabricating host data", async () => {
|
||||
sendCommand.mockResolvedValueOnce({ success: false, error: "CEP bridge offline" });
|
||||
const resource = getLiveContextResources({ timeoutMs: 10 })[2];
|
||||
const output = await resource.read(new URL(resource.uri));
|
||||
const snapshot = JSON.parse(output.contents[0].text);
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
ok: false,
|
||||
resource: "premiere://project/media",
|
||||
error: "CEP bridge offline",
|
||||
});
|
||||
expect(snapshot).not.toHaveProperty("data");
|
||||
});
|
||||
|
||||
it("redacts unexpected path fields at the attachable-resource boundary", async () => {
|
||||
sendCommand.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: {
|
||||
presets: [{ name: "Match Source", path: "C:/Users/editor/private.epr" }],
|
||||
projectPath: "C:/Projects/private.prproj",
|
||||
nested: { tree_path: "Root/Private", fsName: "/tmp/private" },
|
||||
},
|
||||
});
|
||||
const resource = getLiveContextResources({ timeoutMs: 10 }).find(
|
||||
(candidate) => candidate.uri === "premiere://export/presets",
|
||||
);
|
||||
const output = await resource!.read(new URL(resource!.uri));
|
||||
const snapshot = JSON.parse(output.contents[0].text);
|
||||
|
||||
expect(snapshot.data).toEqual({
|
||||
presets: [{ name: "Match Source" }],
|
||||
nested: {},
|
||||
});
|
||||
expect(output.contents[0].text).not.toContain("C:/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import http from "node:http";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
||||
import { toNodeHandler } from "@modelcontextprotocol/node";
|
||||
import { createMcpHandler } from "@modelcontextprotocol/server";
|
||||
import { createServer } from "../src/server.js";
|
||||
|
||||
const closers: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(closers.splice(0).map((close) => close()));
|
||||
});
|
||||
|
||||
describe("MCP 2026-07-28 protocol entry", () => {
|
||||
it("negotiates the modern era and emits routing and cache metadata", async () => {
|
||||
const observed: Array<{ method?: string; name?: string; version?: string }> = [];
|
||||
const handler = createMcpHandler(() => createServer({ timeoutMs: 25 }));
|
||||
const nodeHandler = toNodeHandler(handler);
|
||||
const server = http.createServer((req, res) => {
|
||||
observed.push({
|
||||
method: req.headers["mcp-method"] as string | undefined,
|
||||
name: req.headers["mcp-name"] as string | undefined,
|
||||
version: req.headers["mcp-protocol-version"] as string | undefined,
|
||||
});
|
||||
void nodeHandler(req, res);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Expected a TCP test address");
|
||||
|
||||
const client = new Client(
|
||||
{ name: "premiere-modern-protocol-test", version: "1.0.0" },
|
||||
{ versionNegotiation: { mode: "auto" } },
|
||||
);
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${address.port}/mcp`),
|
||||
);
|
||||
closers.push(async () => {
|
||||
await client.close().catch(() => {});
|
||||
await handler.close().catch(() => {});
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
expect(client.getProtocolEra()).toBe("modern");
|
||||
|
||||
const capabilities = client.getServerCapabilities();
|
||||
expect(capabilities?.extensions?.["io.github.leancoderkavy/premiere-pro"]).toMatchObject({
|
||||
protocolRevision: "2026-07-28",
|
||||
dualEra: true,
|
||||
});
|
||||
|
||||
const tools = await client.listTools();
|
||||
expect(tools.ttlMs).toBe(30_000);
|
||||
expect(tools.cacheScope).toBe("private");
|
||||
expect(tools.tools).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: "get_capabilities" }),
|
||||
]));
|
||||
|
||||
const result = await client.callTool({ name: "get_capabilities", arguments: {} });
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
mcp: {
|
||||
protocol: { modernRevision: "2026-07-28", dualEra: true },
|
||||
features: {
|
||||
resultTypeDiscriminator: "supported",
|
||||
cancellation: "supported_by_protocol_host_limited",
|
||||
jsonSchema202012: "supported",
|
||||
cursorPagination: "supported_single_page_catalogs",
|
||||
resourceTemplates: "not_exposed_no_parameterized_resource_family",
|
||||
completions: "not_exposed_no_bounded_completion_domain",
|
||||
mcpAppsExtension: "not_implemented",
|
||||
skillsOverMcp: "experimental_not_advertised",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(observed).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ method: "tools/list", version: "2026-07-28" }),
|
||||
expect.objectContaining({ method: "tools/call", name: "get_capabilities", version: "2026-07-28" }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WORKFLOW_CATALOG, WORKFLOW_PROMPTS, WORKFLOW_RESOURCE } from "../src/workflows/catalog.js";
|
||||
import { annotationsForTool, structuredToolResult } from "../src/workflows/tool-metadata.js";
|
||||
import { createServer } from "../src/server.js";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import type { Telemetry, TelemetryProperties } from "../src/telemetry.js";
|
||||
|
||||
describe("modern MCP surface", () => {
|
||||
it("publishes focused workflow prompts with inspection and verification guidance", () => {
|
||||
expect(WORKFLOW_PROMPTS).toHaveLength(WORKFLOW_CATALOG.length);
|
||||
expect(new Set(WORKFLOW_PROMPTS.map((prompt) => prompt.name)).size).toBe(WORKFLOW_PROMPTS.length);
|
||||
|
||||
const rendered = WORKFLOW_PROMPTS[0].render({ goal: "Create a 30 second teaser" });
|
||||
expect(rendered.messages[0].content.text).toContain("Begin with project inspection");
|
||||
expect(rendered.messages[0].content.text).toContain("inspect the result");
|
||||
});
|
||||
|
||||
it("exposes a machine-readable workflow resource", () => {
|
||||
const resource = JSON.parse(WORKFLOW_RESOURCE);
|
||||
expect(resource.version).toBe(1);
|
||||
expect(resource.workflows).toHaveLength(16);
|
||||
expect(resource.workflows[0].recommendedTools).toContain("get_premiere_state");
|
||||
const organization = resource.workflows.find((workflow: { id: string }) => workflow.id === "project-organization");
|
||||
expect(organization.recommendedTools).toContain("apply_editorial_organization_plan");
|
||||
expect(organization.recommendedTools).not.toContain("organize_project_items_uxp");
|
||||
expect(organization.summary).toContain("advanced/manual only");
|
||||
const transcriptFirst = resource.workflows.find((workflow: { id: string }) => workflow.id === "transcript-first-context");
|
||||
expect(transcriptFirst.recommendedTools).toContain("create_editorial_context_pack");
|
||||
});
|
||||
|
||||
it("marks inspection as read-only and script execution as open-world", () => {
|
||||
expect(annotationsForTool("get_project_info")).toMatchObject({
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
});
|
||||
expect(annotationsForTool("delete_bin")).toMatchObject({
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
});
|
||||
expect(annotationsForTool("execute_extendscript").openWorldHint).toBe(true);
|
||||
expect(annotationsForTool("search_project_context")).toMatchObject({ readOnlyHint: true, idempotentHint: true });
|
||||
expect(annotationsForTool("create_context_edit_plan")).toMatchObject({ readOnlyHint: true, idempotentHint: true });
|
||||
expect(annotationsForTool("create_editorial_context_pack")).toMatchObject({ readOnlyHint: true, idempotentHint: true });
|
||||
expect(annotationsForTool("create_editorial_plan")).toMatchObject({ readOnlyHint: true, idempotentHint: true });
|
||||
expect(annotationsForTool("preview_editorial_plan")).toMatchObject({ readOnlyHint: true, idempotentHint: true });
|
||||
expect(annotationsForTool("verify_delivery_conformance")).toMatchObject({ readOnlyHint: true, idempotentHint: true, openWorldHint: false });
|
||||
expect(annotationsForTool("apply_editorial_organization_plan")).toMatchObject({
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
});
|
||||
expect(annotationsForTool("manage_project_context")).toMatchObject({
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
});
|
||||
for (const name of ["list_bins", "inspect_clip", "find_media", "check_project"]) {
|
||||
expect(annotationsForTool(name)).toMatchObject({ readOnlyHint: true, idempotentHint: true });
|
||||
}
|
||||
for (const name of ["remove_clip", "ripple_delete_clip", "close_project"]) {
|
||||
expect(annotationsForTool(name).destructiveHint).toBe(true);
|
||||
}
|
||||
expect(annotationsForTool("send_raw_script").openWorldHint).toBe(true);
|
||||
expect(annotationsForTool("save_project")).toMatchObject({
|
||||
title: "Save Project",
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds stable structured result envelopes", () => {
|
||||
expect(structuredToolResult("ping", true, { ready: true })).toEqual({
|
||||
ok: true,
|
||||
tool: "ping",
|
||||
data: { ready: true },
|
||||
});
|
||||
expect(structuredToolResult("save_project", false, undefined, "offline")).toEqual({
|
||||
ok: false,
|
||||
tool: "save_project",
|
||||
error: "offline",
|
||||
});
|
||||
expect(structuredToolResult("ping", true)).toEqual({ ok: true, tool: "ping", data: null });
|
||||
expect(structuredToolResult("ping", false)).toEqual({ ok: false, tool: "ping", error: "Unknown error" });
|
||||
});
|
||||
|
||||
it("renders optional workflow constraints", () => {
|
||||
const rendered = WORKFLOW_PROMPTS[1].render({ goal: "clean dialogue", constraints: "preserve room tone" });
|
||||
expect(rendered.messages[0].content.text).toContain("Constraints: preserve room tone");
|
||||
});
|
||||
|
||||
it("advertises prompts, resources, and tool annotations over MCP", async () => {
|
||||
const server = createServer({ timeoutMs: 50 });
|
||||
const client = new Client({ name: "modernization-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
try {
|
||||
const prompts = await client.listPrompts();
|
||||
expect(prompts.prompts.map((prompt) => prompt.name)).toContain("premiere-rough-cut");
|
||||
|
||||
const resources = await client.listResources();
|
||||
expect(resources.resources.map((resource) => resource.uri)).toContain("config://premiere-workflows");
|
||||
expect(resources.resources.map((resource) => resource.uri)).toEqual(expect.arrayContaining([
|
||||
"premiere://effects/available",
|
||||
"premiere://effects/applied",
|
||||
"premiere://transitions/available",
|
||||
"premiere://export/presets",
|
||||
"premiere://project/metadata",
|
||||
]));
|
||||
expect(resources.resources.map((resource) => resource.uri)).toContain("config://premiere-project-context");
|
||||
expect(resources.resources.map((resource) => resource.uri)).toEqual(expect.arrayContaining([
|
||||
"premiere://project/info",
|
||||
"premiere://project/sequences",
|
||||
"premiere://project/media",
|
||||
"premiere://project/bins",
|
||||
"premiere://timeline/active",
|
||||
]));
|
||||
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools.find((tool) => tool.name === "get_project_info")?.annotations?.readOnlyHint).toBe(true);
|
||||
expect(tools.tools.map((tool) => tool.name)).toContain("get_capabilities");
|
||||
// The default profile grants inspect/edit/export/filesystem but not
|
||||
// unsafe-script, so the two scripting tools are not advertised.
|
||||
expect(tools.tools.map((tool) => tool.name)).not.toContain("execute_extendscript");
|
||||
expect(tools.tools.map((tool) => tool.name)).not.toContain("evaluate_expression");
|
||||
expect(tools.tools).toHaveLength(347);
|
||||
const capabilityTool = tools.tools.find((tool) => tool.name === "get_capabilities");
|
||||
expect(capabilityTool?.outputSchema).toMatchObject({
|
||||
type: "object",
|
||||
// MCP clients such as Claude Desktop can require the 2020-12 dialect.
|
||||
// Keep this explicit: an older SDK release silently serialized every
|
||||
// registered output schema as draft-07, making every tool unusable
|
||||
// before its handler could run.
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
required: expect.arrayContaining(["ok", "tool"]),
|
||||
properties: expect.objectContaining({
|
||||
ok: expect.any(Object),
|
||||
tool: expect.any(Object),
|
||||
data: expect.any(Object),
|
||||
error: expect.any(Object),
|
||||
}),
|
||||
});
|
||||
expect(tools.tools.map((tool) => tool.name)).toEqual(expect.arrayContaining([
|
||||
"manage_project_context",
|
||||
"search_project_context",
|
||||
"create_context_edit_plan",
|
||||
"create_editorial_context_pack",
|
||||
"create_editorial_plan",
|
||||
"preview_editorial_plan",
|
||||
]));
|
||||
expect(tools.tools.find((tool) => tool.name === "create_editorial_context_pack")?.inputSchema)
|
||||
.toMatchObject({
|
||||
properties: {
|
||||
max_entries: { type: "integer", minimum: 1, maximum: 50 },
|
||||
max_characters: { type: "integer", minimum: 1024, maximum: 24_000 },
|
||||
},
|
||||
});
|
||||
expect(tools.tools.find((tool) => tool.name === "search_project_context")?.inputSchema)
|
||||
.toMatchObject({
|
||||
properties: { max_results: { type: "integer", minimum: 1, maximum: 50 } },
|
||||
});
|
||||
|
||||
const capabilities = await client.callTool({
|
||||
name: "get_capabilities",
|
||||
arguments: {},
|
||||
});
|
||||
const capabilityData = (capabilities.structuredContent as any).data;
|
||||
expect(capabilityData.tools.generatedFrom).toBe("registered-tool-catalog");
|
||||
// The catalog intentionally reports every tool that exists — including
|
||||
// ones this profile disables, each flagged with its authority state — so
|
||||
// it is a superset of what tools/list advertises.
|
||||
expect(capabilityData.tools.total).toBeGreaterThanOrEqual(tools.tools.length);
|
||||
expect(capabilityData.tools.tools.map((tool: any) => tool.name)).toEqual(
|
||||
expect.arrayContaining(tools.tools.map((tool) => tool.name)),
|
||||
);
|
||||
expect(
|
||||
capabilityData.tools.tools.find((tool: any) => tool.name === "create_editorial_context_pack"),
|
||||
).toMatchObject({
|
||||
backend: "local",
|
||||
authority: { required: "inspect", enabled: true },
|
||||
verificationBoundary: "static_metadata_only",
|
||||
hostVerificationRequired: false,
|
||||
});
|
||||
expect(
|
||||
capabilityData.tools.tools.find((tool: any) => tool.name === "execute_extendscript")
|
||||
?.authority,
|
||||
).toMatchObject({ required: "unsafe-script", enabled: false });
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("narrows tools/list with an explicit workflow pack without changing the authority report", async () => {
|
||||
const server = createServer({ timeoutMs: 50 }, { toolPacks: "essential" });
|
||||
const client = new Client({ name: "tool-pack-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
try {
|
||||
const tools = await client.listTools();
|
||||
const names = tools.tools.map((tool) => tool.name);
|
||||
expect(names).toEqual(expect.arrayContaining([
|
||||
"ping",
|
||||
"get_capabilities",
|
||||
"get_project_info",
|
||||
"inspect_sequence_review_report",
|
||||
"preview_edit_plan",
|
||||
"verify_delivery_file",
|
||||
]));
|
||||
expect(names).not.toContain("create_bin");
|
||||
// Essential is a 14-tool focused path (including the two always-visible
|
||||
// diagnostics), versus 342 tools in the default full catalog.
|
||||
expect(names).toHaveLength(14);
|
||||
|
||||
const capabilities = await client.callTool({
|
||||
name: "get_capabilities",
|
||||
arguments: {},
|
||||
});
|
||||
const capabilityData = (capabilities.structuredContent as any).data;
|
||||
expect(capabilityData.toolPacks).toMatchObject({
|
||||
source: "explicit",
|
||||
selected: ["essential"],
|
||||
fullCatalog: false,
|
||||
});
|
||||
expect(capabilityData.authority.enabled).toEqual(expect.arrayContaining([
|
||||
"inspect",
|
||||
"edit",
|
||||
"export",
|
||||
"filesystem",
|
||||
]));
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("records bounded tool usage without arguments or result content", async () => {
|
||||
const events: Array<{ event: string; properties?: TelemetryProperties }> = [];
|
||||
const telemetry: Telemetry = {
|
||||
enabled: true,
|
||||
capture: (event, properties) => events.push({ event, properties }),
|
||||
shutdown: async () => {},
|
||||
};
|
||||
const server = createServer({ timeoutMs: 50 }, { telemetry });
|
||||
const client = new Client({ name: "telemetry-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
try {
|
||||
await client.callTool({
|
||||
name: "get_capabilities",
|
||||
arguments: {},
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
event: "mcp_tool_call",
|
||||
properties: {
|
||||
tool: "get_capabilities",
|
||||
outcome: "succeeded",
|
||||
},
|
||||
});
|
||||
expect(events[0].properties).not.toHaveProperty("arguments");
|
||||
expect(events[0].properties).not.toHaveProperty("result");
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateNativeSdkHeaderInventory } from "../scripts/generate-native-sdk-header-inventory.mjs";
|
||||
|
||||
async function writeFixture(root: string, relativePath: string, content: string): Promise<void> {
|
||||
const path = join(root, relativePath);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
}
|
||||
|
||||
function linkDirectory(target: string, path: string): void {
|
||||
symlinkSync(target, path, process.platform === "win32" ? "junction" : "dir");
|
||||
}
|
||||
|
||||
describe("native SDK header-inventory receipt", () => {
|
||||
it("accounts for the documented UXP Hybrid headers without recording contents or absolute paths", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-native-sdk-"));
|
||||
const sdkRoot = join(directory, "hybrid-sdk");
|
||||
const archivePath = join(directory, "hybrid-sdk.zip");
|
||||
try {
|
||||
writeFileSync(archivePath, "authorized SDK fixture");
|
||||
await writeFixture(sdkRoot, "src/api/UxpAddonTypes.h", "typedef void* addon_value;");
|
||||
await writeFixture(sdkRoot, "src/api/UxpAddonShared.h", "void uxp_addon_example(void);");
|
||||
await writeFixture(sdkRoot, "src/utilities/UxpAddon.h", "#define UXP_ADDON_INIT(x)");
|
||||
await writeFixture(sdkRoot, "src/utilities/internal.hpp", "namespace fixture {}");
|
||||
const inventory = await generateNativeSdkHeaderInventory({
|
||||
sdk: "uxp-hybrid",
|
||||
sdkVersion: "7.3.0-fixture",
|
||||
archivePath,
|
||||
sdkRoot,
|
||||
});
|
||||
|
||||
expect(inventory).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
source: {
|
||||
sdk: "uxp-hybrid",
|
||||
authorityUrl: "https://developer.adobe.com/premiere-pro/uxp/plugins/hybrid-plugins/",
|
||||
inventoryScope: "header_files_only",
|
||||
archiveSha256: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
includeDirectories: ["src/api", "src/utilities"],
|
||||
},
|
||||
stats: { headers: 4 },
|
||||
});
|
||||
expect(inventory.headers.map((header) => header.path)).toEqual([
|
||||
"src/api/UxpAddonShared.h",
|
||||
"src/api/UxpAddonTypes.h",
|
||||
"src/utilities/UxpAddon.h",
|
||||
"src/utilities/internal.hpp",
|
||||
]);
|
||||
expect(JSON.stringify(inventory)).not.toContain(sdkRoot);
|
||||
expect(JSON.stringify(inventory)).not.toContain("typedef void*");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("requires documented Hybrid headers and explicit PrSDK include directories", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-native-sdk-invalid-"));
|
||||
const sdkRoot = join(directory, "sdk");
|
||||
const archivePath = join(directory, "sdk.zip");
|
||||
try {
|
||||
writeFileSync(archivePath, "authorized SDK fixture");
|
||||
await writeFixture(sdkRoot, "src/api/UxpAddonTypes.h", "typedef void* addon_value;");
|
||||
await writeFixture(sdkRoot, "src/api/UxpAddonShared.h", "void uxp_addon_example(void);");
|
||||
await writeFixture(sdkRoot, "src/utilities/Other.h", "#define OTHER 1");
|
||||
await expect(generateNativeSdkHeaderInventory({
|
||||
sdk: "uxp-hybrid", sdkVersion: "fixture", archivePath, sdkRoot,
|
||||
})).rejects.toThrow("Missing required UXP Hybrid SDK header");
|
||||
|
||||
await writeFixture(sdkRoot, "Headers/PrSDKFixture.h", "class PrSDKFixture {};");
|
||||
await expect(generateNativeSdkHeaderInventory({
|
||||
sdk: "premiere-prsdk", sdkVersion: "fixture", archivePath, sdkRoot,
|
||||
})).rejects.toThrow("requires one or more explicit includeDirectories");
|
||||
await expect(generateNativeSdkHeaderInventory({
|
||||
sdk: "premiere-prsdk", sdkVersion: "fixture", archivePath, sdkRoot,
|
||||
includeDirectories: ["Headers"],
|
||||
})).resolves.toMatchObject({
|
||||
source: { sdk: "premiere-prsdk", includeDirectories: ["Headers"] },
|
||||
stats: { headers: 1 },
|
||||
});
|
||||
await expect(generateNativeSdkHeaderInventory({
|
||||
sdk: "premiere-prsdk", sdkVersion: "fixture", archivePath, sdkRoot,
|
||||
includeDirectories: ["Headers/"],
|
||||
})).rejects.toThrow("must use a canonical relative path");
|
||||
await expect(generateNativeSdkHeaderInventory({
|
||||
sdk: "premiere-prsdk", sdkVersion: "fixture", archivePath, sdkRoot,
|
||||
includeDirectories: ["C:Headers"],
|
||||
})).rejects.toThrow("must stay relative to the SDK root");
|
||||
await expect(generateNativeSdkHeaderInventory({
|
||||
sdk: "constructor", sdkVersion: "fixture", archivePath, sdkRoot,
|
||||
})).rejects.toThrow("sdk must be uxp-hybrid or premiere-prsdk");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects include-root and intermediate links that resolve outside the SDK root", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-native-sdk-links-"));
|
||||
const archivePath = join(directory, "sdk.zip");
|
||||
const prsdkRoot = join(directory, "prsdk");
|
||||
const hybridRoot = join(directory, "hybrid");
|
||||
const outsideHeaders = join(directory, "outside-headers");
|
||||
const outsideSrc = join(directory, "outside-src");
|
||||
try {
|
||||
writeFileSync(archivePath, "authorized SDK fixture");
|
||||
await mkdir(prsdkRoot, { recursive: true });
|
||||
await writeFixture(outsideHeaders, "PrSDKFixture.h", "class PrSDKFixture {};");
|
||||
linkDirectory(outsideHeaders, join(prsdkRoot, "Headers"));
|
||||
await expect(generateNativeSdkHeaderInventory({
|
||||
sdk: "premiere-prsdk", sdkVersion: "fixture", archivePath, sdkRoot: prsdkRoot,
|
||||
includeDirectories: ["Headers"],
|
||||
})).rejects.toThrow("must stay inside the SDK root");
|
||||
|
||||
await mkdir(hybridRoot, { recursive: true });
|
||||
await writeFixture(outsideSrc, "api/UxpAddonTypes.h", "typedef void* addon_value;");
|
||||
await writeFixture(outsideSrc, "api/UxpAddonShared.h", "void uxp_addon_example(void);");
|
||||
await writeFixture(outsideSrc, "utilities/UxpAddon.h", "#define UXP_ADDON_INIT(x)");
|
||||
linkDirectory(outsideSrc, join(hybridRoot, "src"));
|
||||
await expect(generateNativeSdkHeaderInventory({
|
||||
sdk: "uxp-hybrid", sdkVersion: "fixture", archivePath, sdkRoot: hybridRoot,
|
||||
})).rejects.toThrow("must stay inside the SDK root");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("writes a deterministic receipt and rejects a stale check", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-native-sdk-cli-"));
|
||||
const sdkRoot = join(directory, "hybrid-sdk");
|
||||
const archivePath = join(directory, "hybrid-sdk.zip");
|
||||
const outputPath = join(directory, "inventory.json");
|
||||
const script = "scripts/generate-native-sdk-header-inventory.mjs";
|
||||
try {
|
||||
writeFileSync(archivePath, "authorized SDK fixture");
|
||||
await writeFixture(sdkRoot, "src/api/UxpAddonTypes.h", "typedef void* addon_value;");
|
||||
await writeFixture(sdkRoot, "src/api/UxpAddonShared.h", "void uxp_addon_example(void);");
|
||||
await writeFixture(sdkRoot, "src/utilities/UxpAddon.h", "#define UXP_ADDON_INIT(x)");
|
||||
const args = [script, "--sdk", "uxp-hybrid", "--sdk-version", "fixture", "--archive", archivePath, "--sdk-root", sdkRoot, "--output", outputPath];
|
||||
expect(spawnSync(process.execPath, args, { encoding: "utf8" }).status).toBe(0);
|
||||
expect(JSON.parse(readFileSync(outputPath, "utf8"))).toMatchObject({ stats: { headers: 3 } });
|
||||
expect(spawnSync(process.execPath, [...args, "--check"], { encoding: "utf8" }).status).toBe(0);
|
||||
writeFileSync(join(sdkRoot, "src/api/UxpAddonShared.h"), "changed");
|
||||
const stale = spawnSync(process.execPath, [...args, "--check"], { encoding: "utf8" });
|
||||
expect(stale.status).not.toBe(0);
|
||||
expect(stale.stderr).toContain("Native SDK header inventory is stale");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createOperationId,
|
||||
emitAudit,
|
||||
stderrAuditSink,
|
||||
} from "../src/security/audit.js";
|
||||
|
||||
describe("security audit helpers", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("creates UUID operation identifiers", () => {
|
||||
expect(createOperationId()).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("adds an ISO timestamp before emitting an audit event", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-15T21:30:00.000Z"));
|
||||
const sink = vi.fn();
|
||||
|
||||
emitAudit(sink, {
|
||||
operationId: "operation-1",
|
||||
action: "project.inspect",
|
||||
outcome: "succeeded",
|
||||
details: { clips: 2 },
|
||||
});
|
||||
|
||||
expect(sink).toHaveBeenCalledWith({
|
||||
operationId: "operation-1",
|
||||
action: "project.inspect",
|
||||
outcome: "succeeded",
|
||||
details: { clips: 2 },
|
||||
timestamp: "2026-08-15T21:30:00.000Z",
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("writes one protocol-safe JSON line to stderr", () => {
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
stderrAuditSink({
|
||||
operationId: "operation-2",
|
||||
action: "media.export",
|
||||
outcome: "denied",
|
||||
timestamp: "2026-08-15T21:31:00.000Z",
|
||||
});
|
||||
|
||||
expect(error).toHaveBeenCalledOnce();
|
||||
expect(JSON.parse(String(error.mock.calls[0][0]))).toEqual({
|
||||
type: "premiere-mcp-audit",
|
||||
operationId: "operation-2",
|
||||
action: "media.export",
|
||||
outcome: "denied",
|
||||
timestamp: "2026-08-15T21:31:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function validManifest(): JsonRecord {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: {
|
||||
apiPackage: "@adobe/premierepro",
|
||||
apiVersion: "26.3.0",
|
||||
changelogUrl: "https://developer.adobe.com/premiere-pro/uxp/changelog/",
|
||||
},
|
||||
entries: [validEntry()],
|
||||
};
|
||||
}
|
||||
|
||||
function validEntry(): JsonRecord {
|
||||
return {
|
||||
id: "test-entry",
|
||||
adobeApi: ["Project.test"],
|
||||
documentationUrls: [
|
||||
"https://developer.adobe.com/premiere-pro/uxp/ppro-reference/",
|
||||
],
|
||||
minimumPremiereVersion: "26.3.0",
|
||||
backend: "uxp",
|
||||
uxpCommand: null,
|
||||
mcpTools: ["test_tool"],
|
||||
availability: "current",
|
||||
implementationStatus: "implemented",
|
||||
verificationStatus: "automated_contract_verified",
|
||||
liveHostVerificationStatus: "not_run",
|
||||
mutatesProject: false,
|
||||
undoable: false,
|
||||
idempotency: "not_applicable",
|
||||
verificationBoundary: "test_result",
|
||||
};
|
||||
}
|
||||
|
||||
async function importWithManifest(manifest: unknown): Promise<unknown> {
|
||||
vi.resetModules();
|
||||
vi.doMock("node:module", () => ({
|
||||
createRequire: () => () => manifest,
|
||||
}));
|
||||
return import("../src/platform-capabilities.js");
|
||||
}
|
||||
|
||||
describe("Adobe UXP manifest validation", () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock("node:module");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
const invalidCases: Array<[string, (manifest: JsonRecord) => unknown, RegExp]> = [
|
||||
["requires an object manifest", () => null, /must be an object/],
|
||||
["requires schema version one", (m) => ({ ...m, schemaVersion: 2 }), /schemaVersion/],
|
||||
["requires an object source", (m) => ({ ...m, source: [] }), /source must be an object/],
|
||||
["requires the Premiere API package", (m) => ({ ...m, source: { ...(m.source as JsonRecord), apiPackage: "other" } }), /source package/],
|
||||
["requires a source API version", (m) => ({ ...m, source: { ...(m.source as JsonRecord), apiVersion: "" } }), /apiVersion/],
|
||||
["rejects malformed URLs", (m) => ({ ...m, source: { ...(m.source as JsonRecord), changelogUrl: "not a url" } }), /valid URL/],
|
||||
["rejects non-Adobe documentation", (m) => ({ ...m, source: { ...(m.source as JsonRecord), changelogUrl: "https://example.com/premiere-pro/uxp/" } }), /official Premiere Pro UXP/],
|
||||
["requires entries", (m) => ({ ...m, entries: [] }), /non-empty array/],
|
||||
["requires entry objects", (m) => ({ ...m, entries: [null] }), /entry 0 must be an object/],
|
||||
["requires entry identifiers", (m) => ({ ...m, entries: [{ ...validEntry(), id: "" }] }), /entry 0.id/],
|
||||
["requires kebab-case identifiers", (m) => ({ ...m, entries: [{ ...validEntry(), id: "Not Kebab" }] }), /unique kebab-case/],
|
||||
["rejects duplicate identifiers", (m) => ({ ...m, entries: [validEntry(), validEntry()] }), /unique kebab-case/],
|
||||
["requires documentation arrays", (m) => ({ ...m, entries: [{ ...validEntry(), documentationUrls: [] }] }), /documentationUrls/],
|
||||
["requires current or planned availability", (m) => ({ ...m, entries: [{ ...validEntry(), availability: "future" }] }), /availability/],
|
||||
["requires a supported implementation status", (m) => ({ ...m, entries: [{ ...validEntry(), implementationStatus: "partial" }] }), /implementationStatus/],
|
||||
["requires a supported verification status", (m) => ({ ...m, entries: [{ ...validEntry(), verificationStatus: "maybe" }] }), /verificationStatus/],
|
||||
["requires a supported live-host status", (m) => ({ ...m, entries: [{ ...validEntry(), liveHostVerificationStatus: "maybe" }] }), /liveHostVerificationStatus/],
|
||||
["requires supported idempotency", (m) => ({ ...m, entries: [{ ...validEntry(), idempotency: "sometimes" }] }), /idempotency/],
|
||||
["requires the UXP backend", (m) => ({ ...m, entries: [{ ...validEntry(), backend: "cep" }] }), /backend must be uxp/],
|
||||
["requires a nullable string command", (m) => ({ ...m, entries: [{ ...validEntry(), uxpCommand: 42 }] }), /uxpCommand/],
|
||||
["requires boolean mutation metadata", (m) => ({ ...m, entries: [{ ...validEntry(), mutatesProject: "no" }] }), /mutation metadata/],
|
||||
["requires non-empty API arrays", (m) => ({ ...m, entries: [{ ...validEntry(), adobeApi: [""] }] }), /adobeApi/],
|
||||
["requires a minimum version", (m) => ({ ...m, entries: [{ ...validEntry(), minimumPremiereVersion: null }] }), /minimumPremiereVersion/],
|
||||
["requires MCP tool arrays", (m) => ({ ...m, entries: [{ ...validEntry(), mcpTools: "tool" }] }), /mcpTools/],
|
||||
["requires a verification boundary", (m) => ({ ...m, entries: [{ ...validEntry(), verificationBoundary: "" }] }), /verificationBoundary/],
|
||||
];
|
||||
|
||||
it.each(invalidCases)("%s", async (_name, mutate, expected) => {
|
||||
await expect(importWithManifest(mutate(validManifest()))).rejects.toThrow(expected);
|
||||
});
|
||||
|
||||
it("accepts every supported manifest enum variant", async () => {
|
||||
const manifest = validManifest();
|
||||
manifest.entries = [
|
||||
validEntry(),
|
||||
{
|
||||
...validEntry(),
|
||||
id: "planned-entry",
|
||||
availability: "planned",
|
||||
implementationStatus: "planned",
|
||||
verificationStatus: "committed_unverified",
|
||||
liveHostVerificationStatus: "verified",
|
||||
idempotency: "operation_id",
|
||||
uxpCommand: "test.command",
|
||||
},
|
||||
{
|
||||
...validEntry(),
|
||||
id: "unstarted-entry",
|
||||
verificationStatus: "not_started",
|
||||
idempotency: "operation_id_when_supported",
|
||||
},
|
||||
];
|
||||
|
||||
const module = await importWithManifest(manifest) as {
|
||||
buildAdobeUxpCoverageReport: () => { summary: Record<string, number> };
|
||||
buildPlatformCapabilityReport: (
|
||||
capabilities: { source: "default"; capabilities: Set<"inspect"> },
|
||||
platform: NodeJS.Platform,
|
||||
tempDirectory: string,
|
||||
) => { runtime: { platformName: string; supported: boolean }; authority: { enabled: string[] } };
|
||||
platformName: (platform: NodeJS.Platform) => string;
|
||||
};
|
||||
expect(module.buildAdobeUxpCoverageReport().summary).toMatchObject({
|
||||
total: 3,
|
||||
current: 2,
|
||||
planned: 1,
|
||||
implemented: 2,
|
||||
committedUnverified: 1,
|
||||
automatedContractVerified: 1,
|
||||
liveHostVerified: 1,
|
||||
});
|
||||
expect(module.platformName("darwin")).toBe("macOS");
|
||||
expect(module.platformName("win32")).toBe("Windows");
|
||||
expect(module.platformName("linux")).toBe("linux");
|
||||
expect(module.buildPlatformCapabilityReport(
|
||||
{ source: "default", capabilities: new Set(["inspect"]) },
|
||||
"linux",
|
||||
"/private/bridge",
|
||||
)).toMatchObject({
|
||||
runtime: { platformName: "linux", supported: false, tempDirectory: "/private/bridge" },
|
||||
authority: { enabled: ["inspect"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import http from "node:http";
|
||||
import { SignJWT, exportJWK, generateKeyPair } from "jose";
|
||||
import { OAuthResourceServer } from "../src/oauth-resource-server.js";
|
||||
|
||||
const configuration = {
|
||||
issuer: "https://identity.example.com",
|
||||
audience: "https://premiere.example.com/mcp",
|
||||
jwksUri: "https://identity.example.com/.well-known/jwks.json",
|
||||
publicUrl: "https://premiere.example.com",
|
||||
requiredScopes: ["premiere:mcp", "premiere:read"],
|
||||
allowedSubjects: ["user-1"],
|
||||
};
|
||||
|
||||
const request = (authorization?: string) => ({ headers: { authorization } } as never);
|
||||
|
||||
describe("OAuth resource server", () => {
|
||||
it("publishes RFC 9728 metadata and a discovery challenge without secrets", () => {
|
||||
const resource = new OAuthResourceServer(configuration, async () => ({ sub: "user-1" }));
|
||||
expect(resource.metadata()).toEqual({
|
||||
resource: "https://premiere.example.com/mcp",
|
||||
authorization_servers: ["https://identity.example.com"],
|
||||
bearer_methods_supported: ["header"],
|
||||
scopes_supported: ["premiere:mcp", "premiere:read"],
|
||||
});
|
||||
expect(resource.challenge()).toBe(
|
||||
'Bearer resource_metadata="https://premiere.example.com/.well-known/oauth-protected-resource/mcp", scope="premiere:mcp premiere:read"',
|
||||
);
|
||||
expect(resource.challenge("missing_token")).not.toContain("error=");
|
||||
expect(resource.challenge("insufficient_scope")).toContain('scope="premiere:mcp premiere:read"');
|
||||
});
|
||||
|
||||
it("requires a bearer token, subject, and every configured scope", async () => {
|
||||
const valid = new OAuthResourceServer(configuration, async () => ({
|
||||
sub: "user-1", scope: "premiere:mcp premiere:read extra",
|
||||
}));
|
||||
await expect(valid.authenticate(request("Bearer signed-token"))).resolves.toMatchObject({
|
||||
authenticated: true,
|
||||
principal: { subject: "user-1", scopes: ["premiere:mcp", "premiere:read", "extra"] },
|
||||
});
|
||||
await expect(valid.authenticate(request())).resolves.toEqual({ authenticated: false, error: "missing_token" });
|
||||
await expect(valid.authenticate(request("Basic signed-token"))).resolves.toEqual({ authenticated: false, error: "missing_token" });
|
||||
|
||||
const missingSubject = new OAuthResourceServer(configuration, async () => ({ scope: "premiere:mcp premiere:read" }));
|
||||
await expect(missingSubject.authenticate(request("Bearer token"))).resolves.toEqual({ authenticated: false, error: "invalid_token" });
|
||||
|
||||
const missingScope = new OAuthResourceServer(configuration, async () => ({ sub: "user-1", scope: "premiere:mcp" }));
|
||||
await expect(missingScope.authenticate(request("Bearer token"))).resolves.toEqual({ authenticated: false, error: "insufficient_scope" });
|
||||
|
||||
const wrongSubject = new OAuthResourceServer(configuration, async () => ({
|
||||
sub: "user-2", scope: "premiere:mcp premiere:read",
|
||||
}));
|
||||
await expect(wrongSubject.authenticate(request("Bearer token"))).resolves.toEqual({ authenticated: false, error: "invalid_token" });
|
||||
});
|
||||
|
||||
it("fails closed when verification or JWKS retrieval fails", async () => {
|
||||
const resource = new OAuthResourceServer(configuration, async () => {
|
||||
throw new Error("identity provider unavailable");
|
||||
});
|
||||
await expect(resource.authenticate(request("Bearer token"))).resolves.toEqual({
|
||||
authenticated: false,
|
||||
error: "invalid_token",
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies signature, issuer, audience, lifetime, and required claims with remote JWKS", async () => {
|
||||
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
||||
const jwk = await exportJWK(publicKey);
|
||||
const jwksServer = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ keys: [{ ...jwk, kid: "test-key", alg: "RS256", use: "sig" }] }));
|
||||
});
|
||||
await new Promise<void>((resolve) => jwksServer.listen(0, "127.0.0.1", resolve));
|
||||
const address = jwksServer.address();
|
||||
if (!address || typeof address === "string") throw new Error("Test JWKS server did not bind");
|
||||
|
||||
try {
|
||||
const resource = new OAuthResourceServer({
|
||||
...configuration,
|
||||
jwksUri: `http://127.0.0.1:${address.port}/jwks.json`,
|
||||
});
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const validToken = await new SignJWT({ scope: "premiere:mcp premiere:read" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
|
||||
.setIssuer(configuration.issuer)
|
||||
.setAudience(configuration.audience)
|
||||
.setSubject("user-1")
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + 300)
|
||||
.sign(privateKey);
|
||||
await expect(resource.authenticate(request(`Bearer ${validToken}`))).resolves.toMatchObject({
|
||||
authenticated: true,
|
||||
principal: { subject: "user-1", rateLimitKey: expect.stringMatching(/^[a-f0-9]{32}$/) },
|
||||
});
|
||||
|
||||
const wrongAudience = await new SignJWT({ scope: "premiere:mcp premiere:read" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
|
||||
.setIssuer(configuration.issuer)
|
||||
.setAudience("https://attacker.example.com/mcp")
|
||||
.setSubject("user-1")
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + 300)
|
||||
.sign(privateKey);
|
||||
await expect(resource.authenticate(request(`Bearer ${wrongAudience}`))).resolves.toEqual({
|
||||
authenticated: false,
|
||||
error: "invalid_token",
|
||||
});
|
||||
|
||||
const rejectedClaims = [
|
||||
new SignJWT({ scope: "premiere:mcp premiere:read" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
|
||||
.setIssuer("https://wrong-issuer.example.com")
|
||||
.setAudience(configuration.audience)
|
||||
.setSubject("user-1")
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + 300),
|
||||
new SignJWT({ scope: "premiere:mcp premiere:read" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
|
||||
.setIssuer(configuration.issuer)
|
||||
.setAudience(configuration.audience)
|
||||
.setSubject("user-1")
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now - 60),
|
||||
new SignJWT({ scope: "premiere:mcp premiere:read" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
|
||||
.setIssuer(configuration.issuer)
|
||||
.setAudience(configuration.audience)
|
||||
.setSubject("user-1")
|
||||
.setIssuedAt(now)
|
||||
.setNotBefore(now + 300)
|
||||
.setExpirationTime(now + 600),
|
||||
new SignJWT({ scope: "premiere:mcp premiere:read" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
|
||||
.setIssuer(configuration.issuer)
|
||||
.setAudience(configuration.audience)
|
||||
.setSubject("user-1")
|
||||
.setExpirationTime(now + 300),
|
||||
];
|
||||
for (const builder of rejectedClaims) {
|
||||
const token = await builder.sign(privateKey);
|
||||
await expect(resource.authenticate(request(`Bearer ${token}`))).resolves.toEqual({
|
||||
authenticated: false,
|
||||
error: "invalid_token",
|
||||
});
|
||||
}
|
||||
|
||||
const { privateKey: attackerKey } = await generateKeyPair("RS256");
|
||||
const badSignature = await new SignJWT({ scope: "premiere:mcp premiere:read" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
|
||||
.setIssuer(configuration.issuer)
|
||||
.setAudience(configuration.audience)
|
||||
.setSubject("user-1")
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + 300)
|
||||
.sign(attackerKey);
|
||||
await expect(resource.authenticate(request(`Bearer ${badSignature}`))).resolves.toEqual({
|
||||
authenticated: false,
|
||||
error: "invalid_token",
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => jwksServer.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const inventory = JSON.parse(readFileSync("src/resources/premiere-doc-inventory.json", "utf8"));
|
||||
|
||||
describe("Adobe Premiere UXP documentation inventory", () => {
|
||||
it("accounts for every page and every public surface family", () => {
|
||||
expect(inventory.source.sitemapUrl).toBe("https://developer.adobe.com/sitemap.xml");
|
||||
expect(inventory.stats.total).toBe(inventory.pages.length);
|
||||
expect(Object.values(inventory.stats.bySurface).reduce((sum: number, count) => sum + Number(count), 0))
|
||||
.toBe(inventory.stats.total);
|
||||
expect(new Set(inventory.pages.map((page: { url: string }) => page.url)).size).toBe(inventory.pages.length);
|
||||
expect(Object.keys(inventory.stats.bySurface)).toEqual([
|
||||
"premiere-dom",
|
||||
"premiere-uxp-supporting-docs",
|
||||
"spectrum-web-components",
|
||||
"uxp-css",
|
||||
"uxp-html",
|
||||
"uxp-javascript",
|
||||
"uxp-plugin-guides",
|
||||
]);
|
||||
for (const page of inventory.pages) {
|
||||
expect(page.url).toMatch(/^https:\/\/developer\.adobe\.com\/premiere-pro\/uxp\//);
|
||||
expect(page.lastModified === null || /^\d{4}-\d{2}-\d{2}$/.test(page.lastModified)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("validates the committed snapshot without refetching the live sitemap", () => {
|
||||
const result = spawnSync(process.execPath, ["scripts/generate-premiere-doc-inventory.mjs", "--check"], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PREMIERE_DOC_SITEMAP_PATH: join(tmpdir(), "missing-premiere-doc-sitemap.xml"),
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("Premiere documentation inventory is current:");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["empty", "<urlset></urlset>", "no URL entries"],
|
||||
["missing location", "<urlset><url><lastmod>2026-09-01</lastmod></url></urlset>", "no location"],
|
||||
["bad date", "<urlset><url><loc>https://developer.adobe.com/premiere-pro/uxp/plugins/</loc><lastmod>today</lastmod></url></urlset>", "Invalid Adobe sitemap lastmod"],
|
||||
["impossible date", "<urlset><url><loc>https://developer.adobe.com/premiere-pro/uxp/plugins/</loc><lastmod>2026-02-31</lastmod></url></urlset>", "Invalid Adobe sitemap lastmod"],
|
||||
["duplicate", "<urlset><url><loc>https://developer.adobe.com/premiere-pro/uxp/plugins/</loc></url><url><loc>https://developer.adobe.com/premiere-pro/uxp/plugins/</loc></url></urlset>", "duplicate"],
|
||||
])("fails closed for a %s sitemap", (_label, sitemap, expectedError) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-doc-inventory-"));
|
||||
const fixture = join(directory, "sitemap.xml");
|
||||
writeFileSync(fixture, sitemap);
|
||||
try {
|
||||
const result = spawnSync(process.execPath, ["scripts/generate-premiere-doc-inventory.mjs", "--validate-only"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PREMIERE_DOC_SITEMAP_PATH: fixture },
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain(expectedError);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("decodes XML entities once without double-unescaping nested text", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-doc-inventory-"));
|
||||
const fixture = join(directory, "sitemap.xml");
|
||||
const output = join(directory, "inventory.json");
|
||||
const nestedEntityUrl = "https://developer.adobe.com/premiere-pro/uxp/plugins/?value=&amp;";
|
||||
writeFileSync(fixture, `<urlset><url><loc>${nestedEntityUrl}</loc></url></urlset>`);
|
||||
try {
|
||||
const result = spawnSync(process.execPath, ["scripts/generate-premiere-doc-inventory.mjs"], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PREMIERE_DOC_SITEMAP_PATH: fixture,
|
||||
PREMIERE_DOC_INVENTORY_OUTPUT_PATH: output,
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
const generated = JSON.parse(readFileSync(output, "utf8"));
|
||||
expect(generated.pages).toHaveLength(1);
|
||||
expect(generated.pages[0].url).toBe("https://developer.adobe.com/premiere-pro/uxp/plugins/?value=&");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies a representative URL from every documentation branch", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-doc-inventory-"));
|
||||
const fixture = join(directory, "sitemap.xml");
|
||||
const output = join(directory, "inventory.json");
|
||||
const expected = new Map([
|
||||
["https://developer.adobe.com/premiere-pro/uxp/ppro-reference/classes/project", "premiere-dom"],
|
||||
["https://developer.adobe.com/premiere-pro/uxp/uxp-api/reference-js/Modules/uxp/", "uxp-javascript"],
|
||||
["https://developer.adobe.com/premiere-pro/uxp/uxp-api/reference-html/HTML%20Elements/", "uxp-html"],
|
||||
["https://developer.adobe.com/premiere-pro/uxp/uxp-api/reference-css/General/", "uxp-css"],
|
||||
["https://developer.adobe.com/premiere-pro/uxp/uxp-api/reference-spectrum/spectrum-to-swc-mapping/", "spectrum-web-components"],
|
||||
["https://developer.adobe.com/premiere-pro/uxp/plugins/concepts/manifest/", "uxp-plugin-guides"],
|
||||
["https://developer.adobe.com/premiere-pro/uxp/resources/migration-guides/", "premiere-uxp-supporting-docs"],
|
||||
]);
|
||||
const sitemap = `<urlset>${[...expected.keys()].map((url) => `<url><loc>${url}</loc></url>`).join("")}</urlset>`;
|
||||
writeFileSync(fixture, sitemap);
|
||||
try {
|
||||
const result = spawnSync(process.execPath, ["scripts/generate-premiere-doc-inventory.mjs"], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PREMIERE_DOC_SITEMAP_PATH: fixture,
|
||||
PREMIERE_DOC_INVENTORY_OUTPUT_PATH: output,
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
const generated = JSON.parse(readFileSync(output, "utf8"));
|
||||
expect(new Map(generated.pages.map((page: { url: string; surface: string }) => [page.url, page.surface])))
|
||||
.toEqual(expected);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type Surface = {
|
||||
id: string;
|
||||
kind: string;
|
||||
authorityUrls: string[];
|
||||
communityReferenceUrls?: string[];
|
||||
versionSource: string;
|
||||
inventoryArtifact: string | null;
|
||||
inventoryState: string;
|
||||
implementationState: string;
|
||||
inventoryCommand?: string;
|
||||
inventoryVerificationCommand?: string;
|
||||
inventoryDocumentation?: string;
|
||||
benchmarkEvidenceCommand?: string;
|
||||
benchmarkEvidenceSchema?: string;
|
||||
addonReceiptCommand?: string;
|
||||
addonReceiptVerificationCommand?: string;
|
||||
addonReceiptDocumentation?: string;
|
||||
ccxReceiptCommand?: string;
|
||||
ccxReceiptVerificationCommand?: string;
|
||||
ccxReceiptDocumentation?: string;
|
||||
ccxReceiptSchemaVersion?: number;
|
||||
betaAafExportOptionsDriftArtifact?: string;
|
||||
betaAafExportOptionsDriftCommand?: string;
|
||||
betaAafExportOptionsDriftVerificationCommand?: string;
|
||||
betaAafExportOptionsDriftDocumentation?: string;
|
||||
betaProjectOptionsDriftArtifact?: string;
|
||||
betaProjectOptionsDriftCommand?: string;
|
||||
betaProjectOptionsDriftVerificationCommand?: string;
|
||||
betaProjectOptionsDriftDocumentation?: string;
|
||||
betaTransitionOptionsDriftArtifact?: string;
|
||||
betaTransitionOptionsDriftCommand?: string;
|
||||
betaTransitionOptionsDriftVerificationCommand?: string;
|
||||
betaTransitionOptionsDriftDocumentation?: string;
|
||||
betaRectFDriftArtifact?: string;
|
||||
betaRectFDriftCommand?: string;
|
||||
betaRectFDriftVerificationCommand?: string;
|
||||
betaRectFDriftDocumentation?: string;
|
||||
betaColorDriftArtifact?: string;
|
||||
betaColorDriftCommand?: string;
|
||||
betaColorDriftVerificationCommand?: string;
|
||||
betaColorDriftDocumentation?: string;
|
||||
betaPointFDriftArtifact?: string;
|
||||
betaPointFDriftCommand?: string;
|
||||
betaPointFDriftVerificationCommand?: string;
|
||||
betaPointFDriftDocumentation?: string;
|
||||
betaGuidDriftArtifact?: string;
|
||||
betaGuidDriftCommand?: string;
|
||||
betaGuidDriftVerificationCommand?: string;
|
||||
betaGuidDriftDocumentation?: string;
|
||||
betaFrameRateDriftArtifact?: string;
|
||||
betaFrameRateDriftCommand?: string;
|
||||
betaFrameRateDriftVerificationCommand?: string;
|
||||
betaFrameRateDriftDocumentation?: string;
|
||||
betaTickTimeDriftArtifact?: string;
|
||||
betaTickTimeDriftCommand?: string;
|
||||
betaTickTimeDriftVerificationCommand?: string;
|
||||
betaTickTimeDriftDocumentation?: string;
|
||||
betaC2paDriftArtifact?: string;
|
||||
betaC2paDriftCommand?: string;
|
||||
betaC2paDriftVerificationCommand?: string;
|
||||
betaC2paDriftDocumentation?: string;
|
||||
betaMediaDriftArtifact?: string;
|
||||
betaMediaDriftCommand?: string;
|
||||
betaMediaDriftVerificationCommand?: string;
|
||||
betaMediaDriftDocumentation?: string;
|
||||
betaMediaManagerDriftArtifact?: string;
|
||||
betaMediaManagerDriftCommand?: string;
|
||||
betaMediaManagerDriftVerificationCommand?: string;
|
||||
betaMediaManagerDriftDocumentation?: string;
|
||||
betaTranscriptDriftArtifact?: string;
|
||||
betaTranscriptDriftCommand?: string;
|
||||
betaTranscriptDriftVerificationCommand?: string;
|
||||
betaTranscriptDriftDocumentation?: string;
|
||||
betaWorkAreaDriftArtifact?: string;
|
||||
betaWorkAreaDriftCommand?: string;
|
||||
betaWorkAreaDriftVerificationCommand?: string;
|
||||
betaWorkAreaDriftDocumentation?: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
type Competitor = {
|
||||
repository: string;
|
||||
commit: string;
|
||||
observedAt: string;
|
||||
featureFamilies: string[];
|
||||
adoptionBoundary: string;
|
||||
};
|
||||
|
||||
const registry = JSON.parse(readFileSync("src/resources/premiere-surface-registry.json", "utf8")) as {
|
||||
schemaVersion: number;
|
||||
researchedAt: string;
|
||||
completionPolicy: string;
|
||||
integrationSurfaces: Surface[];
|
||||
competitorSources: Competitor[];
|
||||
};
|
||||
|
||||
describe("Premiere API and competitor surface registry", () => {
|
||||
it("enumerates every official surface family without overstating completion", () => {
|
||||
expect(registry.schemaVersion).toBe(1);
|
||||
expect(registry.researchedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(registry.completionPolicy).toContain("every item is classified");
|
||||
expect(registry.integrationSurfaces.map((surface) => surface.id)).toEqual([
|
||||
"premiere-dom",
|
||||
"uxp-javascript",
|
||||
"uxp-html",
|
||||
"uxp-css",
|
||||
"spectrum-web-components",
|
||||
"uxp-plugin-guides",
|
||||
"uxp-hybrid-cpp",
|
||||
"premiere-cpp-sdk",
|
||||
"cep-extendscript",
|
||||
"cep-platform",
|
||||
"qe-dom",
|
||||
]);
|
||||
expect(new Set(registry.integrationSurfaces.map((surface) => surface.id)).size)
|
||||
.toBe(registry.integrationSurfaces.length);
|
||||
|
||||
const inventoryStates = new Set([
|
||||
"complete",
|
||||
"partial",
|
||||
"not_started",
|
||||
"blocked_external_artifact",
|
||||
"unavailable_authoritative_source",
|
||||
]);
|
||||
const implementationStates = new Set(["partial", "gated", "not_started", "experimental"]);
|
||||
for (const surface of registry.integrationSurfaces) {
|
||||
if (surface.kind === "undocumented_api") {
|
||||
expect(surface.authorityUrls).toEqual([]);
|
||||
} else {
|
||||
expect(surface.authorityUrls.length).toBeGreaterThan(0);
|
||||
for (const url of surface.authorityUrls) expect(url).toMatch(/^https:\/\//);
|
||||
}
|
||||
for (const url of surface.communityReferenceUrls ?? []) expect(url).toMatch(/^https:\/\//);
|
||||
expect(surface.versionSource.length).toBeGreaterThan(0);
|
||||
expect(surface.notes.length).toBeGreaterThan(20);
|
||||
expect(inventoryStates.has(surface.inventoryState)).toBe(true);
|
||||
expect(implementationStates.has(surface.implementationState)).toBe(true);
|
||||
if (surface.inventoryState === "complete") {
|
||||
expect(surface.inventoryArtifact).toBeTruthy();
|
||||
}
|
||||
}
|
||||
expect(registry.integrationSurfaces.find((surface) => surface.id === "premiere-dom"))
|
||||
.toMatchObject({
|
||||
inventoryState: "complete",
|
||||
implementationState: "partial",
|
||||
betaAafExportOptionsDriftArtifact: "dist/resources/adobe-beta-aaf-export-options-drift.json",
|
||||
betaAafExportOptionsDriftCommand: "npm run adobe:beta-aaf-export-options-drift",
|
||||
betaAafExportOptionsDriftVerificationCommand: "npm run adobe:beta-aaf-export-options-drift:check",
|
||||
betaAafExportOptionsDriftDocumentation: "docs/adobe-beta-aaf-export-options-drift.md",
|
||||
betaProjectOptionsDriftArtifact: "dist/resources/adobe-beta-project-options-drift.json",
|
||||
betaProjectOptionsDriftCommand: "npm run adobe:beta-project-options-drift",
|
||||
betaProjectOptionsDriftVerificationCommand: "npm run adobe:beta-project-options-drift:check",
|
||||
betaProjectOptionsDriftDocumentation: "docs/adobe-beta-project-options-drift.md",
|
||||
betaTransitionOptionsDriftArtifact: "dist/resources/adobe-beta-transition-options-drift.json",
|
||||
betaTransitionOptionsDriftCommand: "npm run adobe:beta-transition-options-drift",
|
||||
betaTransitionOptionsDriftVerificationCommand: "npm run adobe:beta-transition-options-drift:check",
|
||||
betaTransitionOptionsDriftDocumentation: "docs/adobe-beta-transition-options-drift.md",
|
||||
betaRectFDriftArtifact: "dist/resources/adobe-beta-rectf-drift.json",
|
||||
betaRectFDriftCommand: "npm run adobe:beta-rectf-drift",
|
||||
betaRectFDriftVerificationCommand: "npm run adobe:beta-rectf-drift:check",
|
||||
betaRectFDriftDocumentation: "docs/adobe-beta-rectf-drift.md",
|
||||
betaColorDriftArtifact: "dist/resources/adobe-beta-color-drift.json",
|
||||
betaColorDriftCommand: "npm run adobe:beta-color-drift",
|
||||
betaColorDriftVerificationCommand: "npm run adobe:beta-color-drift:check",
|
||||
betaColorDriftDocumentation: "docs/adobe-beta-color-drift.md",
|
||||
betaPointFDriftArtifact: "dist/resources/adobe-beta-pointf-drift.json",
|
||||
betaPointFDriftCommand: "npm run adobe:beta-pointf-drift",
|
||||
betaPointFDriftVerificationCommand: "npm run adobe:beta-pointf-drift:check",
|
||||
betaPointFDriftDocumentation: "docs/adobe-beta-pointf-drift.md",
|
||||
betaGuidDriftArtifact: "dist/resources/adobe-beta-guid-drift.json",
|
||||
betaGuidDriftCommand: "npm run adobe:beta-guid-drift",
|
||||
betaGuidDriftVerificationCommand: "npm run adobe:beta-guid-drift:check",
|
||||
betaGuidDriftDocumentation: "docs/adobe-beta-guid-drift.md",
|
||||
betaFrameRateDriftArtifact: "dist/resources/adobe-beta-frame-rate-drift.json",
|
||||
betaFrameRateDriftCommand: "npm run adobe:beta-frame-rate-drift",
|
||||
betaFrameRateDriftVerificationCommand: "npm run adobe:beta-frame-rate-drift:check",
|
||||
betaFrameRateDriftDocumentation: "docs/adobe-beta-frame-rate-drift.md",
|
||||
betaTickTimeDriftArtifact: "dist/resources/adobe-beta-tick-time-drift.json",
|
||||
betaTickTimeDriftCommand: "npm run adobe:beta-tick-time-drift",
|
||||
betaTickTimeDriftVerificationCommand: "npm run adobe:beta-tick-time-drift:check",
|
||||
betaTickTimeDriftDocumentation: "docs/adobe-beta-tick-time-drift.md",
|
||||
betaC2paDriftArtifact: "dist/resources/adobe-beta-c2pa-drift.json",
|
||||
betaC2paDriftCommand: "npm run adobe:beta-c2pa-drift",
|
||||
betaC2paDriftVerificationCommand: "npm run adobe:beta-c2pa-drift:check",
|
||||
betaC2paDriftDocumentation: "docs/adobe-beta-c2pa-drift.md",
|
||||
betaMediaDriftArtifact: "dist/resources/adobe-beta-media-drift.json",
|
||||
betaMediaDriftCommand: "npm run adobe:beta-media-drift",
|
||||
betaMediaDriftVerificationCommand: "npm run adobe:beta-media-drift:check",
|
||||
betaMediaDriftDocumentation: "docs/adobe-beta-media-drift.md",
|
||||
betaMediaManagerDriftArtifact: "dist/resources/adobe-beta-media-manager-drift.json",
|
||||
betaMediaManagerDriftCommand: "npm run adobe:beta-media-manager-drift",
|
||||
betaMediaManagerDriftVerificationCommand: "npm run adobe:beta-media-manager-drift:check",
|
||||
betaMediaManagerDriftDocumentation: "docs/adobe-beta-media-manager-drift.md",
|
||||
betaTranscriptDriftArtifact: "dist/resources/adobe-beta-transcript-drift.json",
|
||||
betaTranscriptDriftCommand: "npm run adobe:beta-transcript-drift",
|
||||
betaTranscriptDriftVerificationCommand: "npm run adobe:beta-transcript-drift:check",
|
||||
betaTranscriptDriftDocumentation: "docs/adobe-beta-transcript-drift.md",
|
||||
betaWorkAreaDriftArtifact: "dist/resources/adobe-beta-work-area-drift.json",
|
||||
betaWorkAreaDriftCommand: "npm run adobe:beta-work-area-drift",
|
||||
betaWorkAreaDriftVerificationCommand: "npm run adobe:beta-work-area-drift:check",
|
||||
betaWorkAreaDriftDocumentation: "docs/adobe-beta-work-area-drift.md",
|
||||
});
|
||||
expect(registry.integrationSurfaces.find((surface) => surface.id === "uxp-javascript"))
|
||||
.toMatchObject({
|
||||
versionSource: "@adobe/cc-ext-uxp-types@7.3.1",
|
||||
inventoryArtifact: "dist/resources/uxp-js-api-inventory.json",
|
||||
inventoryState: "complete",
|
||||
implementationState: "partial",
|
||||
});
|
||||
expect(registry.integrationSurfaces.find((surface) => surface.id === "cep-extendscript"))
|
||||
.toMatchObject({
|
||||
authorityUrls: ["https://github.com/Adobe-CEP/Samples/tree/master/PProPanel"],
|
||||
communityReferenceUrls: [
|
||||
"https://ppro-scripting.docsforadobe.dev/",
|
||||
"https://github.com/docsforadobe/premiere-scripting-guide",
|
||||
],
|
||||
inventoryArtifact: "dist/resources/extendscript-api-inventory.json",
|
||||
inventoryState: "complete",
|
||||
implementationState: "partial",
|
||||
});
|
||||
expect(registry.integrationSurfaces.find((surface) => surface.id === "cep-platform"))
|
||||
.toMatchObject({
|
||||
inventoryArtifact: "dist/resources/cep-reference-inventory.json",
|
||||
inventoryState: "complete",
|
||||
implementationState: "partial",
|
||||
});
|
||||
expect(registry.integrationSurfaces.filter((surface) => surface.inventoryState === "complete"))
|
||||
.toHaveLength(8);
|
||||
for (const id of ["uxp-hybrid-cpp", "premiere-cpp-sdk"]) {
|
||||
expect(registry.integrationSurfaces.find((surface) => surface.id === id)).toMatchObject({
|
||||
inventoryArtifact: null,
|
||||
inventoryState: "blocked_external_artifact",
|
||||
inventoryCommand: "npm run native:sdk-header-inventory",
|
||||
inventoryVerificationCommand: "npm run native:sdk-header-inventory:verify",
|
||||
inventoryDocumentation: "docs/native-sdk-header-inventory.md",
|
||||
});
|
||||
}
|
||||
expect(registry.integrationSurfaces.find((surface) => surface.id === "uxp-hybrid-cpp"))
|
||||
.toMatchObject({
|
||||
benchmarkEvidenceCommand: "npm run benchmark:uxp-hybrid:verify",
|
||||
benchmarkEvidenceSchema: "benchmarks/uxp-hybrid/evidence.schema.json",
|
||||
addonReceiptCommand: "npm run native:hybrid-addon-receipt",
|
||||
addonReceiptVerificationCommand: "npm run native:hybrid-addon-receipt:verify",
|
||||
addonReceiptDocumentation: "docs/uxp-hybrid-addon-receipt.md",
|
||||
ccxReceiptCommand: "npm run native:hybrid-ccx-receipt",
|
||||
ccxReceiptVerificationCommand: "npm run native:hybrid-ccx-receipt:verify",
|
||||
ccxReceiptDocumentation: "docs/uxp-hybrid-ccx-receipt.md",
|
||||
ccxReceiptSchemaVersion: 2,
|
||||
});
|
||||
expect(registry.integrationSurfaces.find((surface) => surface.id === "uxp-hybrid-cpp")?.notes)
|
||||
.toContain("root main.js entrypoint and three-target bundle layout");
|
||||
expect(registry.integrationSurfaces.find((surface) => surface.id === "uxp-hybrid-cpp")?.notes)
|
||||
.toContain("schema-v2 local CCX archive receipt can bind that current layout receipt to the byte-identical required files, a full safe ZIP entry-name-set digest with unambiguous non-ASCII UTF-8 names and declared UTF-8 file comments, matching and feature-sufficient local ZIP version-needed, Deflate-only compression-option flags, framed non-ZIP64 extra fields, and core header fields, no unaccounted local-record or central-directory-to-end bytes, no declared Unix special file types, nonempty directory entries, or nonzero directory CRC-32 values, required streamed data-descriptor CRC/sizes, recomputed ZIP CRC-32, and exact DEFLATE range consumption for the already-required payloads; it also rejects encrypted-entry, central-directory-encryption, other unsupported general-purpose flags, or ZIP64 entry metadata before reading required payloads");
|
||||
expect(registry.integrationSurfaces.find((surface) => surface.id === "uxp-hybrid-cpp")?.notes)
|
||||
.toContain("schema-v3 candidate benchmark");
|
||||
});
|
||||
|
||||
it("pins reviewed competitor sources and explicit safe-adoption boundaries", () => {
|
||||
expect(registry.competitorSources).toHaveLength(4);
|
||||
expect(new Set(registry.competitorSources.map((source) => source.repository)).size)
|
||||
.toBe(registry.competitorSources.length);
|
||||
for (const source of registry.competitorSources) {
|
||||
expect(source.repository).toMatch(/^[\w.-]+\/[\w.-]+$/);
|
||||
expect(source.commit).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(source.observedAt).toBe(registry.researchedAt);
|
||||
expect(source.featureFamilies.length).toBeGreaterThan(0);
|
||||
expect(source.adoptionBoundary.length).toBeGreaterThan(40);
|
||||
}
|
||||
});
|
||||
});
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createProjectBackup } from "../src/tools/recovery.js";
|
||||
|
||||
describe("createProjectBackup", () => {
|
||||
it("proves the recovery copy is byte-identical and leaves the source unchanged", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-project-backup-"));
|
||||
const source = join(directory, "edit.prproj");
|
||||
const contents = Buffer.from("premiere-project-fixture\nrevision=7\n", "utf8");
|
||||
writeFileSync(source, contents);
|
||||
const before = statSync(source);
|
||||
|
||||
const receipt = createProjectBackup(source, new Date("2026-08-23T17:00:00.000Z"));
|
||||
|
||||
expect(receipt).toMatchObject({
|
||||
sourcePath: source,
|
||||
sourceUnchanged: true,
|
||||
byteIdentical: true,
|
||||
sizeBytes: contents.length,
|
||||
});
|
||||
expect(readFileSync(source)).toEqual(contents);
|
||||
expect(readFileSync(receipt.backupPath)).toEqual(contents);
|
||||
expect(statSync(source).mtimeMs).toBe(before.mtimeMs);
|
||||
});
|
||||
|
||||
it("fails closed for non-project files, missing files, and backup-name collisions", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-project-backup-"));
|
||||
const textFile = join(directory, "notes.txt");
|
||||
writeFileSync(textFile, "not a project");
|
||||
expect(() => createProjectBackup(textFile)).toThrow(".prproj");
|
||||
expect(() => createProjectBackup(join(directory, "missing.prproj"))).toThrow("does not exist");
|
||||
|
||||
const project = join(directory, "edit.prproj");
|
||||
writeFileSync(project, "fixture");
|
||||
const now = new Date("2026-08-23T17:00:00.000Z");
|
||||
createProjectBackup(project, now);
|
||||
expect(() => createProjectBackup(project, now)).toThrow();
|
||||
});
|
||||
});
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
defaultProjectContextDirectory,
|
||||
MAX_CONTEXT_RECORDS,
|
||||
normalizeContextKeywords,
|
||||
normalizeContextText,
|
||||
ProjectContextRepository,
|
||||
searchProjectContext,
|
||||
} from "../src/context/project-context-store.js";
|
||||
import {
|
||||
buildContextDocumentFromSnapshot,
|
||||
enrichContextDocument,
|
||||
getProjectContextTools,
|
||||
MAX_CONCURRENT_SOURCE_FINGERPRINTS,
|
||||
type PremiereContextSnapshot,
|
||||
} from "../src/tools/project-context.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
const [nodeMajor, nodeMinor] = process.versions.node.split(".").map(Number);
|
||||
const supportsNodeSqlite = nodeMajor > 22 || (nodeMajor === 22 && nodeMinor >= 5);
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
function snapshot(overrides: Partial<PremiereContextSnapshot["sequence"]["clips"][number]> = {}): PremiereContextSnapshot {
|
||||
return {
|
||||
projectName: "Documentary",
|
||||
projectPath: "D:/Projects/documentary.prproj",
|
||||
sequence: {
|
||||
id: "sequence-1",
|
||||
name: "Assembly",
|
||||
durationSeconds: 120,
|
||||
truncated: false,
|
||||
clips: [
|
||||
{
|
||||
nodeId: "timeline-1",
|
||||
name: "Interview A",
|
||||
startSeconds: 10,
|
||||
endSeconds: 20,
|
||||
inPointSeconds: 30,
|
||||
outPointSeconds: 40,
|
||||
speed: 1,
|
||||
trackType: "video",
|
||||
trackIndex: 0,
|
||||
sourceId: "source-1",
|
||||
sourceName: "Interview A.mov",
|
||||
mediaPath: "D:/Media/interview-a.mov",
|
||||
offline: false,
|
||||
...overrides,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("project context index", () => {
|
||||
it("separates source invalidation from timeline placement revisions", async () => {
|
||||
let mediaVersion = 1;
|
||||
const fingerprint = async () => ({ mediaPathHash: "path-hash", size: 1_000, modifiedMs: mediaVersion });
|
||||
const first = await buildContextDocumentFromSnapshot(snapshot(), undefined, fingerprint);
|
||||
const source = first.document.records.find((record) => record.kind === "source")!;
|
||||
const enriched = enrichContextDocument(first.document, [
|
||||
{
|
||||
kind: "transcript",
|
||||
source_id: "source-1",
|
||||
source_revision: source.sourceRevision,
|
||||
start_seconds: 31,
|
||||
end_seconds: 35,
|
||||
text: "The budget needs approval by Friday.",
|
||||
keywords: ["budget", "approval"],
|
||||
},
|
||||
]).document;
|
||||
|
||||
const moved = await buildContextDocumentFromSnapshot(
|
||||
snapshot({ startSeconds: 45, endSeconds: 55 }),
|
||||
enriched,
|
||||
fingerprint,
|
||||
);
|
||||
expect(moved.document.sourceRevision).toBe(first.document.sourceRevision);
|
||||
expect(moved.document.timelineRevision).not.toBe(first.document.timelineRevision);
|
||||
expect(moved.document.records.some((record) => record.kind === "transcript")).toBe(true);
|
||||
expect(moved.invalidatedRecords).toBe(0);
|
||||
|
||||
mediaVersion = 2;
|
||||
const changedSource = await buildContextDocumentFromSnapshot(snapshot(), moved.document, fingerprint);
|
||||
expect(changedSource.document.sourceRevision).not.toBe(first.document.sourceRevision);
|
||||
expect(changedSource.document.records.some((record) => record.kind === "transcript")).toBe(false);
|
||||
expect(changedSource.invalidatedRecords).toBe(1);
|
||||
});
|
||||
|
||||
it("bounds concurrent source fingerprint work during a large capture", async () => {
|
||||
const largeSnapshot = snapshot();
|
||||
largeSnapshot.sequence.clips = Array.from({ length: MAX_CONCURRENT_SOURCE_FINGERPRINTS * 3 }, (_, index) => ({
|
||||
...largeSnapshot.sequence.clips[0]!,
|
||||
nodeId: `timeline-${index}`,
|
||||
sourceId: `source-${index}`,
|
||||
mediaPath: `D:/Media/source-${index}.mov`,
|
||||
}));
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
await buildContextDocumentFromSnapshot(largeSnapshot, undefined, async () => {
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
active -= 1;
|
||||
return {};
|
||||
});
|
||||
expect(peak).toBe(MAX_CONCURRENT_SOURCE_FINGERPRINTS);
|
||||
});
|
||||
|
||||
it("ranks bounded transcript and audio evidence while preserving revision provenance", async () => {
|
||||
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({ mediaPathHash: "hash" }));
|
||||
const source = built.document.records.find((record) => record.kind === "source")!;
|
||||
const enriched = enrichContextDocument(built.document, [
|
||||
{
|
||||
id: "budget-transcript",
|
||||
kind: "transcript",
|
||||
name: "Budget answer",
|
||||
source_id: "source-1",
|
||||
source_revision: source.sourceRevision,
|
||||
start_seconds: 31,
|
||||
end_seconds: 35,
|
||||
text: "The budget needs approval by Friday.",
|
||||
keywords: ["budget", "approval"],
|
||||
},
|
||||
{
|
||||
id: "room-tone",
|
||||
kind: "audio",
|
||||
source_id: "source-1",
|
||||
source_revision: source.sourceRevision,
|
||||
text: "Quiet room tone suitable for dialogue padding.",
|
||||
keywords: ["room tone"],
|
||||
},
|
||||
]).document;
|
||||
|
||||
const results = searchProjectContext(enriched, { query: "budget approval", limit: 1 });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].record.id).toBe("budget-transcript");
|
||||
expect(results[0].record.sourceRevision).toBe(source.sourceRevision);
|
||||
expect(results[0].matchedTerms).toEqual(expect.arrayContaining(["budget", "approval"]));
|
||||
});
|
||||
|
||||
it("persists locally without retaining a native media path", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ppmcp-context-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const repository = new ProjectContextRepository({ backend: "json", directory });
|
||||
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({ mediaPathHash: "redacted-path-hash" }));
|
||||
await repository.put(built.document);
|
||||
|
||||
const restored = await repository.get(built.document.projectId);
|
||||
expect(restored).toEqual(built.document);
|
||||
expect(await repository.backendName()).toBe("json");
|
||||
const files = await readdir(directory);
|
||||
const serialized = await readFile(path.join(directory, files.find((name) => name.endsWith(".json"))!), "utf8");
|
||||
expect(serialized).not.toContain("D:/Media/interview-a.mov");
|
||||
expect(serialized).toContain("redacted-path-hash");
|
||||
});
|
||||
|
||||
it.runIf(supportsNodeSqlite)("uses SQLite when requested and supports complete repository lifecycle", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ppmcp-context-sqlite-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const repository = new ProjectContextRepository({ backend: "sqlite", directory });
|
||||
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({ mediaPathHash: "hash" }));
|
||||
|
||||
expect(await repository.backendName()).toBe("sqlite");
|
||||
expect(await repository.get("missing")).toBeUndefined();
|
||||
await repository.put(built.document);
|
||||
expect(await repository.get(built.document.projectId)).toEqual(built.document);
|
||||
expect(await repository.list()).toEqual([
|
||||
expect.objectContaining({ projectId: built.document.projectId, recordCount: built.document.records.length }),
|
||||
]);
|
||||
const updated = { ...built.document, projectName: "Updated documentary" };
|
||||
await repository.put(updated);
|
||||
expect((await repository.get(built.document.projectId))?.projectName).toBe("Updated documentary");
|
||||
expect(await repository.delete(built.document.projectId)).toBe(true);
|
||||
expect(await repository.delete(built.document.projectId)).toBe(false);
|
||||
await repository.close();
|
||||
});
|
||||
|
||||
it("handles missing and corrupt JSON entries without hiding valid project summaries", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ppmcp-context-json-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const repository = new ProjectContextRepository({ backend: "json", directory });
|
||||
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({}));
|
||||
|
||||
expect(await repository.get("missing")).toBeUndefined();
|
||||
expect(await repository.delete("missing")).toBe(false);
|
||||
await repository.put(built.document);
|
||||
await writeFile(path.join(directory, "corrupt.json"), "not json", "utf8");
|
||||
expect(await repository.list()).toEqual([
|
||||
expect.objectContaining({ projectId: built.document.projectId }),
|
||||
]);
|
||||
|
||||
const validFile = (await readdir(directory)).find((name) => name !== "corrupt.json")!;
|
||||
await writeFile(path.join(directory, validFile), JSON.stringify({ schemaVersion: 999 }), "utf8");
|
||||
await expect(repository.get(built.document.projectId)).rejects.toThrow("Unsupported project context schema version");
|
||||
});
|
||||
|
||||
it("validates repository backend, record bounds, and context directory overrides", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ppmcp-context-env-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
vi.stubEnv("PREMIERE_CONTEXT_DIR", directory);
|
||||
expect(defaultProjectContextDirectory()).toBe(path.resolve(directory));
|
||||
await expect(new ProjectContextRepository({ backend: "invalid" as any }).backendName())
|
||||
.rejects.toThrow("must be auto, sqlite, json, or memory");
|
||||
|
||||
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({}));
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
await expect(repository.put({
|
||||
...built.document,
|
||||
records: Array.from({ length: MAX_CONTEXT_RECORDS + 1 }, () => built.document.records[0]),
|
||||
})).rejects.toThrow(`limited to ${MAX_CONTEXT_RECORDS}`);
|
||||
expect(await repository.get("missing")).toBeUndefined();
|
||||
expect(await repository.list()).toEqual([]);
|
||||
expect(await repository.delete("missing")).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes bounded text and filters search by sequence, kind, and result limits", async () => {
|
||||
expect(normalizeContextText(null)).toBe("");
|
||||
expect(normalizeContextText(" multiple\n spaces ")).toBe("multiple spaces");
|
||||
expect(normalizeContextKeywords("not-an-array")).toEqual([]);
|
||||
expect(normalizeContextKeywords([" budget ", "budget", 4])).toEqual(["budget"]);
|
||||
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({}));
|
||||
expect(() => searchProjectContext(built.document, { query: " " })).toThrow("must not be empty");
|
||||
expect(searchProjectContext(built.document, {
|
||||
query: "interview",
|
||||
sequenceId: "different-sequence",
|
||||
})).toEqual([]);
|
||||
const sourceOnly = searchProjectContext(built.document, {
|
||||
query: "interview",
|
||||
kinds: ["source"],
|
||||
limit: 100,
|
||||
});
|
||||
expect(sourceOnly).toHaveLength(1);
|
||||
expect(sourceOnly[0].record.kind).toBe("source");
|
||||
expect(searchProjectContext(built.document, { query: "not-present", limit: -4 })).toEqual([]);
|
||||
});
|
||||
|
||||
it("supports enrich, status, search, plan, and clear through the MCP tool contracts", async () => {
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({ mediaPathHash: "hash" }));
|
||||
await repository.put(built.document);
|
||||
const tools = getProjectContextTools({}, { repository });
|
||||
const source = built.document.records.find((record) => record.kind === "source")!;
|
||||
|
||||
const enrich = await (tools.manage_project_context.handler as any)({
|
||||
action: "enrich",
|
||||
project_id: built.document.projectId,
|
||||
records: [
|
||||
{
|
||||
kind: "transcript",
|
||||
source_id: "source-1",
|
||||
source_revision: source.sourceRevision,
|
||||
start_seconds: 31,
|
||||
end_seconds: 35,
|
||||
text: "The budget needs approval by Friday.",
|
||||
keywords: ["budget"],
|
||||
metadata: { confidence: 0.97, source_path: "D:/private.mov", api_token: "secret" },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(enrich).toMatchObject({ success: true, data: { upserted: 1 } });
|
||||
|
||||
const status = await (tools.manage_project_context.handler as any)({
|
||||
action: "status",
|
||||
project_id: built.document.projectId,
|
||||
});
|
||||
expect(status).toMatchObject({ success: true, data: { backend: "memory", counts: { transcript: 1 } } });
|
||||
|
||||
const search = await (tools.search_project_context.handler as any)({
|
||||
project_id: built.document.projectId,
|
||||
query: "budget",
|
||||
});
|
||||
expect(search.data.results[0]).toMatchObject({ kind: "transcript", sourceId: "source-1" });
|
||||
expect(search.data.results[0].metadata).toEqual({ confidence: 0.97 });
|
||||
await expect((tools.search_project_context.handler as any)({
|
||||
project_id: built.document.projectId,
|
||||
query: "budget",
|
||||
max_results: 51,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("1 through 50") });
|
||||
|
||||
const plan = await (tools.create_context_edit_plan.handler as any)({
|
||||
project_id: built.document.projectId,
|
||||
intent: "Use the budget answer",
|
||||
});
|
||||
expect(plan).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
applied: false,
|
||||
expectedTimelineRevision: built.document.timelineRevision,
|
||||
candidates: [{ kind: "transcript", sourceId: "source-1" }],
|
||||
},
|
||||
});
|
||||
expect(plan.data.nextSteps.join(" ")).toContain("preview_edit_plan");
|
||||
|
||||
const cleared = await (tools.manage_project_context.handler as any)({
|
||||
action: "clear",
|
||||
project_id: built.document.projectId,
|
||||
});
|
||||
expect(cleared).toEqual({ success: true, data: { projectId: built.document.projectId, cleared: true } });
|
||||
});
|
||||
|
||||
it("imports explicit revision-bound editorial evidence without opening frames or calling a provider", async () => {
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({ mediaPathHash: "hash" }));
|
||||
await repository.put(built.document);
|
||||
const source = built.document.records.find((record) => record.kind === "source")!;
|
||||
const tools = getProjectContextTools({}, { repository });
|
||||
|
||||
const result = await (tools.manage_project_context.handler as any)({
|
||||
action: "import_evidence",
|
||||
project_id: built.document.projectId,
|
||||
evidence: [
|
||||
{
|
||||
type: "transcript_passage",
|
||||
text: "The budget needs approval by Friday.",
|
||||
speaker_label: "Presenter",
|
||||
source_id: "source-1",
|
||||
source_revision: source.sourceRevision,
|
||||
sequence_id: "sequence-1",
|
||||
timeline_item_id: "timeline-1",
|
||||
timeline_revision: built.document.timelineRevision,
|
||||
start_seconds: 31,
|
||||
end_seconds: 35,
|
||||
metadata: { confidence: 0.97, source_path: "D:/private.mov", api_token: "secret" },
|
||||
},
|
||||
{
|
||||
type: "shot_log",
|
||||
text: "Medium interview framing with a visible product sample.",
|
||||
source_id: "source-1",
|
||||
source_revision: source.sourceRevision,
|
||||
},
|
||||
{
|
||||
type: "audio_observation",
|
||||
text: "Clean dialogue; brief room-tone gap before the answer.",
|
||||
},
|
||||
{
|
||||
type: "operator_note",
|
||||
text: "Prefer this explanation after the opening question.",
|
||||
},
|
||||
{
|
||||
type: "frame_reference",
|
||||
frame_reference_id: "contact-sheet-003",
|
||||
sequence_id: "sequence-1",
|
||||
timeline_revision: built.document.timelineRevision,
|
||||
},
|
||||
{
|
||||
type: "speaker_label",
|
||||
speaker_label: "Presenter",
|
||||
source_id: "source-1",
|
||||
source_revision: source.sourceRevision,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
applied: false,
|
||||
upserted: 6,
|
||||
importedEvidenceTypes: expect.arrayContaining(["transcript_passage", "frame_reference"]),
|
||||
},
|
||||
});
|
||||
const current = await repository.get(built.document.projectId);
|
||||
const transcript = current!.records.find((record) => record.kind === "transcript")!;
|
||||
const frame = current!.records.find((record) => record.metadata?.frameReferenceId === "contact-sheet-003")!;
|
||||
expect(transcript).toMatchObject({
|
||||
sourceRevision: source.sourceRevision,
|
||||
timelineRevision: built.document.timelineRevision,
|
||||
metadata: { evidenceType: "transcript_passage", speakerLabel: "Presenter", confidence: 0.97 },
|
||||
});
|
||||
expect(transcript.metadata).not.toHaveProperty("source_path");
|
||||
expect(transcript.metadata).not.toHaveProperty("api_token");
|
||||
expect(frame).toMatchObject({ kind: "shot", timelineRevision: built.document.timelineRevision });
|
||||
|
||||
const moved = await buildContextDocumentFromSnapshot(
|
||||
snapshot({ startSeconds: 45, endSeconds: 55 }),
|
||||
current,
|
||||
async () => ({ mediaPathHash: "hash" }),
|
||||
);
|
||||
expect(moved.document.records.some((record) => record.metadata?.evidenceType === "transcript_passage")).toBe(false);
|
||||
expect(moved.document.records.some((record) => record.metadata?.evidenceType === "frame_reference")).toBe(false);
|
||||
expect(moved.document.records.some((record) => record.metadata?.evidenceType === "shot_log")).toBe(true);
|
||||
expect(moved.invalidatedRecords).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("rejects unguarded, stale, unknown, and path-shaped editorial evidence references", async () => {
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({ mediaPathHash: "hash" }));
|
||||
await repository.put(built.document);
|
||||
const tools = getProjectContextTools({}, { repository });
|
||||
const importEvidence = (evidence: unknown[]) => (tools.manage_project_context.handler as any)({
|
||||
action: "import_evidence",
|
||||
project_id: built.document.projectId,
|
||||
evidence,
|
||||
});
|
||||
|
||||
await expect(importEvidence([{ type: "shot_log", text: "Shot", source_id: "source-1" }]))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("source_revision") });
|
||||
await expect(importEvidence([{ type: "operator_note", text: "Note", sequence_id: "sequence-1" }]))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("timeline_revision") });
|
||||
await expect(importEvidence([{ type: "frame_reference", frame_reference_id: "D:/private/frame.png" }]))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("opaque identifier") });
|
||||
await expect(importEvidence([{ type: "operator_note", text: "Note", timeline_revision: "stale" }]))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("requires sequence_id") });
|
||||
});
|
||||
|
||||
it("captures through the MCP contract and preserves bridge failures", async () => {
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
const captureSnapshot = vi.fn().mockResolvedValue({ success: true, data: snapshot() });
|
||||
const tools = getProjectContextTools({}, { repository, captureSnapshot });
|
||||
const captured = await (tools.manage_project_context.handler as any)({ action: "capture" });
|
||||
expect(captured).toMatchObject({
|
||||
success: true,
|
||||
data: { backend: "memory", projectName: "Documentary", recordCount: 4, invalidatedRecords: 0 },
|
||||
});
|
||||
expect(captureSnapshot).toHaveBeenCalledOnce();
|
||||
|
||||
const statusList = await (tools.manage_project_context.handler as any)({ action: "status" });
|
||||
expect(statusList.data.projects).toHaveLength(1);
|
||||
const failedTools = getProjectContextTools({}, {
|
||||
repository,
|
||||
captureSnapshot: async () => ({ success: false, error: "Premiere offline" }),
|
||||
});
|
||||
await expect((failedTools.manage_project_context.handler as any)({ action: "capture" }))
|
||||
.resolves.toEqual({ success: false, error: "Premiere offline" });
|
||||
});
|
||||
|
||||
it("returns bounded not-found and unsupported-action errors", async () => {
|
||||
const repository = new ProjectContextRepository({ backend: "memory" });
|
||||
const tools = getProjectContextTools({}, { repository });
|
||||
await expect((tools.manage_project_context.handler as any)({ action: "status", project_id: "missing" }))
|
||||
.resolves.toEqual({ success: false, error: "Project context not found" });
|
||||
await expect((tools.manage_project_context.handler as any)({ action: "enrich", project_id: "missing", records: [] }))
|
||||
.resolves.toEqual({ success: false, error: "Project context not found; capture it before enrichment" });
|
||||
await expect((tools.search_project_context.handler as any)({ project_id: "missing", query: "x" }))
|
||||
.resolves.toEqual({ success: false, error: "Project context not found" });
|
||||
await expect((tools.create_context_edit_plan.handler as any)({ project_id: "missing", intent: "x" }))
|
||||
.resolves.toEqual({ success: false, error: "Project context not found" });
|
||||
await expect((tools.manage_project_context.handler as any)({ action: "unknown" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported project context action: unknown" });
|
||||
await expect((tools.manage_project_context.handler as any)({ action: "clear", project_id: "" }))
|
||||
.rejects.toThrow("project_id is required");
|
||||
});
|
||||
|
||||
it("rejects stale enrichment revisions", async () => {
|
||||
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({ mediaPathHash: "hash" }));
|
||||
expect(() => enrichContextDocument(built.document, [
|
||||
{
|
||||
kind: "transcript",
|
||||
source_id: "source-1",
|
||||
source_revision: "stale",
|
||||
text: "Old analysis",
|
||||
},
|
||||
])).toThrow("stale source_revision");
|
||||
});
|
||||
|
||||
it("validates enrichment shapes, bounds, replacement, and timeline revisions", async () => {
|
||||
const built = await buildContextDocumentFromSnapshot(snapshot(), undefined, async () => ({}));
|
||||
expect(() => enrichContextDocument(built.document, [])).toThrow("at least one enrichment");
|
||||
expect(() => enrichContextDocument(built.document, Array.from({ length: 513 }, () => ({
|
||||
kind: "note" as const,
|
||||
text: "note",
|
||||
})))).toThrow("limited to 512");
|
||||
expect(() => enrichContextDocument(built.document, [{ kind: "source", text: "bad" }]))
|
||||
.toThrow("kind must be transcript, shot, audio, or note");
|
||||
expect(() => enrichContextDocument(built.document, [{ kind: "note", text: " " }]))
|
||||
.toThrow("text must not be empty");
|
||||
expect(() => enrichContextDocument(built.document, [{ kind: "audio", source_id: "missing", text: "quiet" }]))
|
||||
.toThrow("unknown source_id");
|
||||
expect(() => enrichContextDocument(built.document, [{
|
||||
kind: "note",
|
||||
timeline_revision: "stale",
|
||||
text: "stale note",
|
||||
}])).toThrow("stale timeline_revision");
|
||||
expect(() => enrichContextDocument(built.document, [{ kind: "shot", start_seconds: -1, text: "bad" }]))
|
||||
.toThrow("start_seconds must be non-negative");
|
||||
expect(() => enrichContextDocument(built.document, [{ kind: "shot", start_seconds: 5, end_seconds: 4, text: "bad" }]))
|
||||
.toThrow("end_seconds must be at or after");
|
||||
|
||||
const first = enrichContextDocument(built.document, [{ id: "replace-me", kind: "note", text: "first" }]).document;
|
||||
const replaced = enrichContextDocument(first, [{
|
||||
id: "replace-me",
|
||||
kind: "note",
|
||||
text: "second",
|
||||
timeline_item_id: "timeline-1",
|
||||
timeline_revision: first.timelineRevision,
|
||||
track_type: "video",
|
||||
track_index: 0,
|
||||
metadata: { approved: true, score: 1, nullable: null, nested: { ignored: true } },
|
||||
}], true).document;
|
||||
expect(replaced.records.filter((record) => record.kind === "note")).toEqual([
|
||||
expect.objectContaining({ text: "second", timelineRevision: first.timelineRevision }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("validates incomplete and oversized Premiere snapshots", async () => {
|
||||
await expect(buildContextDocumentFromSnapshot({ projectName: "bad" } as any))
|
||||
.rejects.toThrow("incomplete context snapshot");
|
||||
const oversized = snapshot();
|
||||
oversized.sequence.clips = Array.from({ length: 2_001 }, (_, index) => ({
|
||||
...snapshot().sequence.clips[0],
|
||||
nodeId: `clip-${index}`,
|
||||
}));
|
||||
await expect(buildContextDocumentFromSnapshot(oversized))
|
||||
.rejects.toThrow("limited to 2000 timeline items");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
|
||||
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "premiere-project-intake-host-report-"));
|
||||
const validator = path.resolve("scripts/validate-project-intake-host-report.mjs");
|
||||
|
||||
function evidence(kind: string, reference: string) {
|
||||
return { kind, reference: `evidence://${reference}`, sha256: "d".repeat(64) };
|
||||
}
|
||||
|
||||
function completeSyntheticReport() {
|
||||
return {
|
||||
schemaVersion: "project-intake-host-report/v1",
|
||||
sourceCommit: "a".repeat(40),
|
||||
host: {
|
||||
os: "Windows",
|
||||
premiereVersion: "26.0.0",
|
||||
premiereBuild: "26.0.0-build-1",
|
||||
connector: { type: "cep", buildHash: "b".repeat(40) },
|
||||
},
|
||||
client: { name: "test-client", version: "1.0.0" },
|
||||
fixture: { revision: "generated-fixture-v1", sha256: "c".repeat(64) },
|
||||
privacy: {
|
||||
containsOnlyGeneratedFixtureData: true,
|
||||
localPathsRemoved: true,
|
||||
mediaNamesRemoved: true,
|
||||
promptsRemoved: true,
|
||||
transcriptsRemoved: true,
|
||||
credentialsRemoved: true,
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
id: "PIP-CONNECT-001",
|
||||
status: "passed",
|
||||
executedAt: "2026-08-23T00:00:00Z",
|
||||
assertions: { tool: "verify_premiere_connection", overall: "ready" },
|
||||
evidence: [evidence("structured_connection_response", "connection")],
|
||||
},
|
||||
{
|
||||
id: "PIP-PREVIEW-001",
|
||||
status: "passed",
|
||||
executedAt: "2026-08-23T00:01:00Z",
|
||||
assertions: {
|
||||
tool: "preview_project_intake",
|
||||
applied: false,
|
||||
pathDisclosure: "redacted",
|
||||
organizationPlanApplied: false,
|
||||
},
|
||||
evidence: [evidence("structured_preview_response", "preview")],
|
||||
},
|
||||
{
|
||||
id: "PIP-NO-MUTATION-001",
|
||||
status: "passed",
|
||||
executedAt: "2026-08-23T00:02:00Z",
|
||||
assertions: { tool: "preview_project_intake", projectMutated: false, projectSaved: false },
|
||||
evidence: [
|
||||
evidence("before_project_panel", "before-panel"),
|
||||
evidence("after_project_panel", "after-panel"),
|
||||
evidence("structured_preview_response", "preview-no-mutation"),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function validate(report: unknown) {
|
||||
const reportPath = path.join(tempDirectory, `${Math.random().toString(16).slice(2)}.json`);
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report));
|
||||
return spawnSync(process.execPath, [validator, reportPath], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
afterAll(() => fs.rmSync(tempDirectory, { recursive: true, force: true }));
|
||||
|
||||
describe("Project Intake licensed-host report validator", () => {
|
||||
it("accepts the not-run template without treating it as host verification", () => {
|
||||
const template = JSON.parse(fs.readFileSync("docs/project-intake-host-report.template.json", "utf8"));
|
||||
const result = validate(template);
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain('"not_run": 3');
|
||||
expect(result.stdout).toContain('"licensedHostVerifiedByValidator": false');
|
||||
});
|
||||
|
||||
it("validates a structurally complete synthetic fixture without treating it as host evidence", () => {
|
||||
const result = validate(completeSyntheticReport());
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain('"passed": 3');
|
||||
expect(result.stdout).toContain('"humanReviewRequired": true');
|
||||
expect(result.stdout).toContain('"licensedHostVerifiedByValidator": false');
|
||||
});
|
||||
|
||||
it("fails closed when a preview report reveals paths or fails its redacted postcondition", () => {
|
||||
const report = completeSyntheticReport();
|
||||
report.cases[1].assertions.pathDisclosure = "requested";
|
||||
report.client.name = "C:/restricted";
|
||||
const result = validate(report);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("pathDisclosure must be \"redacted\"");
|
||||
expect(result.stderr).toContain("project data, a local path, or credential-like content");
|
||||
});
|
||||
|
||||
it("requires no-mutation evidence whenever the Project Intake preview passes", () => {
|
||||
const report = completeSyntheticReport();
|
||||
report.cases[2].status = "not_run";
|
||||
delete report.cases[2].executedAt;
|
||||
delete report.cases[2].assertions;
|
||||
report.cases[2].evidence = [];
|
||||
const result = validate(report);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("PIP-PREVIEW-001 cannot pass unless PIP-NO-MUTATION-001 also passes");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateFacilityIntakeTemplate } from "../src/intake/project-intake.js";
|
||||
|
||||
type StarterTemplateFile = {
|
||||
templates: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
checks: string[];
|
||||
template: unknown;
|
||||
}>;
|
||||
};
|
||||
|
||||
const starters = JSON.parse(
|
||||
readFileSync(join(process.cwd(), "landing", "lib", "project-intake-starter-templates.json"), "utf8"),
|
||||
) as StarterTemplateFile;
|
||||
|
||||
describe("Project Intake starter templates", () => {
|
||||
it("keeps every public starter template valid for the real preview tool", () => {
|
||||
expect(starters.templates.map((starter) => starter.key)).toEqual([
|
||||
"editorial_handoff",
|
||||
"proxy_ready",
|
||||
"delivery_preflight",
|
||||
]);
|
||||
|
||||
for (const starter of starters.templates) {
|
||||
expect(starter.label).not.toHaveLength(0);
|
||||
expect(starter.description).not.toHaveLength(0);
|
||||
expect(starter.checks.length).toBeGreaterThan(0);
|
||||
const template = validateFacilityIntakeTemplate(starter.template);
|
||||
expect(template.approvedPathPrefixes).toEqual([]);
|
||||
expect(JSON.stringify(template)).not.toMatch(/[A-Za-z]:[\\/]|\\\\/);
|
||||
}
|
||||
});
|
||||
});
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildProjectIntakeReport,
|
||||
validateFacilityIntakeTemplate,
|
||||
validateProjectIntakeSnapshot,
|
||||
} from "../src/intake/project-intake.js";
|
||||
|
||||
const template = {
|
||||
schemaVersion: 1,
|
||||
id: "facility-intake",
|
||||
version: "2026-08",
|
||||
requiredBins: [{ name: "Incoming" }, { name: "Interviews", parentPath: "Editorial" }],
|
||||
allowedExtensions: ["mov", "wav"],
|
||||
allowedFrameRates: [23.976, 24],
|
||||
proxyPolicy: "require",
|
||||
approvedPathPrefixes: [{ id: "show-storage", prefix: "D:/Shows/Current" }],
|
||||
organizationRules: [{
|
||||
id: "interviews",
|
||||
destinationBinName: "Interviews",
|
||||
match: { filenamePrefixes: ["INT_"], extensions: ["mov"] },
|
||||
colorIndex: 3,
|
||||
}],
|
||||
};
|
||||
|
||||
const snapshot = {
|
||||
project: { id: "project-1", name: "Documentary" },
|
||||
truncated: false,
|
||||
unavailableEvidence: [],
|
||||
items: [
|
||||
{ id: "bin-editorial", name: "Editorial", type: "bin", treePath: "Editorial" },
|
||||
{ id: "bin-incoming", name: "Incoming", type: "bin", treePath: "Incoming" },
|
||||
{ id: "bin-interviews", name: "Interviews", type: "bin", treePath: "Editorial/Interviews", parentId: "bin-editorial" },
|
||||
{
|
||||
id: "clip-1", name: "INT_A_001.mov", type: "clip", parentId: "bin-incoming",
|
||||
mediaPath: "D:/Shows/Current/Camera/INT_A_001.mov", offline: false, hasProxy: true, frameRate: 23.976,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("project intake engine", () => {
|
||||
it("validates a bounded facility template and rejects unknown or unsafe shapes", () => {
|
||||
const normalized = validateFacilityIntakeTemplate({ ...template, allowedExtensions: ["WAV", ".MOV"] });
|
||||
expect(normalized.allowedExtensions).toEqual(["mov", "wav"]);
|
||||
expect(normalized.organizationRules[0].match).toEqual({ extensions: ["mov"], filenamePrefixes: ["int_"] });
|
||||
expect(() => validateFacilityIntakeTemplate({ ...template, unexpected: true })).toThrow("not supported");
|
||||
expect(() => validateFacilityIntakeTemplate({ ...template, organizationRules: [{
|
||||
id: "unsafe", destinationBinName: "Unsafe", match: {},
|
||||
}] })).toThrow("must include a filename prefix or extension");
|
||||
expect(() => validateFacilityIntakeTemplate({ ...template, allowedExtensions: ["../mov"] })).toThrow("simple file extension");
|
||||
});
|
||||
|
||||
it("produces a deterministic, read-only, path-redacted organization proposal", () => {
|
||||
const report = buildProjectIntakeReport(snapshot, template);
|
||||
const reordered = buildProjectIntakeReport({ ...snapshot, items: [...snapshot.items].reverse() }, template);
|
||||
|
||||
expect(report).toMatchObject({
|
||||
applied: false,
|
||||
status: "ready",
|
||||
capture: { itemCount: 4, truncated: false, pathDisclosure: "redacted" },
|
||||
organizationPlan: {
|
||||
applied: false,
|
||||
proposedBins: [{ name: "Interviews", exists: true, createIfApproved: false }],
|
||||
proposedMoves: [{ projectItemId: "clip-1", expectedParentId: "bin-incoming", destinationBinName: "Interviews", ruleId: "interviews", colorIndex: 3 }],
|
||||
},
|
||||
});
|
||||
expect(report.findings).toEqual([]);
|
||||
expect(report.project.revision).toBe(reordered.project.revision);
|
||||
expect(report.template.digest).toBe(reordered.template.digest);
|
||||
expect(report.organizationPlan.planDigest).toBe(reordered.organizationPlan.planDigest);
|
||||
expect(JSON.stringify(report)).not.toContain("D:/Shows/Current");
|
||||
});
|
||||
|
||||
it("reports observed extension, frame-rate, offline, proxy, and path violations without exposing paths by default", () => {
|
||||
const report = buildProjectIntakeReport({
|
||||
...snapshot,
|
||||
items: snapshot.items.map((item) => item.id === "clip-1" ? {
|
||||
...item,
|
||||
name: "INT_A_001.mp4",
|
||||
mediaPath: "D:/Unapproved/INT_A_001.mp4",
|
||||
frameRate: 29.97,
|
||||
offline: true,
|
||||
hasProxy: false,
|
||||
} : item),
|
||||
}, template);
|
||||
|
||||
expect(report.status).toBe("needs_attention");
|
||||
expect(report.findings.map((finding) => finding.code)).toEqual(expect.arrayContaining([
|
||||
"EXTENSION_NOT_ALLOWED", "FRAME_RATE_NOT_ALLOWED", "OFFLINE_MEDIA", "PROXY_MISSING", "MEDIA_PATH_NOT_APPROVED",
|
||||
]));
|
||||
const pathFinding = report.findings.find((finding) => finding.code === "MEDIA_PATH_NOT_APPROVED")!;
|
||||
expect(pathFinding).toMatchObject({ certainty: "observed", observed: { mediaPathHash: expect.stringMatching(/^[a-f0-9]{64}$/) } });
|
||||
expect(JSON.stringify(report)).not.toContain("D:/Unapproved");
|
||||
|
||||
const disclosed = buildProjectIntakeReport({
|
||||
...snapshot,
|
||||
items: snapshot.items.map((item) => item.id === "clip-1" ? { ...item, mediaPath: "D:/Unapproved/INT_A_001.mp4" } : item),
|
||||
}, template, { includePaths: true });
|
||||
expect(disclosed.capture.pathDisclosure).toBe("requested");
|
||||
expect(JSON.stringify(disclosed)).toContain("D:/Unapproved/INT_A_001.mp4");
|
||||
});
|
||||
|
||||
it("normalizes noisy Premiere frame-rate evidence without conflating canonical timebases", () => {
|
||||
const noisy = buildProjectIntakeReport({
|
||||
...snapshot,
|
||||
items: snapshot.items.map((item) => item.id === "clip-1" ? { ...item, frameRate: 29.93295 } : item),
|
||||
}, { ...template, allowedFrameRates: [29.97] });
|
||||
expect(noisy.findings.some((finding) => finding.code === "FRAME_RATE_NOT_ALLOWED")).toBe(false);
|
||||
|
||||
const distinctCanonical = buildProjectIntakeReport({
|
||||
...snapshot,
|
||||
items: snapshot.items.map((item) => item.id === "clip-1" ? { ...item, frameRate: 23.976 } : item),
|
||||
}, { ...template, allowedFrameRates: [24] });
|
||||
expect(distinctCanonical.findings).toContainEqual(expect.objectContaining({
|
||||
code: "FRAME_RATE_NOT_ALLOWED",
|
||||
itemId: "clip-1",
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([Number.NaN, Number.POSITIVE_INFINITY, 0, 241, null])(
|
||||
"keeps malformed frame-rate evidence as an item-level unsupported finding: %s",
|
||||
(frameRate) => {
|
||||
const report = buildProjectIntakeReport({
|
||||
...snapshot,
|
||||
items: snapshot.items.map((item) => item.id === "clip-1" ? { ...item, frameRate } : item),
|
||||
}, template);
|
||||
expect(report.status).toBe("incomplete");
|
||||
expect(report.capture.itemCount).toBe(snapshot.items.length);
|
||||
expect(report.findings).toContainEqual(expect.objectContaining({
|
||||
code: "FRAME_RATE_UNSUPPORTED",
|
||||
certainty: "unavailable",
|
||||
itemId: "clip-1",
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
it("returns incomplete when required evidence is unavailable and distinguishes optional checks not performed", () => {
|
||||
const incomplete = buildProjectIntakeReport({
|
||||
project: { id: "project-2" },
|
||||
truncated: false,
|
||||
unavailableEvidence: ["frame_rate"],
|
||||
items: [{ id: "clip-2", name: "Untitled", type: "clip" }],
|
||||
}, template);
|
||||
expect(incomplete.status).toBe("incomplete");
|
||||
expect(incomplete.findings).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ code: "REQUIRED_EVIDENCE_UNAVAILABLE", certainty: "unavailable" }),
|
||||
expect.objectContaining({ code: "EXTENSION_UNAVAILABLE", certainty: "unavailable" }),
|
||||
expect.objectContaining({ code: "FRAME_RATE_UNAVAILABLE", certainty: "unavailable" }),
|
||||
expect.objectContaining({ code: "PROXY_STATE_UNAVAILABLE", certainty: "unavailable" }),
|
||||
expect.objectContaining({ code: "MEDIA_PATH_UNAVAILABLE", certainty: "unavailable" }),
|
||||
]));
|
||||
|
||||
const explicitlyRequired = buildProjectIntakeReport({
|
||||
project: { id: "project-3" },
|
||||
truncated: false,
|
||||
unavailableEvidence: [],
|
||||
items: [{ id: "clip-3", name: "Untitled", type: "clip" }],
|
||||
}, {
|
||||
...template,
|
||||
allowedExtensions: [],
|
||||
allowedFrameRates: [],
|
||||
proxyPolicy: "ignore",
|
||||
approvedPathPrefixes: [],
|
||||
requiredEvidence: ["extension", "frame_rate", "offline", "proxy", "path"],
|
||||
});
|
||||
expect(explicitlyRequired.status).toBe("incomplete");
|
||||
expect(explicitlyRequired.findings.map((finding) => finding.code)).toEqual(expect.arrayContaining([
|
||||
"EXTENSION_UNAVAILABLE", "FRAME_RATE_UNAVAILABLE", "OFFLINE_STATE_UNAVAILABLE", "PROXY_STATE_UNAVAILABLE", "MEDIA_PATH_UNAVAILABLE",
|
||||
]));
|
||||
|
||||
const notChecked = buildProjectIntakeReport(snapshot, { ...template, proxyPolicy: "report_missing" });
|
||||
const reportWithoutProxyState = buildProjectIntakeReport({
|
||||
...snapshot,
|
||||
items: snapshot.items.map((item) => item.id === "clip-1" ? { ...item, hasProxy: undefined } : item),
|
||||
}, { ...template, proxyPolicy: "report_missing" });
|
||||
expect(notChecked.findings).toEqual([]);
|
||||
expect(reportWithoutProxyState.findings).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ code: "PROXY_STATE_NOT_CHECKED", certainty: "not_checked", severity: "info" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("flags missing bins, ambiguous organization rules, and truncated captures without proposing a mutation", () => {
|
||||
const report = buildProjectIntakeReport({
|
||||
...snapshot,
|
||||
truncated: true,
|
||||
items: snapshot.items.filter((item) => item.id !== "bin-incoming"),
|
||||
}, {
|
||||
...template,
|
||||
organizationRules: [
|
||||
template.organizationRules[0],
|
||||
{ id: "also-interviews", destinationBinName: "Also Interviews", match: { filenamePrefixes: ["INT_"] } },
|
||||
],
|
||||
});
|
||||
expect(report.status).toBe("incomplete");
|
||||
expect(report.findings).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ code: "CAPTURE_TRUNCATED", certainty: "unavailable" }),
|
||||
expect.objectContaining({ code: "REQUIRED_BIN_MISSING", certainty: "observed" }),
|
||||
expect.objectContaining({ code: "AMBIGUOUS_ORGANIZATION_RULES", itemId: "clip-1", certainty: "observed" }),
|
||||
]));
|
||||
expect(report.organizationPlan.proposedMoves).toEqual([]);
|
||||
expect(report.applied).toBe(false);
|
||||
});
|
||||
|
||||
it("distinguishes unavailable, incorrect, and suffix-matched required-bin parents", () => {
|
||||
const baseItems = snapshot.items.filter((item) => item.id !== "bin-interviews");
|
||||
|
||||
const unavailable = buildProjectIntakeReport({
|
||||
...snapshot,
|
||||
items: [...baseItems, { id: "bin-interviews", name: "Interviews", type: "bin" }],
|
||||
}, template);
|
||||
expect(unavailable.status).toBe("incomplete");
|
||||
expect(unavailable.findings).toContainEqual(expect.objectContaining({
|
||||
code: "REQUIRED_BIN_PARENT_UNAVAILABLE",
|
||||
certainty: "unavailable",
|
||||
}));
|
||||
|
||||
const wrong = buildProjectIntakeReport({
|
||||
...snapshot,
|
||||
items: [...baseItems, {
|
||||
id: "bin-interviews", name: "Interviews", type: "bin", treePath: "Other/Interviews",
|
||||
}],
|
||||
}, template);
|
||||
expect(wrong.status).toBe("needs_attention");
|
||||
expect(wrong.findings).toContainEqual(expect.objectContaining({
|
||||
code: "REQUIRED_BIN_WRONG_PARENT",
|
||||
certainty: "observed",
|
||||
}));
|
||||
|
||||
const suffixMatched = buildProjectIntakeReport({
|
||||
...snapshot,
|
||||
items: [...baseItems, {
|
||||
id: "bin-interviews", name: "Interviews", type: "bin", treePath: "Show/Editorial/Interviews",
|
||||
}],
|
||||
}, template);
|
||||
expect(suffixMatched.findings.some((finding) => finding.code.startsWith("REQUIRED_BIN_"))).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds snapshots and rejects malformed structural evidence before evaluation", () => {
|
||||
expect(() => validateProjectIntakeSnapshot({
|
||||
project: { id: "project" }, items: [{ id: "one", name: "One", type: "clip" }, { id: "one", name: "Two", type: "clip" }],
|
||||
truncated: false, unavailableEvidence: [],
|
||||
})).toThrow("duplicate ids");
|
||||
expect(() => validateProjectIntakeSnapshot({
|
||||
project: { id: "project" }, items: [], truncated: "no", unavailableEvidence: [],
|
||||
})).toThrow("must be a boolean");
|
||||
expect(() => validateProjectIntakeSnapshot({
|
||||
project: { id: "project" }, items: [{ id: "one", name: "One", type: "asset" }],
|
||||
truncated: false, unavailableEvidence: [],
|
||||
})).toThrow("type must be clip, bin, sequence, or other");
|
||||
});
|
||||
|
||||
it("rejects duplicate and out-of-contract facility rules", () => {
|
||||
expect(() => validateFacilityIntakeTemplate({
|
||||
...template,
|
||||
approvedPathPrefixes: [{ id: "storage", prefix: "D:/One" }, { id: "storage", prefix: "D:/Two" }],
|
||||
})).toThrow("approvedPathPrefixes contains duplicate ids");
|
||||
expect(() => validateFacilityIntakeTemplate({
|
||||
...template,
|
||||
requiredEvidence: ["proxy", "proxy"],
|
||||
})).toThrow("requiredEvidence contains duplicate entries");
|
||||
expect(() => validateFacilityIntakeTemplate({
|
||||
...template,
|
||||
requiredEvidence: ["duration"],
|
||||
})).toThrow("must be extension, frame_rate, offline, proxy, or path");
|
||||
expect(() => validateFacilityIntakeTemplate({
|
||||
...template,
|
||||
organizationRules: [template.organizationRules[0], { ...template.organizationRules[0] }],
|
||||
})).toThrow("organizationRules contains duplicate ids");
|
||||
expect(() => validateFacilityIntakeTemplate({
|
||||
...template,
|
||||
organizationRules: [{ ...template.organizationRules[0], colorIndex: 15 }],
|
||||
})).toThrow("colorIndex must be an integer from 0 through 14");
|
||||
expect(() => validateFacilityIntakeTemplate({ ...template, proxyPolicy: "generate" })).toThrow(
|
||||
"proxyPolicy must be ignore, report_missing, or require",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
|
||||
describe("quick-start localization foundation", () => {
|
||||
it("keeps translated quick starts structurally aligned with the English source", () => {
|
||||
const output = execFileSync(process.execPath, ["scripts/check-quickstart-locales.mjs"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(output).toContain("2 translations");
|
||||
});
|
||||
|
||||
it("links the README proof path without calling an illustration a live screenshot", () => {
|
||||
const readme = readFileSync(join(root, "README.md"), "utf8");
|
||||
expect(readme).toContain("premiere-pro-mcp-demo-poster.png");
|
||||
expect(readme).toContain("not a Premiere panel screenshot or licensed-host proof");
|
||||
expect(readme).toContain("docs/quickstart/README.md");
|
||||
});
|
||||
});
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (path: string) => readFileSync(join(root, path), "utf8");
|
||||
const readJson = (path: string) => JSON.parse(read(path));
|
||||
|
||||
describe("canonical release metadata", () => {
|
||||
const release = readJson("release-metadata.json");
|
||||
|
||||
it("aligns every distributable manifest with the canonical version", () => {
|
||||
expect(readJson("package.json").version).toBe(release.version);
|
||||
expect(readJson("package.json").description).toContain(
|
||||
`${release.coreTools} core AI video editing tools`,
|
||||
);
|
||||
const packageLock = readJson("package-lock.json");
|
||||
expect(packageLock.version).toBe(release.version);
|
||||
expect(packageLock.packages[""].version).toBe(release.version);
|
||||
expect(readJson("claude-desktop/manifest.json").version).toBe(release.version);
|
||||
expect(readJson("uxp-plugin/manifest.json").version).toBe(release.version);
|
||||
expect(readJson("plugins/premiere-pro/.codex-plugin/plugin.json").version).toBe(
|
||||
release.version,
|
||||
);
|
||||
expect(
|
||||
readJson("claude-plugins/premiere-pro/.claude-plugin/plugin.json").version,
|
||||
).toBe(release.version);
|
||||
expect(readJson(".claude-plugin/marketplace.json").plugins[0].version).toBe(
|
||||
release.version,
|
||||
);
|
||||
|
||||
for (const path of [
|
||||
"plugins/premiere-pro/.mcp.json",
|
||||
"claude-plugins/premiere-pro/.mcp.json",
|
||||
]) {
|
||||
expect(read(path)).toContain(`premiere-pro-mcp@${release.version}`);
|
||||
}
|
||||
|
||||
const cepManifest = read("cep-plugin/CSXS/manifest.xml");
|
||||
expect(cepManifest).toContain(`ExtensionBundleVersion="${release.version}"`);
|
||||
expect(cepManifest).toContain(`Version="${release.version}"`);
|
||||
expect(read("cep-plugin/updater.cjs")).toContain(
|
||||
`CURRENT_VERSION = "${release.version}"`,
|
||||
);
|
||||
expect(read("cep-plugin/index.html")).toContain(`Version ${release.version}`);
|
||||
});
|
||||
|
||||
it("aligns public release and capability claims", () => {
|
||||
const readme = read("README.md");
|
||||
const llms = read("landing/public/llms.txt");
|
||||
const llmsFull = read("landing/public/llms-full.txt");
|
||||
const llmAlias = read("landing/public/llm.txt");
|
||||
const landingProduct = read("landing/lib/product.ts");
|
||||
const landingArticles = read("landing/lib/articles.ts");
|
||||
const marketingAssets = read("docs/marketing-assets.md");
|
||||
const supportedActions = read("docs/supported-actions.md");
|
||||
|
||||
expect(readme).toContain(`${release.coreTools} core tools`);
|
||||
expect(readme).toContain(
|
||||
`${release.defaultProfileTools} under the default profile`,
|
||||
);
|
||||
expect(readme).toContain(
|
||||
`${release.defaultProfileWithUxpTools} with a connected UXP bridge`,
|
||||
);
|
||||
expect(readme).toContain(`${release.uxpAdditionalTools} capability-gated tools`);
|
||||
expect(llms).toContain(`Current project release: ${release.version}`);
|
||||
expect(llms).toContain(
|
||||
`${release.coreTools} core structured MCP tools`,
|
||||
);
|
||||
expect(llmsFull).toContain(`Current release: ${release.version}`);
|
||||
expect(llmsFull).toContain(
|
||||
`${release.uxpAdditionalTools} capability-gated tools`,
|
||||
);
|
||||
expect(llmsFull).toContain(
|
||||
`${release.defaultProfileWithUxpTools} connected tools`,
|
||||
);
|
||||
expect(llmAlias).toContain("https://premiere-pro-mcp.com/llms.txt");
|
||||
expect(marketingAssets).toContain(
|
||||
`${release.uxpAdditionalTools} additional capability-gated tools`,
|
||||
);
|
||||
expect(landingProduct).toContain(`version: "${release.version}"`);
|
||||
expect(landingProduct).toContain(`coreToolCount: ${release.coreTools}`);
|
||||
expect(landingProduct).toContain(`defaultProfileToolCount: ${release.defaultProfileTools}`);
|
||||
expect(landingProduct).toContain(`connectedUxpToolCount: ${release.defaultProfileWithUxpTools}`);
|
||||
expect(landingArticles).toContain(
|
||||
`The server currently registers ${release.coreTools} core structured tools`,
|
||||
);
|
||||
expect(landingArticles).toContain(
|
||||
`${release.uxpAdditionalTools} capability-gated tools, bringing the connected surface to ${release.defaultProfileWithUxpTools}`,
|
||||
);
|
||||
expect(landingProduct).toContain(
|
||||
`/releases/download/v${release.version}/premiere-pro-mcp-${release.version}.mcpb`,
|
||||
);
|
||||
expect(landingProduct).toContain(
|
||||
`/releases/download/v${release.version}/MCPBridgeCEP.zxp`,
|
||||
);
|
||||
expect(landingProduct).toContain(`/releases/tag/v${release.version}`);
|
||||
expect(readme).toContain(`Latest release: ${release.version}`);
|
||||
expect(readme).toContain(`registers ${release.coreTools} tools, filtered by authority profile`);
|
||||
expect(read("landing/app/changelog/page.tsx")).toContain(
|
||||
`version: "${release.version}"`,
|
||||
);
|
||||
expect(supportedActions).toContain(
|
||||
`| Registered core actions | ${release.coreTools} |`,
|
||||
);
|
||||
expect(supportedActions).toContain(
|
||||
`| Default-profile core actions | ${release.defaultProfileTools} |`,
|
||||
);
|
||||
expect(supportedActions).toContain(
|
||||
`| Authenticated UXP additions | ${release.uxpAdditionalTools} |`,
|
||||
);
|
||||
expect(supportedActions).toContain(
|
||||
`| Default profile with UXP | ${release.defaultProfileWithUxpTools} |`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps computed tool-count relationships explicit", () => {
|
||||
expect(release.defaultProfileTools + release.uxpAdditionalTools).toBe(
|
||||
release.defaultProfileWithUxpTools,
|
||||
);
|
||||
expect(release.coreTools - release.defaultProfileTools).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps the CLI help count aligned with the default profile", () => {
|
||||
expect(read("src/index.ts")).toContain(
|
||||
`(${release.defaultProfileTools} default-profile tools)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the generated public product manifest aligned with current release metadata", () => {
|
||||
const manifest = readJson("public-product-manifest.json");
|
||||
expect(manifest.schemaVersion).toBe("premiere-pro-mcp.public-product.v1");
|
||||
expect(manifest.product.version).toBe(release.version);
|
||||
expect(manifest.product.mcpName).toBe(readJson("package.json").mcpName);
|
||||
expect(manifest.capabilitySurface).toMatchObject({
|
||||
registeredCoreTools: release.coreTools,
|
||||
defaultProfileTools: release.defaultProfileTools,
|
||||
authenticatedUxpAdditions: release.uxpAdditionalTools,
|
||||
defaultProfileWithUxp: release.defaultProfileWithUxpTools,
|
||||
guidedWorkflows: release.guidedWorkflows,
|
||||
});
|
||||
expect(manifest.proofKit.status).toBe("runbook_and_redacted_template_only");
|
||||
expect(manifest.proofKit.video).toBeNull();
|
||||
expect(manifest.workflows.map((workflow: { id: string }) => workflow.id)).toEqual([
|
||||
"safe-project-intake",
|
||||
"transcript-backed-rough-cut",
|
||||
"caption-review",
|
||||
"verified-delivery",
|
||||
]);
|
||||
});
|
||||
});
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (path: string) => readFileSync(join(root, path), "utf8");
|
||||
|
||||
describe("npm release package verification", () => {
|
||||
it("checks the actual tarball and an isolated CLI installation before publication", () => {
|
||||
const packageJson = JSON.parse(read("package.json"));
|
||||
const verifier = read("scripts/verify-npm-package.mjs");
|
||||
|
||||
expect(packageJson.scripts["pack:check"]).toBe("node scripts/verify-npm-package.mjs");
|
||||
expect(verifier).toContain("package/docs/premiere-surface-registry.md");
|
||||
expect(verifier).toContain("package/docs/adobe-beta-aaf-export-options-drift.md");
|
||||
expect(verifier).toContain("package/docs/adobe-beta-project-options-drift.md");
|
||||
expect(verifier).toContain("package/docs/adobe-beta-transition-options-drift.md");
|
||||
expect(verifier).toContain("package/docs/adobe-beta-rectf-drift.md");
|
||||
expect(verifier).toContain("package/docs/adobe-beta-color-drift.md");
|
||||
expect(verifier).toContain("package/docs/adobe-beta-pointf-drift.md");
|
||||
expect(verifier).toContain("package/docs/adobe-beta-guid-drift.md");
|
||||
expect(verifier).toContain("package/docs/adobe-beta-frame-rate-drift.md");
|
||||
expect(verifier).toContain("package/docs/adobe-beta-tick-time-drift.md");
|
||||
expect(verifier).toContain("package/docs/adobe-beta-c2pa-drift.md");
|
||||
expect(verifier).toContain("package/docs/adobe-beta-media-drift.md");
|
||||
expect(verifier).toContain("package/docs/adobe-beta-media-manager-drift.md");
|
||||
expect(verifier).toContain("package/docs/adobe-beta-transcript-drift.md");
|
||||
expect(verifier).toContain("package/docs/adobe-beta-work-area-drift.md");
|
||||
expect(verifier).toContain("package/docs/uxp-hybrid-benchmark.md");
|
||||
expect(verifier).toContain("package/docs/uxp-hybrid-addon-receipt.md");
|
||||
expect(verifier).toContain("package/docs/uxp-hybrid-ccx-receipt.md");
|
||||
expect(verifier).toContain("package/dist/resources/premiere-surface-registry.json");
|
||||
expect(verifier).toContain("package/dist/resources/adobe-beta-aaf-export-options-drift.json");
|
||||
expect(verifier).toContain("package/dist/resources/adobe-beta-project-options-drift.json");
|
||||
expect(verifier).toContain("package/dist/resources/adobe-beta-transition-options-drift.json");
|
||||
expect(verifier).toContain("package/dist/resources/adobe-beta-rectf-drift.json");
|
||||
expect(verifier).toContain("package/dist/resources/adobe-beta-color-drift.json");
|
||||
expect(verifier).toContain("package/dist/resources/adobe-beta-pointf-drift.json");
|
||||
expect(verifier).toContain("package/dist/resources/adobe-beta-guid-drift.json");
|
||||
expect(verifier).toContain("package/dist/resources/adobe-beta-frame-rate-drift.json");
|
||||
expect(verifier).toContain("package/dist/resources/adobe-beta-tick-time-drift.json");
|
||||
expect(verifier).toContain("package/dist/resources/adobe-beta-c2pa-drift.json");
|
||||
expect(verifier).toContain("package/dist/resources/adobe-beta-media-drift.json");
|
||||
expect(verifier).toContain("package/dist/resources/adobe-beta-media-manager-drift.json");
|
||||
expect(verifier).toContain("package/dist/resources/adobe-beta-transcript-drift.json");
|
||||
expect(verifier).toContain("package/dist/resources/adobe-beta-work-area-drift.json");
|
||||
expect(verifier).toContain("installedRegistry.integrationSurfaces");
|
||||
expect(verifier).toContain("registry references a missing inventory artifact");
|
||||
expect(verifier).toContain('"package/dist/index.js"');
|
||||
expect(verifier).toContain('"package/cep-plugin/CSXS/manifest.xml"');
|
||||
expect(verifier).toContain('"package/uxp-plugin/manifest.json"');
|
||||
expect(verifier).toContain('"--ignore-scripts"');
|
||||
expect(verifier).toContain('"--pack-destination"');
|
||||
expect(verifier).toContain("Object.values(packed)");
|
||||
expect(verifier).toContain('installedCli, "--help"');
|
||||
expect(verifier).toContain("process.env.npm_execpath");
|
||||
expect(verifier).toContain('"lib", "node_modules", "npm"');
|
||||
});
|
||||
|
||||
it("requires provenance and package verification in every npm publish path", () => {
|
||||
const publish = read(".github/workflows/npm-publish.yml");
|
||||
const crossPlatform = read(".github/workflows/cross-platform.yml");
|
||||
|
||||
expect(publish).toContain("npm publish --provenance --access public");
|
||||
expect(publish).toContain("npm run pack:check");
|
||||
expect(publish).toContain("npm run premiere:docs-inventory:check");
|
||||
expect(publish.indexOf("npm run premiere:docs-inventory:check"))
|
||||
.toBeLessThan(publish.indexOf("npm run build"));
|
||||
expect(crossPlatform).toContain("npm run pack:check");
|
||||
});
|
||||
|
||||
it("validates the Claude bundle against its declared production dependencies", () => {
|
||||
const validator = read("scripts/validate-distribution.mjs");
|
||||
|
||||
expect(validator).toContain("Object.keys(packageJson.dependencies ?? {})");
|
||||
expect(validator).toContain("node_modules\", dependency, \"package.json");
|
||||
expect(validator).not.toContain('"@modelcontextprotocol", "sdk"');
|
||||
});
|
||||
});
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (path: string) => readFileSync(join(root, path), "utf8");
|
||||
const release = JSON.parse(read("release-metadata.json"));
|
||||
|
||||
function verify(tag?: string) {
|
||||
return spawnSync(process.execPath, ["scripts/verify-release-tag.mjs"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, RELEASE_TAG: tag ?? "" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("release tag verification", () => {
|
||||
it("accepts only the exact canonical tag and aligned distributable manifests", () => {
|
||||
expect(verify(`v${release.version}`)).toMatchObject({
|
||||
status: 0,
|
||||
stdout: expect.stringContaining(`v${release.version}`),
|
||||
});
|
||||
expect(verify(`v${release.version}.1`)).toMatchObject({
|
||||
status: 1,
|
||||
stderr: expect.stringContaining("Release tag must be exactly"),
|
||||
});
|
||||
expect(verify()).toMatchObject({
|
||||
status: 1,
|
||||
stderr: expect.stringContaining("Release tag must be exactly"),
|
||||
});
|
||||
});
|
||||
|
||||
it("gates every release attachment workflow on the matching release tag", () => {
|
||||
for (const workflow of [
|
||||
".github/workflows/cep-release.yml",
|
||||
".github/workflows/uxp-package.yml",
|
||||
".github/workflows/claude-desktop-bundle.yml",
|
||||
]) {
|
||||
const source = read(workflow);
|
||||
expect(source).toContain("npm run check");
|
||||
expect(source).toContain("RELEASE_TAG: ${{ github.event.release.tag_name }}");
|
||||
expect(source).toContain("node scripts/verify-release-tag.mjs");
|
||||
}
|
||||
});
|
||||
});
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
// We need to test jsonSchemaToInputSchema which is not exported directly.
|
||||
// We'll test it indirectly by importing createServer with mocked tool modules
|
||||
// that exercise various parameter types.
|
||||
|
||||
vi.mock("../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
sendRawCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
getTempDir: vi.fn().mockReturnValue("/tmp/test"),
|
||||
cleanupTempDir: vi.fn(),
|
||||
}));
|
||||
|
||||
// Create a mock module with all parameter types to exercise the schema adapter.
|
||||
const ALL_TYPES_TOOL = {
|
||||
test_all_types: {
|
||||
description: "Tests all parameter types",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
str_param: { type: "string", description: "A string" },
|
||||
num_param: { type: "number", description: "A number" },
|
||||
bool_param: { type: "boolean", description: "A boolean" },
|
||||
arr_param: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "An array",
|
||||
},
|
||||
obj_param: { type: "object", description: "An object" },
|
||||
enum_param: {
|
||||
type: "string",
|
||||
enum: ["a", "b", "c"],
|
||||
description: "An enum",
|
||||
},
|
||||
optional_str: { type: "string", description: "Optional string" },
|
||||
},
|
||||
required: ["str_param", "num_param"],
|
||||
},
|
||||
handler: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
},
|
||||
test_empty_params: {
|
||||
description: "No parameters",
|
||||
parameters: {},
|
||||
handler: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
},
|
||||
test_no_required: {
|
||||
description: "All optional",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Optional name" },
|
||||
},
|
||||
},
|
||||
handler: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../src/tools/discovery.js", () => ({
|
||||
getDiscoveryTools: () => ALL_TYPES_TOOL,
|
||||
}));
|
||||
vi.mock("../src/tools/project.js", () => ({ getProjectTools: () => ({}) }));
|
||||
vi.mock("../src/tools/media.js", () => ({ getMediaTools: () => ({}) }));
|
||||
vi.mock("../src/tools/sequence.js", () => ({ getSequenceTools: () => ({}) }));
|
||||
vi.mock("../src/tools/timeline.js", () => ({ getTimelineTools: () => ({}) }));
|
||||
vi.mock("../src/tools/effects.js", () => ({ getEffectsTools: () => ({}) }));
|
||||
vi.mock("../src/tools/transitions.js", () => ({
|
||||
getTransitionsTools: () => ({}),
|
||||
}));
|
||||
vi.mock("../src/tools/audio.js", () => ({ getAudioTools: () => ({}) }));
|
||||
vi.mock("../src/tools/text.js", () => ({ getTextTools: () => ({}) }));
|
||||
vi.mock("../src/tools/markers.js", () => ({ getMarkerTools: () => ({}) }));
|
||||
vi.mock("../src/tools/tracks.js", () => ({ getTrackTools: () => ({}) }));
|
||||
vi.mock("../src/tools/playhead.js", () => ({ getPlayheadTools: () => ({}) }));
|
||||
vi.mock("../src/tools/metadata.js", () => ({ getMetadataTools: () => ({}) }));
|
||||
vi.mock("../src/tools/export.js", () => ({ getExportTools: () => ({}) }));
|
||||
vi.mock("../src/tools/media-analysis.js", () => ({ getMediaAnalysisTools: () => ({}) }));
|
||||
vi.mock("../src/tools/interchange-analysis.js", () => ({ getInterchangeAnalysisTools: () => ({}) }));
|
||||
vi.mock("../src/tools/advanced.js", () => ({ getAdvancedTools: () => ({}) }));
|
||||
vi.mock("../src/tools/keyframes.js", () => ({ getKeyframeTools: () => ({}) }));
|
||||
vi.mock("../src/tools/scripting.js", () => ({ getScriptingTools: () => ({}) }));
|
||||
vi.mock("../src/tools/inspection.js", () => ({
|
||||
getInspectionTools: () => ({}),
|
||||
}));
|
||||
vi.mock("../src/tools/selection.js", () => ({ getSelectionTools: () => ({}) }));
|
||||
vi.mock("../src/tools/clipboard.js", () => ({ getClipboardTools: () => ({}) }));
|
||||
vi.mock("../src/tools/source-monitor.js", () => ({
|
||||
getSourceMonitorTools: () => ({}),
|
||||
}));
|
||||
vi.mock("../src/tools/track-targeting.js", () => ({
|
||||
getTrackTargetingTools: () => ({}),
|
||||
}));
|
||||
vi.mock("../src/tools/utility.js", () => ({ getUtilityTools: () => ({}) }));
|
||||
vi.mock("../src/tools/health.js", () => ({ getHealthTools: () => ({}) }));
|
||||
vi.mock("../src/tools/workspace.js", () => ({ getWorkspaceTools: () => ({}) }));
|
||||
vi.mock("../src/tools/captions.js", () => ({ getCaptionTools: () => ({}) }));
|
||||
vi.mock("../src/tools/playback.js", () => ({ getPlaybackTools: () => ({}) }));
|
||||
vi.mock("../src/tools/project-manager.js", () => ({
|
||||
getProjectManagerTools: () => ({}),
|
||||
}));
|
||||
vi.mock("../src/resources/extendscript-reference.js", () => ({
|
||||
EXTENDSCRIPT_REFERENCE: "mock",
|
||||
}));
|
||||
|
||||
import { createServer } from "../src/server.js";
|
||||
|
||||
describe("Schema registration (jsonSchemaToInputSchema)", () => {
|
||||
it("registers tools with all parameter types without error", () => {
|
||||
// If the schema conversion fails, createServer will throw
|
||||
expect(() => createServer({})).not.toThrow();
|
||||
});
|
||||
|
||||
it("handles string parameters", () => {
|
||||
const server = createServer({});
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles number parameters", () => {
|
||||
const server = createServer({});
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles boolean parameters", () => {
|
||||
const server = createServer({});
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles array parameters", () => {
|
||||
const server = createServer({});
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles object parameters", () => {
|
||||
const server = createServer({});
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles enum parameters", () => {
|
||||
const server = createServer({});
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles empty parameters", () => {
|
||||
const server = createServer({});
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles all-optional parameters", () => {
|
||||
const server = createServer({});
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Server tool registration callback", () => {
|
||||
it("returns text content on successful tool call", async () => {
|
||||
// The server wraps tool handlers in a callback that formats results.
|
||||
// We test this by verifying the server creates successfully.
|
||||
// (The actual MCP protocol callback is internal to the SDK,
|
||||
// but we validated handler return values in tool-modules.test.ts)
|
||||
const server = createServer({});
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
it("registers premiere-instructions resource", () => {
|
||||
// Resource registration doesn't throw if params are valid
|
||||
const server = createServer({});
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
it("registers extendscript-reference resource", () => {
|
||||
const server = createServer({});
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,599 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { capabilitiesForToolInvocation, capabilityForTool, guardToolHandler, isToolPermitted, resolveCapabilities } from "../src/security/capabilities.js";
|
||||
import { buildPlatformCapabilityReport } from "../src/platform-capabilities.js";
|
||||
import {
|
||||
buildToolCapabilityReport,
|
||||
deriveToolOperationalCapability,
|
||||
} from "../src/tool-capability-report.js";
|
||||
|
||||
describe("capability profiles", () => {
|
||||
it("fails closed for unsafe scripting by default", async () => {
|
||||
const handler = vi.fn(async () => "ok");
|
||||
const guarded = guardToolHandler("execute_extendscript", handler, resolveCapabilities(undefined), () => "op-1");
|
||||
await expect(guarded({})).rejects.toMatchObject({ code: "CAPABILITY_DENIED", operationId: "op-1" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("permits unsafe scripting only when explicitly enabled", async () => {
|
||||
const handler = vi.fn(async () => "ok");
|
||||
const guarded = guardToolHandler("send_raw_script", handler, resolveCapabilities("inspect,unsafe-script"));
|
||||
await expect(guarded({})).resolves.toBe("ok");
|
||||
});
|
||||
|
||||
it("rejects unknown capabilities instead of silently widening access", () => {
|
||||
expect(() => resolveCapabilities("inspect,admin")).toThrow("Unknown Premiere MCP capability");
|
||||
});
|
||||
|
||||
it("classifies sensitive tools", () => {
|
||||
expect(capabilityForTool("execute_extendscript")).toBe("unsafe-script");
|
||||
expect(capabilityForTool("evaluate_expression")).toBe("unsafe-script");
|
||||
expect(capabilityForTool("export_sequence")).toBe("export");
|
||||
expect(capabilityForTool("export_sequence_review_frames")).toBe("export");
|
||||
expect(capabilityForTool("export_sequence_marker_review_frames")).toBe("export");
|
||||
expect(capabilityForTool("capture_frame")).toBe("export");
|
||||
expect(capabilityForTool("validate_export_preset")).toBe("export");
|
||||
expect(capabilityForTool("verify_delivery_file")).toBe("filesystem");
|
||||
expect(capabilityForTool("verify_delivery_conformance")).toBe("filesystem");
|
||||
expect(capabilityForTool("create_project_backup")).toBe("filesystem");
|
||||
expect(capabilityForTool("analyze_loudness")).toBe("filesystem");
|
||||
expect(capabilityForTool("detect_beats")).toBe("filesystem");
|
||||
expect(capabilityForTool("detect_motion_peaks")).toBe("filesystem");
|
||||
expect(capabilityForTool("read_video_scopes")).toBe("filesystem");
|
||||
expect(capabilityForTool("plan_shot_match")).toBe("filesystem");
|
||||
expect(capabilityForTool("analyze_video_qc")).toBe("filesystem");
|
||||
expect(capabilityForTool("detect_source_scene_changes")).toBe("filesystem");
|
||||
expect(capabilityForTool("normalize_loudness_file")).toBe("filesystem");
|
||||
expect(capabilityForTool("import_media")).toBe("filesystem");
|
||||
expect(capabilityForTool("manage_proxy_ingest_uxp")).toBe("edit");
|
||||
expect(capabilityForTool("audition_source_monitor_uxp")).toBe("edit");
|
||||
expect(capabilityForTool("relink_offline_media_uxp")).toBe("filesystem");
|
||||
expect(capabilityForTool("import_project_media_uxp")).toBe("filesystem");
|
||||
expect(capabilityForTool("edit_timeline_uxp")).toBe("edit");
|
||||
expect(capabilityForTool("encode_media_uxp")).toBe("export");
|
||||
expect(capabilityForTool("inspect_project_selection_uxp")).toBe("inspect");
|
||||
expect(capabilityForTool("manage_markers_uxp")).toBe("edit");
|
||||
expect(capabilityForTool("list_markers_uxp")).toBe("inspect");
|
||||
expect(capabilityForTool("get_project_info")).toBe("inspect");
|
||||
expect(capabilityForTool("preview_transcript_edit_uxp")).toBe("inspect");
|
||||
expect(capabilityForTool("plan_transcript_rough_cut_uxp")).toBe("inspect");
|
||||
expect(capabilityForTool("create_context_edit_plan")).toBe("inspect");
|
||||
expect(capabilityForTool("search_project_context")).toBe("inspect");
|
||||
expect(capabilityForTool("ping")).toBe("inspect");
|
||||
expect(capabilityForTool("trim_clip")).toBe("edit");
|
||||
});
|
||||
|
||||
it("enforces inspect and edit profiles instead of only guarding unsafe tools", async () => {
|
||||
const handler = vi.fn(async () => "ok");
|
||||
await expect(
|
||||
guardToolHandler("get_project_info", handler, resolveCapabilities("edit"), () => "inspect-1")({}),
|
||||
).rejects.toMatchObject({ code: "CAPABILITY_DENIED", capability: "inspect" });
|
||||
await expect(
|
||||
guardToolHandler("trim_clip", handler, resolveCapabilities("inspect"), () => "edit-1")({}),
|
||||
).rejects.toMatchObject({ code: "CAPABILITY_DENIED", capability: "edit" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enforces every capability required by consolidated UXP actions", async () => {
|
||||
const handler = vi.fn(async () => "ok");
|
||||
expect(capabilitiesForToolInvocation("manage_proxy_ingest_uxp", { action: "attach_proxy" }))
|
||||
.toEqual(["edit", "filesystem"]);
|
||||
expect(capabilitiesForToolInvocation("manage_proxy_ingest_uxp", { action: "inspect_proxy" }))
|
||||
.toEqual(["inspect"]);
|
||||
expect(capabilitiesForToolInvocation("manage_project_context", { action: "capture" }))
|
||||
.toEqual(["inspect", "filesystem"]);
|
||||
expect(capabilitiesForToolInvocation("manage_project_context", { action: "status" }))
|
||||
.toEqual(["inspect"]);
|
||||
expect(capabilitiesForToolInvocation("manage_project_context", { action: "clear" }))
|
||||
.toEqual(["filesystem"]);
|
||||
|
||||
await expect(
|
||||
guardToolHandler("manage_proxy_ingest_uxp", handler, resolveCapabilities("filesystem"), () => "proxy-edit")({ action: "set_ingest" }),
|
||||
).rejects.toMatchObject({ code: "CAPABILITY_DENIED", capability: "edit", operationId: "proxy-edit" });
|
||||
await expect(
|
||||
guardToolHandler("manage_proxy_ingest_uxp", handler, resolveCapabilities("edit"), () => "proxy-file")({ action: "attach_proxy" }),
|
||||
).rejects.toMatchObject({ code: "CAPABILITY_DENIED", capability: "filesystem", operationId: "proxy-file" });
|
||||
await expect(
|
||||
guardToolHandler("audition_source_monitor_uxp", handler, resolveCapabilities("filesystem"), () => "monitor-edit")({ action: "play" }),
|
||||
).rejects.toMatchObject({ code: "CAPABILITY_DENIED", capability: "edit" });
|
||||
await expect(
|
||||
guardToolHandler("edit_timeline_uxp", handler, resolveCapabilities("filesystem"), () => "timeline-edit")({ action: "insert" }),
|
||||
).rejects.toMatchObject({ code: "CAPABILITY_DENIED", capability: "edit" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires all authority dimensions for guarded MOGRT authoring", async () => {
|
||||
const handler = vi.fn(async () => "ok");
|
||||
expect(capabilitiesForToolInvocation("verify_after_effects_connection", {})).toEqual(["inspect"]);
|
||||
expect(capabilitiesForToolInvocation("preview_mogrt_recipe", {})).toEqual(["inspect", "filesystem"]);
|
||||
expect(capabilitiesForToolInvocation("create_mogrt_recipe", {})).toEqual(["edit", "export", "filesystem"]);
|
||||
expect(capabilitiesForToolInvocation("verify_mogrt_artifact", {})).toEqual(["inspect", "filesystem"]);
|
||||
expect(capabilitiesForToolInvocation("preview_mogrt_batch", {})).toEqual(["inspect", "filesystem"]);
|
||||
expect(capabilitiesForToolInvocation("create_mogrt_batch", {})).toEqual(["edit", "export", "filesystem"]);
|
||||
expect(capabilitiesForToolInvocation("publish_mogrt_to_library", {})).toEqual(["filesystem"]);
|
||||
expect(capabilitiesForToolInvocation("enqueue_after_effects_render", {})).toEqual(["edit", "export", "filesystem"]);
|
||||
expect(capabilitiesForToolInvocation("apply_mogrt_premiere_handoff", {})).toEqual(["edit", "filesystem"]);
|
||||
expect(isToolPermitted("create_mogrt_batch", resolveCapabilities("inspect,edit,filesystem"))).toBe(false);
|
||||
expect(isToolPermitted("enqueue_after_effects_render", resolveCapabilities("edit,export,filesystem"))).toBe(true);
|
||||
|
||||
await expect(
|
||||
guardToolHandler("create_mogrt_recipe", handler, resolveCapabilities("edit,export"), () => "mogrt-filesystem")({}),
|
||||
).rejects.toMatchObject({ code: "CAPABILITY_DENIED", capability: "filesystem", operationId: "mogrt-filesystem" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists sequence-range inspection for inspect authority but requires edit to update", async () => {
|
||||
const handler = vi.fn(async () => "ok");
|
||||
const inspectOnly = resolveCapabilities("inspect");
|
||||
expect(isToolPermitted("manage_sequence_range_uxp", inspectOnly)).toBe(true);
|
||||
expect(capabilitiesForToolInvocation("manage_sequence_range_uxp", { action: "inspect" })).toEqual(["inspect"]);
|
||||
expect(capabilitiesForToolInvocation("manage_sequence_range_uxp", { action: "update" })).toEqual(["edit"]);
|
||||
|
||||
await expect(
|
||||
guardToolHandler("manage_sequence_range_uxp", handler, inspectOnly, () => "range-inspect")({ action: "inspect" }),
|
||||
).resolves.toBe("ok");
|
||||
await expect(
|
||||
guardToolHandler("manage_sequence_range_uxp", handler, inspectOnly, () => "range-update")({ action: "update" }),
|
||||
).rejects.toMatchObject({ code: "CAPABILITY_DENIED", capability: "edit", operationId: "range-update" });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lists sequence-playhead inspection for inspect authority but requires edit to set", async () => {
|
||||
const handler = vi.fn(async () => "ok");
|
||||
const inspectOnly = resolveCapabilities("inspect");
|
||||
expect(isToolPermitted("manage_sequence_playhead_uxp", inspectOnly)).toBe(true);
|
||||
expect(capabilitiesForToolInvocation("manage_sequence_playhead_uxp", { action: "inspect" })).toEqual(["inspect"]);
|
||||
expect(capabilitiesForToolInvocation("manage_sequence_playhead_uxp", { action: "set" })).toEqual(["edit"]);
|
||||
|
||||
await expect(
|
||||
guardToolHandler("manage_sequence_playhead_uxp", handler, inspectOnly, () => "playhead-inspect")({ action: "inspect" }),
|
||||
).resolves.toBe("ok");
|
||||
await expect(
|
||||
guardToolHandler("manage_sequence_playhead_uxp", handler, inspectOnly, () => "playhead-set")({ action: "set" }),
|
||||
).rejects.toMatchObject({ code: "CAPABILITY_DENIED", capability: "edit", operationId: "playhead-set" });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lists app-preference inspection for inspect authority but requires edit to set", async () => {
|
||||
const handler = vi.fn(async () => "ok");
|
||||
const inspectOnly = resolveCapabilities("inspect");
|
||||
expect(isToolPermitted("manage_app_preferences_uxp", inspectOnly)).toBe(true);
|
||||
expect(capabilitiesForToolInvocation("manage_app_preferences_uxp", { action: "inspect" })).toEqual(["inspect"]);
|
||||
expect(capabilitiesForToolInvocation("manage_app_preferences_uxp", { action: "set" })).toEqual(["edit"]);
|
||||
|
||||
await expect(
|
||||
guardToolHandler("manage_app_preferences_uxp", handler, inspectOnly, () => "preference-inspect")({ action: "inspect" }),
|
||||
).resolves.toBe("ok");
|
||||
await expect(
|
||||
guardToolHandler("manage_app_preferences_uxp", handler, inspectOnly, () => "preference-set")({ action: "set" }),
|
||||
).rejects.toMatchObject({ code: "CAPABILITY_DENIED", capability: "edit", operationId: "preference-set" });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lists source-label inspection for inspect authority but requires edit to update", async () => {
|
||||
const handler = vi.fn(async () => "ok");
|
||||
const inspectOnly = resolveCapabilities("inspect");
|
||||
expect(isToolPermitted("manage_timeline_source_label_uxp", inspectOnly)).toBe(true);
|
||||
expect(capabilitiesForToolInvocation("manage_timeline_source_label_uxp", { action: "inspect" })).toEqual(["inspect"]);
|
||||
expect(capabilitiesForToolInvocation("manage_timeline_source_label_uxp", { action: "update" })).toEqual(["edit"]);
|
||||
|
||||
await expect(
|
||||
guardToolHandler("manage_timeline_source_label_uxp", handler, inspectOnly, () => "source-label-inspect")({ action: "inspect" }),
|
||||
).resolves.toBe("ok");
|
||||
await expect(
|
||||
guardToolHandler("manage_timeline_source_label_uxp", handler, inspectOnly, () => "source-label-update")({ action: "update" }),
|
||||
).rejects.toMatchObject({ code: "CAPABILITY_DENIED", capability: "edit", operationId: "source-label-update" });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lists and invokes native sequence-timing inspection with inspect authority only", async () => {
|
||||
const handler = vi.fn(async () => "ok");
|
||||
const inspectOnly = resolveCapabilities("inspect");
|
||||
expect(isToolPermitted("inspect_sequence_timing_uxp", inspectOnly)).toBe(true);
|
||||
expect(capabilitiesForToolInvocation("inspect_sequence_timing_uxp", {})).toEqual(["inspect"]);
|
||||
expect(isToolPermitted("inspect_frame_alignment_uxp", inspectOnly)).toBe(true);
|
||||
expect(capabilitiesForToolInvocation("inspect_frame_alignment_uxp", { action: "align", frame_rate: 24, seconds: 1 })).toEqual(["inspect"]);
|
||||
expect(capabilitiesForToolInvocation("inspect_project_insertion_bin_uxp", {})).toEqual(["inspect"]);
|
||||
expect(capabilitiesForToolInvocation("inspect_installed_mogrt_directory_uxp", {})).toEqual(["inspect"]);
|
||||
expect(capabilitiesForToolInvocation("inspect_track_item_identity_uxp", {})).toEqual(["inspect"]);
|
||||
await expect(
|
||||
guardToolHandler("inspect_sequence_timing_uxp", handler, inspectOnly, () => "timing-inspect")({}),
|
||||
).resolves.toBe("ok");
|
||||
await expect(
|
||||
guardToolHandler("inspect_frame_alignment_uxp", handler, inspectOnly, () => "frame-alignment-inspect")({ action: "frame", frame_rate: 24, frame_count: 1 }),
|
||||
).resolves.toBe("ok");
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("withholds and rejects non-undoable empty sequence creation without edit authority", async () => {
|
||||
const handler = vi.fn(async () => "ok");
|
||||
const inspectOnly = resolveCapabilities("inspect");
|
||||
expect(capabilitiesForToolInvocation("create_empty_sequence_uxp", {})).toEqual(["edit"]);
|
||||
expect(isToolPermitted("create_empty_sequence_uxp", inspectOnly)).toBe(false);
|
||||
await expect(
|
||||
guardToolHandler("create_empty_sequence_uxp", handler, inspectOnly, () => "empty-sequence-create")({}),
|
||||
).rejects.toMatchObject({ code: "CAPABILITY_DENIED", capability: "edit", operationId: "empty-sequence-create" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["manage_clip_effects_uxp", "catalog"],
|
||||
["batch_selected_clips_uxp", "inspect"],
|
||||
["manage_timeline_selection_uxp", "inspect"],
|
||||
["manage_timeline_selection_uxp", "inspect_targets"],
|
||||
["manage_proxy_ingest_uxp", "inspect_proxy"],
|
||||
["manage_metadata_uxp", "get"],
|
||||
["inspect_project_panel_metadata_uxp", "panel"],
|
||||
["inspect_project_panel_metadata_uxp", "item_columns"],
|
||||
["manage_project_panel_metadata_uxp", "inspect"],
|
||||
["create_project_metadata_field_uxp", "inspect"],
|
||||
["manage_color_conformance_uxp", "preflight"],
|
||||
["audition_source_monitor_uxp", "state"],
|
||||
["preflight_production_storage_uxp", "preflight"],
|
||||
["inspect_project_selection_uxp", "views"],
|
||||
["manage_markers_uxp", "inspect"],
|
||||
["organize_project_items_uxp", "inspect_bin"],
|
||||
["manage_sequence_settings_uxp", "get"],
|
||||
["manage_sequence_display_format_uxp", "inspect"],
|
||||
["manage_sequence_range_uxp", "inspect"],
|
||||
["manage_sequence_playhead_uxp", "inspect"],
|
||||
["manage_app_preferences_uxp", "inspect"],
|
||||
["manage_timeline_source_label_uxp", "inspect"],
|
||||
["inspect_project_insertion_bin_uxp", undefined],
|
||||
["inspect_installed_mogrt_directory_uxp", undefined],
|
||||
["inspect_sequence_timing_uxp", undefined],
|
||||
["inspect_frame_alignment_uxp", undefined],
|
||||
["calculate_tick_time_uxp", undefined],
|
||||
["inspect_sequence_timing_by_guid_uxp", undefined],
|
||||
["inspect_track_item_identity_uxp", undefined],
|
||||
["automate_effect_parameters_uxp", "inspect"],
|
||||
["automate_effect_parameters_uxp", "inspect_point_value"],
|
||||
["automate_effect_parameters_uxp", "inspect_point_displacement"],
|
||||
["automate_effect_parameters_uxp", "inspect_color_value"],
|
||||
["automate_effect_parameters_uxp", "inspect_keyframe", { keyframe_direction: "nearest" }],
|
||||
["automate_effect_parameters_uxp", "inspect_time_varying"],
|
||||
["transform_track_item_uxp", "inspect"],
|
||||
["manage_sequences_uxp", "inspect"],
|
||||
["encode_media_uxp", "preflight"],
|
||||
])("keeps %s:%s available to inspect-only profiles", (toolName, action, args = {}) => {
|
||||
expect(capabilitiesForToolInvocation(toolName, { action, ...args })).toEqual(["inspect"]);
|
||||
expect(isToolPermitted(toolName, resolveCapabilities("inspect"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["manage_clip_effects_uxp", "add", ["edit"]],
|
||||
["batch_selected_clips_uxp", "remove_effect", ["edit"]],
|
||||
["manage_timeline_selection_uxp", "replace", ["edit"]],
|
||||
["manage_timeline_selection_uxp", "clear", ["edit"]],
|
||||
["manage_metadata_uxp", "update", ["edit"]],
|
||||
["manage_project_panel_metadata_uxp", "update", ["edit"]],
|
||||
["create_project_metadata_field_uxp", "create", ["edit"]],
|
||||
["manage_color_conformance_uxp", "update", ["edit"]],
|
||||
["preflight_production_storage_uxp", "configure_project", ["edit"]],
|
||||
["manage_markers_uxp", "remove", ["edit"]],
|
||||
["manage_markers_uxp", "remove_many", ["edit"]],
|
||||
["organize_project_items_uxp", "move", ["edit"]],
|
||||
["manage_sequence_settings_uxp", "update", ["edit"]],
|
||||
["manage_sequence_display_format_uxp", "update", ["edit"]],
|
||||
["manage_sequence_range_uxp", "update", ["edit"]],
|
||||
["manage_sequence_playhead_uxp", "set", ["edit"]],
|
||||
["manage_app_preferences_uxp", "set", ["edit"]],
|
||||
["manage_timeline_source_label_uxp", "update", ["edit"]],
|
||||
["import_project_media_uxp", "files", ["edit", "filesystem"]],
|
||||
["automate_effect_parameters_uxp", "set_point_value", ["edit"]],
|
||||
["automate_effect_parameters_uxp", "set_color_value", ["edit"]],
|
||||
["automate_effect_parameters_uxp", "add_keyframe", ["edit"]],
|
||||
["automate_effect_parameters_uxp", "set_time_varying", ["edit"]],
|
||||
["transform_track_item_uxp", "update", ["edit"]],
|
||||
["manage_sequences_uxp", "delete", ["edit"]],
|
||||
["encode_media_uxp", "sequence", ["export", "filesystem"]],
|
||||
] as const)("requires exact mutation authority for %s:%s", (toolName, action, required) => {
|
||||
expect(capabilitiesForToolInvocation(toolName, { action })).toEqual(required);
|
||||
});
|
||||
|
||||
it("requires filesystem authority only for preset-based encoder preflight", async () => {
|
||||
expect(capabilitiesForToolInvocation("encode_media_uxp", { action: "preflight" }))
|
||||
.toEqual(["inspect"]);
|
||||
expect(capabilitiesForToolInvocation("encode_media_uxp", { action: "preflight", preset_file: "D:/Approved/h264.epr" }))
|
||||
.toEqual(["inspect", "filesystem"]);
|
||||
|
||||
const handler = vi.fn(async () => "ok");
|
||||
await expect(
|
||||
guardToolHandler("encode_media_uxp", handler, resolveCapabilities("inspect"), () => "preset-preflight")({
|
||||
action: "preflight", preset_file: "D:/Approved/h264.epr",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CAPABILITY_DENIED", capability: "filesystem", operationId: "preset-preflight" });
|
||||
await expect(
|
||||
guardToolHandler("encode_media_uxp", handler, resolveCapabilities("inspect"))({ action: "preflight" }),
|
||||
).resolves.toBe("ok");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["darwin", "macOS"],
|
||||
["win32", "Windows"],
|
||||
] as const)("reports static compatibility for %s", (platform, name) => {
|
||||
const report = buildPlatformCapabilityReport(
|
||||
resolveCapabilities("inspect,edit,export,filesystem"),
|
||||
platform,
|
||||
platform === "win32" ? "C:\\Temp\\premiere-mcp-bridge" : "/tmp/premiere-mcp-bridge",
|
||||
);
|
||||
expect(report.runtime).toMatchObject({ platform, platformName: name, supported: true });
|
||||
expect(report.backends.cep.platforms).toEqual(["macOS", "Windows"]);
|
||||
expect(report.backends.uxp.hostVerificationRequired).toBe(true);
|
||||
expect(report.backends.uxp.commands).toContain("operation.cancel");
|
||||
expect(report.backends.uxp.commands).toEqual(expect.arrayContaining([
|
||||
"sequence.playhead.inspect",
|
||||
"sequence.playhead.set",
|
||||
]));
|
||||
expect(report.backends.uxp.commands).toContain("markers.addBeatGrid");
|
||||
expect(report.backends.uxp.commands).toContain("markers.removeMany");
|
||||
expect(report.backends.uxp.events).toContain("premiere.state.changed");
|
||||
expect(report.backends.uxp.operationSemantics.atomicRollback).toBe(false);
|
||||
expect(report.authority.disabled).toContain("unsafe-script");
|
||||
});
|
||||
|
||||
it("does not claim Premiere host support on Linux", () => {
|
||||
const report = buildPlatformCapabilityReport(resolveCapabilities(undefined), "linux");
|
||||
expect(report.runtime.supported).toBe(false);
|
||||
expect(report.premiere.hostVerificationRequired).toBe(true);
|
||||
});
|
||||
|
||||
it("derives tool operational metadata from catalog definitions and authority", () => {
|
||||
const capabilities = resolveCapabilities("inspect");
|
||||
const qeTool = deriveToolOperationalCapability(
|
||||
"ripple_delete",
|
||||
{ description: "Ripple delete a clip. Uses QE DOM." },
|
||||
capabilities,
|
||||
);
|
||||
expect(qeTool).toMatchObject({
|
||||
backend: "CEP/ExtendScript + QE",
|
||||
backends: ["cep", "extendscript", "qe"],
|
||||
status: "experimental",
|
||||
minimumPremiereVersion: "2020 (QE availability varies by build)",
|
||||
authority: { required: "edit", enabled: false },
|
||||
verificationBoundary: "bridge_response",
|
||||
hostVerificationRequired: true,
|
||||
});
|
||||
expect(qeTool.notes).toContain(
|
||||
"Disabled by the current 'environment' authority profile.",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports local discovery separately from live-host verification", () => {
|
||||
const tool = deriveToolOperationalCapability(
|
||||
"get_capabilities",
|
||||
{ description: "Report static capabilities." },
|
||||
resolveCapabilities(undefined),
|
||||
);
|
||||
expect(tool).toMatchObject({
|
||||
backend: "local",
|
||||
backends: ["local"],
|
||||
status: "supported",
|
||||
minimumPremiereVersion: null,
|
||||
verificationBoundary: "static_metadata_only",
|
||||
hostVerificationRequired: false,
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it("classifies caption inventory with its UXP backend and baseline", () => {
|
||||
const tool = deriveToolOperationalCapability(
|
||||
"inspect_caption_tracks_uxp",
|
||||
{ description: "Inventory native caption tracks on the active sequence." },
|
||||
resolveCapabilities("inspect"),
|
||||
);
|
||||
expect(tool).toMatchObject({
|
||||
backend: "UXP",
|
||||
backends: ["uxp"],
|
||||
minimumPremiereVersion: "25.6",
|
||||
authority: { required: "inspect", enabled: true },
|
||||
verificationBoundary: "host_response",
|
||||
hostVerificationRequired: true,
|
||||
});
|
||||
expect(tool.notes).toEqual([
|
||||
"Runs through the authenticated local UXP bridge using Premiere UXP APIs.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not infer QE usage from a UXP tool description that rejects QE", () => {
|
||||
const tool = deriveToolOperationalCapability(
|
||||
"edit_timeline_uxp",
|
||||
{ description: "Edit through documented UXP APIs without undocumented QE calls." },
|
||||
resolveCapabilities("edit"),
|
||||
);
|
||||
expect(tool).toMatchObject({
|
||||
backend: "UXP",
|
||||
backends: ["uxp"],
|
||||
status: "supported",
|
||||
minimumPremiereVersion: "25.6",
|
||||
});
|
||||
expect(tool.notes).not.toEqual(expect.arrayContaining([
|
||||
expect.stringContaining("undocumented QE DOM"),
|
||||
]));
|
||||
});
|
||||
|
||||
it("represents authenticated UXP-only tool metadata without a CEP fallback claim", () => {
|
||||
const tool = deriveToolOperationalCapability(
|
||||
"inspect_caption_tracks_uxp",
|
||||
{
|
||||
description: "Inspect native caption tracks.",
|
||||
operationalCapability: {
|
||||
backend: "UXP",
|
||||
backends: ["uxp"],
|
||||
minimumPremiereVersion: "25.6",
|
||||
verificationBoundary: "structured_uxp_readback",
|
||||
hostVerificationRequired: true,
|
||||
},
|
||||
},
|
||||
resolveCapabilities("inspect"),
|
||||
);
|
||||
expect(tool).toMatchObject({
|
||||
backend: "UXP",
|
||||
backends: ["uxp"],
|
||||
minimumPremiereVersion: "25.6",
|
||||
verificationBoundary: "structured_uxp_readback",
|
||||
hostVerificationRequired: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a deterministic summary from the registered catalog", () => {
|
||||
const report = buildToolCapabilityReport(
|
||||
{
|
||||
trim_clip: { description: "Trim a clip." },
|
||||
get_project_info: { description: "Read the project." },
|
||||
add_transition: { description: "Add a transition. Uses QE DOM." },
|
||||
},
|
||||
resolveCapabilities("inspect,edit"),
|
||||
);
|
||||
expect(report.generatedFrom).toBe("registered-tool-catalog");
|
||||
expect(report.total).toBe(3);
|
||||
expect(report.byStatus).toEqual({
|
||||
supported: 2,
|
||||
limited: 0,
|
||||
experimental: 1,
|
||||
unsupported: 0,
|
||||
});
|
||||
expect(report.tools.map((tool) => tool.name)).toEqual([
|
||||
"add_transition",
|
||||
"get_project_info",
|
||||
"trim_clip",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"verify_delivery_file",
|
||||
"Verify a delivery file and calculate its checksum.",
|
||||
{
|
||||
backend: "local",
|
||||
backends: ["local"],
|
||||
authority: { required: "filesystem", enabled: true },
|
||||
verificationBoundary: "local_filesystem",
|
||||
hostVerificationRequired: false,
|
||||
minimumPremiereVersion: null,
|
||||
},
|
||||
],
|
||||
[
|
||||
"get_advanced_feature_support",
|
||||
"Report collaboration and AI feature support.",
|
||||
{
|
||||
backend: "local",
|
||||
backends: ["local"],
|
||||
authority: { required: "inspect", enabled: true },
|
||||
verificationBoundary: "static_metadata_only",
|
||||
hostVerificationRequired: false,
|
||||
minimumPremiereVersion: null,
|
||||
},
|
||||
],
|
||||
[
|
||||
"preview_editorial_plan",
|
||||
"Revalidate an editorial plan against saved project context.",
|
||||
{
|
||||
backend: "local",
|
||||
backends: ["local"],
|
||||
authority: { required: "inspect", enabled: true },
|
||||
verificationBoundary: "plan_revalidation",
|
||||
hostVerificationRequired: false,
|
||||
minimumPremiereVersion: null,
|
||||
},
|
||||
],
|
||||
[
|
||||
"validate_export_preset",
|
||||
"Validate an export preset and resolve its output extension.",
|
||||
{
|
||||
backend: "local + CEP/ExtendScript",
|
||||
backends: ["local", "cep", "extendscript"],
|
||||
authority: { required: "export", enabled: true },
|
||||
verificationBoundary: "local_and_host_response",
|
||||
hostVerificationRequired: true,
|
||||
minimumPremiereVersion: "2020",
|
||||
},
|
||||
],
|
||||
])("reports QA-forward boundaries for %s", (name, description, expected) => {
|
||||
const tool = deriveToolOperationalCapability(
|
||||
name,
|
||||
{ description },
|
||||
resolveCapabilities("inspect,export,filesystem"),
|
||||
);
|
||||
expect(tool).toMatchObject({ name, status: "supported", ...expected });
|
||||
});
|
||||
|
||||
it("lets registration metadata override catalog defaults", () => {
|
||||
const tool = deriveToolOperationalCapability(
|
||||
"verify_delivery_file",
|
||||
{
|
||||
description: "Future host-backed delivery verification.",
|
||||
operationalCapability: {
|
||||
backend: "local + CEP/ExtendScript",
|
||||
backends: ["local", "cep", "extendscript"],
|
||||
minimumPremiereVersion: "26.0",
|
||||
hostVerificationRequired: true,
|
||||
verificationBoundary: "local_and_host_response",
|
||||
notes: ["Explicit registration metadata."],
|
||||
},
|
||||
},
|
||||
resolveCapabilities("filesystem"),
|
||||
);
|
||||
expect(tool).toMatchObject({
|
||||
backend: "local + CEP/ExtendScript",
|
||||
backends: ["local", "cep", "extendscript"],
|
||||
minimumPremiereVersion: "26.0",
|
||||
hostVerificationRequired: true,
|
||||
verificationBoundary: "local_and_host_response",
|
||||
notes: ["Explicit registration metadata."],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isToolPermitted", () => {
|
||||
it("withholds unsafe-script tools under the default profile", () => {
|
||||
const config = resolveCapabilities(undefined);
|
||||
expect(isToolPermitted("execute_extendscript", config)).toBe(false);
|
||||
expect(isToolPermitted("evaluate_expression", config)).toBe(false);
|
||||
expect(isToolPermitted("send_raw_script", config)).toBe(false);
|
||||
});
|
||||
|
||||
it("permits unsafe-script tools once the authority is named", () => {
|
||||
const config = resolveCapabilities("inspect,edit,export,filesystem,unsafe-script");
|
||||
expect(isToolPermitted("execute_extendscript", config)).toBe(true);
|
||||
expect(isToolPermitted("evaluate_expression", config)).toBe(true);
|
||||
});
|
||||
|
||||
it("permits ordinary edit tools under the default profile", () => {
|
||||
const config = resolveCapabilities(undefined);
|
||||
expect(isToolPermitted("trim_clip", config)).toBe(true);
|
||||
expect(isToolPermitted("get_project_info", config)).toBe(true);
|
||||
expect(isToolPermitted("export_sequence", config)).toBe(true);
|
||||
});
|
||||
|
||||
it("withholds edit and export tools under an inspect-only profile", () => {
|
||||
const config = resolveCapabilities("inspect");
|
||||
expect(isToolPermitted("trim_clip", config)).toBe(false);
|
||||
expect(isToolPermitted("export_sequence", config)).toBe(false);
|
||||
expect(isToolPermitted("import_media", config)).toBe(false);
|
||||
expect(isToolPermitted("verify_delivery_conformance", config)).toBe(false);
|
||||
expect(isToolPermitted("get_project_info", config)).toBe(true);
|
||||
expect(isToolPermitted("manage_proxy_ingest_uxp", config)).toBe(true);
|
||||
expect(isToolPermitted("audition_source_monitor_uxp", config)).toBe(true);
|
||||
expect(isToolPermitted("edit_timeline_uxp", config)).toBe(false);
|
||||
});
|
||||
|
||||
it("permits delivery conformance verification only with filesystem authority", () => {
|
||||
expect(isToolPermitted("verify_delivery_conformance", resolveCapabilities("inspect"))).toBe(false);
|
||||
expect(isToolPermitted("verify_delivery_conformance", resolveCapabilities("filesystem"))).toBe(true);
|
||||
});
|
||||
|
||||
it("always advertises the diagnostic tools, even under a profile that excludes inspect", () => {
|
||||
// Without this, a too-narrow profile leaves no supported way to ask the
|
||||
// server which authority it actually has.
|
||||
const config = resolveCapabilities("export");
|
||||
expect(isToolPermitted("get_capabilities", config)).toBe(true);
|
||||
expect(isToolPermitted("ping", config)).toBe(true);
|
||||
expect(isToolPermitted("get_project_info", config)).toBe(false);
|
||||
});
|
||||
|
||||
it("agrees with capabilityForTool for every capability in the profile", () => {
|
||||
const config = resolveCapabilities("edit");
|
||||
expect(capabilityForTool("trim_clip")).toBe("edit");
|
||||
expect(isToolPermitted("trim_clip", config)).toBe(true);
|
||||
});
|
||||
});
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { createServer, SERVER_VERSION } from "../src/server.js";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
|
||||
const { getDiscoveryTools } = vi.hoisted(() => ({
|
||||
getDiscoveryTools: vi.fn(() => ({
|
||||
mock_discovery_tool: {
|
||||
description: "A mock discovery tool",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Name param" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
handler: vi.fn().mockResolvedValue({ success: true, data: { found: true } }),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock all tool modules to return simple tool definitions
|
||||
vi.mock("../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
sendRawCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
getTempDir: vi.fn().mockReturnValue("/tmp/test"),
|
||||
cleanupTempDir: vi.fn(),
|
||||
}));
|
||||
|
||||
// We need the real tool modules to verify registration,
|
||||
// but mock sendCommand so handlers don't actually do file I/O
|
||||
vi.mock("../src/tools/discovery.js", () => ({
|
||||
getDiscoveryTools,
|
||||
}));
|
||||
|
||||
vi.mock("../src/tools/project.js", () => ({ getProjectTools: () => ({}) }));
|
||||
vi.mock("../src/tools/media.js", () => ({ getMediaTools: () => ({}) }));
|
||||
vi.mock("../src/tools/sequence.js", () => ({ getSequenceTools: () => ({}) }));
|
||||
vi.mock("../src/tools/timeline.js", () => ({ getTimelineTools: () => ({}) }));
|
||||
vi.mock("../src/tools/effects.js", () => ({ getEffectsTools: () => ({}) }));
|
||||
vi.mock("../src/tools/transitions.js", () => ({ getTransitionsTools: () => ({}) }));
|
||||
vi.mock("../src/tools/audio.js", () => ({ getAudioTools: () => ({}) }));
|
||||
vi.mock("../src/tools/text.js", () => ({ getTextTools: () => ({}) }));
|
||||
vi.mock("../src/tools/markers.js", () => ({ getMarkerTools: () => ({}) }));
|
||||
vi.mock("../src/tools/tracks.js", () => ({ getTrackTools: () => ({}) }));
|
||||
vi.mock("../src/tools/playhead.js", () => ({ getPlayheadTools: () => ({}) }));
|
||||
vi.mock("../src/tools/metadata.js", () => ({ getMetadataTools: () => ({}) }));
|
||||
vi.mock("../src/tools/export.js", () => ({ getExportTools: () => ({}) }));
|
||||
vi.mock("../src/tools/media-analysis.js", () => ({ getMediaAnalysisTools: () => ({}) }));
|
||||
vi.mock("../src/tools/interchange-analysis.js", () => ({ getInterchangeAnalysisTools: () => ({}) }));
|
||||
vi.mock("../src/tools/advanced.js", () => ({ getAdvancedTools: () => ({}) }));
|
||||
vi.mock("../src/tools/keyframes.js", () => ({ getKeyframeTools: () => ({}) }));
|
||||
vi.mock("../src/tools/scripting.js", () => ({ getScriptingTools: () => ({}) }));
|
||||
vi.mock("../src/tools/inspection.js", () => ({ getInspectionTools: () => ({}) }));
|
||||
vi.mock("../src/tools/selection.js", () => ({ getSelectionTools: () => ({}) }));
|
||||
vi.mock("../src/tools/clipboard.js", () => ({ getClipboardTools: () => ({}) }));
|
||||
vi.mock("../src/tools/source-monitor.js", () => ({ getSourceMonitorTools: () => ({}) }));
|
||||
vi.mock("../src/tools/track-targeting.js", () => ({ getTrackTargetingTools: () => ({}) }));
|
||||
vi.mock("../src/tools/utility.js", () => ({ getUtilityTools: () => ({}) }));
|
||||
vi.mock("../src/tools/health.js", () => ({ getHealthTools: () => ({}) }));
|
||||
vi.mock("../src/tools/workspace.js", () => ({ getWorkspaceTools: () => ({}) }));
|
||||
vi.mock("../src/tools/captions.js", () => ({ getCaptionTools: () => ({}) }));
|
||||
vi.mock("../src/tools/playback.js", () => ({ getPlaybackTools: () => ({}) }));
|
||||
vi.mock("../src/tools/project-manager.js", () => ({ getProjectManagerTools: () => ({}) }));
|
||||
vi.mock("../src/resources/extendscript-reference.js", () => ({
|
||||
EXTENDSCRIPT_REFERENCE: "mock reference",
|
||||
}));
|
||||
|
||||
describe("createServer", () => {
|
||||
it("returns an McpServer instance", () => {
|
||||
const server = createServer({});
|
||||
expect(server).toBeDefined();
|
||||
expect(typeof server.registerTool).toBe("function");
|
||||
expect(typeof server.registerResource).toBe("function");
|
||||
expect(typeof server.connect).toBe("function");
|
||||
});
|
||||
|
||||
it("registers tools from all modules", () => {
|
||||
const toolSpy = vi.fn();
|
||||
const originalTool = createServer({}).registerTool;
|
||||
|
||||
// Create a new server and spy on tool registration
|
||||
const server = createServer({});
|
||||
// The server should have been created successfully with registered tools
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not throw with empty bridge options", () => {
|
||||
expect(() => createServer({})).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not throw with custom bridge options", () => {
|
||||
expect(() =>
|
||||
createServer({ tempDir: "/custom/tmp", timeoutMs: 5000 })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("reuses immutable tool definitions for equivalent server configurations", () => {
|
||||
getDiscoveryTools.mockClear();
|
||||
const bridgeOptions = { tempDir: "/tmp/server-static-catalog-cache" };
|
||||
|
||||
createServer(bridgeOptions);
|
||||
createServer(bridgeOptions);
|
||||
|
||||
expect(getDiscoveryTools).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("registers and serves the bounded live context resources", async () => {
|
||||
const server = createServer({});
|
||||
const client = new Client({ name: "live-resource-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
|
||||
try {
|
||||
const resources = await client.listResources();
|
||||
expect(resources.resources.map((resource) => resource.uri)).toEqual(expect.arrayContaining([
|
||||
"premiere://project/info",
|
||||
"premiere://project/sequences",
|
||||
"premiere://project/media",
|
||||
"premiere://project/bins",
|
||||
"premiere://timeline/active",
|
||||
"premiere://effects/available",
|
||||
"premiere://effects/applied",
|
||||
"premiere://transitions/available",
|
||||
"premiere://export/presets",
|
||||
"premiere://project/metadata",
|
||||
]));
|
||||
|
||||
const read = await client.readResource({ uri: "premiere://project/info" });
|
||||
expect(JSON.parse(read.contents[0].text as string)).toMatchObject({
|
||||
ok: true,
|
||||
resource: "premiere://project/info",
|
||||
resourceSchemaVersion: 1,
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("jsonSchemaToInputSchema (tested via createServer)", () => {
|
||||
// We test the schema adapter indirectly by verifying the server
|
||||
// successfully registers tools with different parameter types.
|
||||
// The function is private, so we test through the public API.
|
||||
|
||||
it("handles tools with string parameters", () => {
|
||||
// The mock_discovery_tool has a required string parameter.
|
||||
// If schema conversion fails, createServer would throw.
|
||||
expect(() => createServer({})).not.toThrow();
|
||||
});
|
||||
|
||||
it("handles tools with no parameters", () => {
|
||||
// Many tools (health ping, etc.) have empty parameters.
|
||||
expect(() => createServer({})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SERVER_VERSION", () => {
|
||||
it("matches the version in package.json", () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(process.cwd(), "package.json"), "utf-8"),
|
||||
);
|
||||
expect(SERVER_VERSION).toBe(pkg.version);
|
||||
});
|
||||
|
||||
it("is a concrete semver, never the 'unknown' fallback", () => {
|
||||
expect(SERVER_VERSION).toMatch(/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
});
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../src/bridge/file-bridge.js", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("../src/bridge/file-bridge.js")>();
|
||||
return { ...original, sendCommand: vi.fn(async () => ({ success: true, data: { applied: true } })) };
|
||||
});
|
||||
|
||||
import { sendCommand } from "../src/bridge/file-bridge.js";
|
||||
import { capabilityForTool } from "../src/security/capabilities.js";
|
||||
import {
|
||||
getSpotWorkflowTools,
|
||||
spotWorkflowConfirmationToken,
|
||||
validateSpotWorkflowPlan,
|
||||
} from "../src/tools/spot-workflows.js";
|
||||
import { annotationsForTool } from "../src/workflows/tool-metadata.js";
|
||||
|
||||
const productArgs = {
|
||||
sequence_id: "sequence-1",
|
||||
asset_item_ids: ["item-a", "item-b"],
|
||||
};
|
||||
|
||||
describe("spot workflow plans", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("advertises previews as inspect-only and the apply route as an edit", () => {
|
||||
expect(capabilityForTool("preview_product_spot")).toBe("inspect");
|
||||
expect(annotationsForTool("preview_product_spot").readOnlyHint).toBe(true);
|
||||
expect(capabilityForTool("apply_spot_workflow_plan")).toBe("edit");
|
||||
});
|
||||
|
||||
it("previews the product assembly locally without contacting Premiere", async () => {
|
||||
const tools = getSpotWorkflowTools({}, {
|
||||
capabilities: { capabilities: new Set(["inspect"]), source: "explicit" },
|
||||
operationIdFactory: () => "preview-1",
|
||||
});
|
||||
|
||||
const result = await tools.preview_product_spot.handler(productArgs);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
operationId: "preview-1",
|
||||
applied: false,
|
||||
plan: {
|
||||
workflow: "product_spot",
|
||||
sequence_id: "sequence-1",
|
||||
clip_duration_seconds: 4,
|
||||
motion_style: "alternate",
|
||||
transition: { name: "Cross Dissolve", duration_seconds: 0.5 },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(sendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires edit authority and the exact preview confirmation before applying", async () => {
|
||||
const inspectOnly = getSpotWorkflowTools({}, {
|
||||
capabilities: { capabilities: new Set(["inspect"]), source: "explicit" },
|
||||
auditSink: vi.fn(),
|
||||
operationIdFactory: () => "apply-no-edit",
|
||||
});
|
||||
const preview = await inspectOnly.preview_product_spot.handler(productArgs);
|
||||
const plan = preview.data.plan;
|
||||
|
||||
await expect(inspectOnly.apply_spot_workflow_plan.handler({
|
||||
plan,
|
||||
confirmation_token: preview.data.confirmationToken,
|
||||
})).rejects.toMatchObject({ code: "CAPABILITY_DENIED" });
|
||||
|
||||
const tools = getSpotWorkflowTools({}, {
|
||||
capabilities: { capabilities: new Set(["inspect", "edit"]), source: "explicit" },
|
||||
auditSink: vi.fn(),
|
||||
operationIdFactory: () => "apply-bad-token",
|
||||
});
|
||||
await expect(tools.apply_spot_workflow_plan.handler({ plan, confirmation_token: "different" })).rejects.toThrow("does not match");
|
||||
expect(sendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revalidates the plan and generates a contained, readback-oriented host command", async () => {
|
||||
const tools = getSpotWorkflowTools({}, {
|
||||
capabilities: { capabilities: new Set(["inspect", "edit"]), source: "explicit" },
|
||||
auditSink: vi.fn(),
|
||||
operationIdFactory: () => "apply-1",
|
||||
});
|
||||
const preview = await tools.preview_motion_graphics_demo.handler({
|
||||
sequence_id: "sequence-1",
|
||||
asset_item_ids: ["item-a", "item-b", "item-c"],
|
||||
});
|
||||
const plan = preview.data.plan;
|
||||
|
||||
const result = await tools.apply_spot_workflow_plan.handler({
|
||||
plan,
|
||||
confirmation_token: spotWorkflowConfirmationToken(plan),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ success: true, data: { applied: true, operationId: "apply-1" } });
|
||||
const script = String(vi.mocked(sendCommand).mock.calls[0]?.[0]);
|
||||
expect(script).toContain("Target sequence must be active before applying a spot workflow plan");
|
||||
expect(script).toContain("Target video and audio tracks must be empty");
|
||||
expect(script).toContain("Project item ID was not found exactly");
|
||||
expect(script).toContain("function findPlacedClip(track, itemId, expectedStart)");
|
||||
expect(script).toContain("function trimPlacedClip(clip, targetEnd)");
|
||||
expect(script).toContain("var audioCountBefore = audioTrack.clips.numItems");
|
||||
expect(script).toContain("if (audioCountAfter > audioCountBefore)");
|
||||
expect(script).toContain("did not identify and trim the placed audio item");
|
||||
expect(script).toContain("clip.end = requestedEnd");
|
||||
expect(script).toContain("requestedDurationSeconds");
|
||||
expect(script).toContain("scaleProperty.getValueAtKey");
|
||||
expect(script).toContain("__findClip(placed[cutIndex + 1].nodeId)");
|
||||
expect(script).toContain("__findQeClipByDomClip(qeTrack, placedInfo.clip)");
|
||||
expect(script).toContain("typeof qeClip.addTransition !== \"function\"");
|
||||
expect(script).toContain('qeClip.addTransition(transitionQE, true, String(durationFrames), "0", 0.5, false, true)');
|
||||
expect(script).toContain("foundAtExpectedCut");
|
||||
expect(script).toContain("Math.abs(((transitionStartTicks + transitionEndTicks) / 2) - expectedCutTicks) <= frameTicks / 2");
|
||||
expect(script).not.toContain("qeTrack.addTransition(");
|
||||
expect(script).toContain("renderVerified: false");
|
||||
expect(script).not.toContain("importMedia");
|
||||
expect(script).not.toContain("createNewSequence");
|
||||
});
|
||||
|
||||
it("keeps a MOGRT within an approved workspace and requires filesystem authority at apply time", async () => {
|
||||
const args = {
|
||||
...productArgs,
|
||||
mogrt_path: "D:/Approved/title.mogrt",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
title_track_index: 1,
|
||||
title_start_seconds: 0,
|
||||
};
|
||||
const previewTools = getSpotWorkflowTools({}, {
|
||||
capabilities: { capabilities: new Set(["inspect", "edit"]), source: "explicit" },
|
||||
auditSink: vi.fn(),
|
||||
operationIdFactory: () => "brand-1",
|
||||
fileExists: () => true,
|
||||
});
|
||||
const preview = await previewTools.preview_brand_spot.handler(args);
|
||||
const plan = preview.data.plan;
|
||||
|
||||
await expect(previewTools.apply_spot_workflow_plan.handler({
|
||||
plan,
|
||||
confirmation_token: preview.data.confirmationToken,
|
||||
})).rejects.toMatchObject({ code: "CAPABILITY_DENIED", capability: "filesystem" });
|
||||
expect(sendCommand).not.toHaveBeenCalled();
|
||||
|
||||
const applyTools = getSpotWorkflowTools({}, {
|
||||
capabilities: { capabilities: new Set(["inspect", "edit", "filesystem"]), source: "explicit" },
|
||||
auditSink: vi.fn(),
|
||||
operationIdFactory: () => "brand-2",
|
||||
fileExists: (file) => file === "D:/Approved/title.mogrt",
|
||||
});
|
||||
await applyTools.apply_spot_workflow_plan.handler({
|
||||
plan,
|
||||
confirmation_token: preview.data.confirmationToken,
|
||||
});
|
||||
const script = String(vi.mocked(sendCommand).mock.calls[0]?.[0]);
|
||||
expect(script).toContain("seq.importMGT");
|
||||
expect(script).toContain("MOGRT track-count");
|
||||
});
|
||||
|
||||
it("rejects plans that add an unpreviewed MOGRT or point outside the approved workspace", async () => {
|
||||
await expect(getSpotWorkflowTools({}, { capabilities: { capabilities: new Set(["inspect"]), source: "explicit" } })
|
||||
.preview_brand_spot.handler({
|
||||
...productArgs,
|
||||
mogrt_path: "D:/Elsewhere/title.mogrt",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
})).rejects.toThrow("within approved_workspace_path");
|
||||
|
||||
expect(() => validateSpotWorkflowPlan({
|
||||
schema_version: 1,
|
||||
workflow: "product_spot",
|
||||
sequence_id: "sequence-1",
|
||||
asset_item_ids: ["item-a"],
|
||||
clip_duration_seconds: 4,
|
||||
video_track_index: 0,
|
||||
audio_track_index: 0,
|
||||
motion_style: "alternate",
|
||||
mogrt: {
|
||||
path: "D:/Approved/title.mogrt",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
title_track_index: 1,
|
||||
title_start_seconds: 0,
|
||||
},
|
||||
})).toThrow("only brand_spot");
|
||||
});
|
||||
|
||||
it("validates preview inputs before a workflow token is issued", async () => {
|
||||
const tools = getSpotWorkflowTools({}, {
|
||||
capabilities: { capabilities: new Set(["inspect"]), source: "explicit" },
|
||||
operationIdFactory: () => "invalid-preview",
|
||||
});
|
||||
|
||||
await expect(tools.preview_product_spot.handler({ ...productArgs, unexpected: true } as any)).rejects.toThrow("unsupported field");
|
||||
await expect(tools.preview_product_spot.handler({ ...productArgs, motion_style: "zoom" } as any)).rejects.toThrow("motion_style");
|
||||
await expect(tools.preview_product_spot.handler({ ...productArgs, video_track_index: 33 } as any)).rejects.toThrow("video_track_index");
|
||||
await expect(tools.preview_product_spot.handler({ ...productArgs, clip_duration_seconds: 0 } as any)).rejects.toThrow("clip_duration_seconds");
|
||||
await expect(tools.preview_product_spot.handler({ sequence_id: "sequence-1", asset_item_ids: [] } as any)).rejects.toThrow("asset_item_ids");
|
||||
await expect(tools.preview_brand_spot.handler({
|
||||
...productArgs,
|
||||
mogrt_path: "/approved/title.txt",
|
||||
approved_workspace_path: "/approved",
|
||||
} as any)).rejects.toThrow(".mogrt");
|
||||
await expect(tools.preview_brand_spot.handler({
|
||||
...productArgs,
|
||||
mogrt_path: "relative/title.mogrt",
|
||||
approved_workspace_path: "/approved",
|
||||
} as any)).rejects.toThrow("absolute paths");
|
||||
await expect(tools.preview_brand_spot.handler({
|
||||
...productArgs,
|
||||
mogrt_path: "/approved/title.mogrt",
|
||||
approved_workspace_path: "/approved",
|
||||
title_track_index: 0,
|
||||
} as any)).rejects.toThrow("must differ");
|
||||
});
|
||||
|
||||
it("handles portable paths and rejects stale or malformed confirmed plans", async () => {
|
||||
const previewTools = getSpotWorkflowTools({}, {
|
||||
capabilities: { capabilities: new Set(["inspect", "edit", "filesystem"]), source: "explicit" },
|
||||
operationIdFactory: () => "portable-preview",
|
||||
fileExists: () => false,
|
||||
});
|
||||
const preview = await previewTools.preview_brand_spot.handler({
|
||||
...productArgs,
|
||||
mogrt_path: "/approved/title.mogrt",
|
||||
approved_workspace_path: "/approved",
|
||||
transition_name: "none",
|
||||
motion_style: "none",
|
||||
});
|
||||
expect(preview.data.plan.transition).toBeUndefined();
|
||||
expect(preview.data.changes.motion).toMatchObject({ skipped: true });
|
||||
await expect(previewTools.apply_spot_workflow_plan.handler({
|
||||
plan: preview.data.plan,
|
||||
confirmation_token: preview.data.confirmationToken,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("no longer exists") });
|
||||
|
||||
expect(() => validateSpotWorkflowPlan({
|
||||
schema_version: 2,
|
||||
workflow: "product_spot",
|
||||
})).toThrow("schema_version");
|
||||
expect(() => validateSpotWorkflowPlan({
|
||||
schema_version: 1,
|
||||
workflow: "not-a-workflow",
|
||||
})).toThrow("workflow");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const posthog = vi.hoisted(() => ({ constructor: vi.fn(), capture: vi.fn() }));
|
||||
|
||||
vi.mock("posthog-node", () => ({
|
||||
PostHog: class {
|
||||
constructor(apiKey: string, options: unknown) {
|
||||
posthog.constructor(apiKey, options);
|
||||
}
|
||||
capture = posthog.capture;
|
||||
shutdown = vi.fn(async () => {});
|
||||
},
|
||||
}));
|
||||
|
||||
const keys = [
|
||||
"POSTHOG_API_KEY", "POSTHOG_HOST", "POSTHOG_DISTINCT_ID", "FLY_MACHINE_ID",
|
||||
"POSTHOG_ENVIRONMENT", "NODE_ENV", "FLY_REGION", "PREMIERE_MCP_TRANSPORT",
|
||||
"npm_package_version",
|
||||
] as const;
|
||||
const original = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
for (const key of keys) {
|
||||
const value = original[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
function clearTelemetryEnvironment(): void {
|
||||
for (const key of keys) delete process.env[key];
|
||||
}
|
||||
|
||||
describe("telemetry environment fallbacks", () => {
|
||||
it("uses Fly identity, Node environment, and default host/metadata", async () => {
|
||||
clearTelemetryEnvironment();
|
||||
Object.assign(process.env, {
|
||||
POSTHOG_API_KEY: "key",
|
||||
FLY_MACHINE_ID: "machine-1",
|
||||
NODE_ENV: "development",
|
||||
});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const { getTelemetry } = await import("../src/telemetry.js");
|
||||
getTelemetry().capture("fallbacks");
|
||||
|
||||
expect(posthog.constructor).toHaveBeenCalledWith("key", expect.objectContaining({
|
||||
host: "https://us.i.posthog.com",
|
||||
}));
|
||||
expect(posthog.capture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
distinctId: "machine-1",
|
||||
properties: expect.objectContaining({
|
||||
service_version: "unknown",
|
||||
environment: "development",
|
||||
transport: "unknown",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("generates an anonymous server identity and defaults to production", async () => {
|
||||
clearTelemetryEnvironment();
|
||||
process.env.POSTHOG_API_KEY = "key";
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const { getTelemetry } = await import("../src/telemetry.js");
|
||||
getTelemetry().capture("anonymous");
|
||||
|
||||
expect(posthog.capture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
distinctId: expect.stringMatching(/^server-[0-9a-f-]{36}$/i),
|
||||
properties: expect.objectContaining({ environment: "production" }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const posthog = vi.hoisted(() => ({
|
||||
capture: vi.fn(),
|
||||
shutdown: vi.fn(async () => {}),
|
||||
constructor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("posthog-node", () => ({
|
||||
PostHog: class {
|
||||
constructor(apiKey: string, options: unknown) {
|
||||
posthog.constructor(apiKey, options);
|
||||
}
|
||||
capture = posthog.capture;
|
||||
shutdown = posthog.shutdown;
|
||||
},
|
||||
}));
|
||||
|
||||
const ENV_KEYS = [
|
||||
"POSTHOG_API_KEY", "POSTHOG_HOST", "POSTHOG_DISTINCT_ID", "FLY_MACHINE_ID",
|
||||
"POSTHOG_ENVIRONMENT", "NODE_ENV", "FLY_REGION", "PREMIERE_MCP_TRANSPORT",
|
||||
"npm_package_version",
|
||||
] as const;
|
||||
const original = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = original[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
describe("telemetry", () => {
|
||||
it("is a stable no-op when no API key is configured", async () => {
|
||||
delete process.env.POSTHOG_API_KEY;
|
||||
const { getTelemetry } = await import("../src/telemetry.js");
|
||||
const first = getTelemetry();
|
||||
expect(first.enabled).toBe(false);
|
||||
expect(getTelemetry()).toBe(first);
|
||||
expect(() => first.capture("ignored", { value: 1 })).not.toThrow();
|
||||
await expect(first.shutdown()).resolves.toBeUndefined();
|
||||
expect(posthog.constructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("configures PostHog and merges safe common and event properties", async () => {
|
||||
Object.assign(process.env, {
|
||||
POSTHOG_API_KEY: "test-key",
|
||||
POSTHOG_HOST: "https://example.invalid",
|
||||
POSTHOG_DISTINCT_ID: "installation-1",
|
||||
POSTHOG_ENVIRONMENT: "test",
|
||||
FLY_REGION: "sea",
|
||||
PREMIERE_MCP_TRANSPORT: "stdio",
|
||||
npm_package_version: "9.8.7",
|
||||
});
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const { getTelemetry } = await import("../src/telemetry.js");
|
||||
const telemetry = getTelemetry();
|
||||
telemetry.capture("tool_called", { service: "override", ok: true });
|
||||
|
||||
expect(posthog.constructor).toHaveBeenCalledWith("test-key", {
|
||||
host: "https://example.invalid", flushAt: 1, flushInterval: 10_000,
|
||||
});
|
||||
expect(posthog.capture).toHaveBeenCalledWith({
|
||||
distinctId: "installation-1",
|
||||
event: "tool_called",
|
||||
properties: expect.objectContaining({
|
||||
service: "override", service_version: "9.8.7", environment: "test",
|
||||
region: "sea", transport: "stdio", ok: true, $process_person_profile: false,
|
||||
}),
|
||||
});
|
||||
await telemetry.shutdown();
|
||||
expect(posthog.shutdown).toHaveBeenCalledOnce();
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("telemetry enabled"));
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
it("emits only the bounded activation properties", async () => {
|
||||
const { captureActivationEvent } = await import("../src/telemetry.js");
|
||||
const telemetry = { enabled: true, capture: vi.fn(), shutdown: vi.fn(async () => {}) };
|
||||
captureActivationEvent(telemetry, { backend: "uxp" });
|
||||
expect(telemetry.capture).toHaveBeenCalledOnce();
|
||||
expect(telemetry.capture).toHaveBeenCalledWith("premiere_mcp_activation_completed", {
|
||||
activation_stage: "verified_connection", backend: "uxp",
|
||||
});
|
||||
expect(JSON.stringify(telemetry.capture.mock.calls)).not.toMatch(/prompt|path|token|project|media|argument|result|profile/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { createServer } from "../../src/server.js";
|
||||
import { getUxpTools } from "../../src/tools/uxp.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
type UxpTool = {
|
||||
parameters: {
|
||||
type?: string;
|
||||
properties?: Record<string, Record<string, unknown>>;
|
||||
required?: string[];
|
||||
};
|
||||
handler: (args: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const ADOBE_26_3_TOOLS = [
|
||||
"rename_track_uxp",
|
||||
"create_subclip_uxp",
|
||||
"list_markers_uxp",
|
||||
"set_source_monitor_position_uxp",
|
||||
"inspect_frame_alignment_uxp",
|
||||
"calculate_tick_time_uxp",
|
||||
"manage_sequence_preview_frame_uxp",
|
||||
"inspect_source_media_provenance_uxp",
|
||||
"inspect_source_proxy_uxp",
|
||||
"manage_source_media_overrides_uxp",
|
||||
"inspect_track_item_identity_uxp",
|
||||
"slide_track_item_uxp",
|
||||
"duplicate_track_item_uxp",
|
||||
"ripple_delete_track_item_uxp",
|
||||
"create_empty_sequence_uxp",
|
||||
"audit_object_masks_uxp",
|
||||
"inspect_unique_object_identity_uxp",
|
||||
"inspect_effect_parameter_catalog_uxp",
|
||||
"has_transcript_uxp",
|
||||
"export_aaf_uxp",
|
||||
] as const;
|
||||
|
||||
function catalog(request = vi.fn().mockResolvedValue({ outcome: "verified" })) {
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
return { request, tools: getUxpTools(bridge) as Record<string, UxpTool> };
|
||||
}
|
||||
|
||||
describe("Adobe Premiere 26.3 UXP public MCP catalog", () => {
|
||||
it("registers the sampled capability-gated tools only with a UXP bridge", async () => {
|
||||
const bridge = {
|
||||
request: vi.fn(),
|
||||
getState: vi.fn(() => ({ status: "listening", connected: false })),
|
||||
} as unknown as UxpWebSocketBridge;
|
||||
const server = createServer({}, { uxpBridge: bridge });
|
||||
const client = new Client({ name: "adobe-26-3-catalog-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
try {
|
||||
const listed = await client.listTools();
|
||||
expect(listed.tools.map((tool) => tool.name)).toEqual(
|
||||
expect.arrayContaining(ADOBE_26_3_TOOLS),
|
||||
);
|
||||
expect(listed.tools).toHaveLength(440);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes bounded schemas for Adobe's documented 26.3 APIs", () => {
|
||||
const { tools } = catalog();
|
||||
expect(Object.keys(tools)).toEqual(expect.arrayContaining(ADOBE_26_3_TOOLS));
|
||||
|
||||
expect(tools.rename_track_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
required: ["track_type", "track_index", "name"],
|
||||
properties: {
|
||||
track_type: { type: "string", enum: ["audio", "video", "caption"] },
|
||||
track_index: { type: "integer" },
|
||||
name: { type: "string" },
|
||||
operation_id: { type: "string" },
|
||||
},
|
||||
});
|
||||
expect(tools.create_subclip_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
required: ["name", "start_seconds", "end_seconds"],
|
||||
properties: {
|
||||
project_item_id: { type: "string" },
|
||||
project_item_name: { type: "string" },
|
||||
name: { type: "string" },
|
||||
start_seconds: { type: "number" },
|
||||
end_seconds: { type: "number" },
|
||||
hard_boundaries: { type: "boolean" },
|
||||
take_video: { type: "boolean" },
|
||||
take_audio: { type: "boolean" },
|
||||
operation_id: { type: "string" },
|
||||
},
|
||||
});
|
||||
expect(tools.list_markers_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["sequence", "project_item"] },
|
||||
project_item_id: { type: "string" },
|
||||
project_item_name: { type: "string" },
|
||||
filters: { type: "array", items: { type: "string" } },
|
||||
include_web_links: { type: "boolean" },
|
||||
include_color_values: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
expect(tools.set_source_monitor_position_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
required: ["seconds"],
|
||||
properties: { seconds: { type: "number" }, operation_id: { type: "string" } },
|
||||
});
|
||||
expect(tools.inspect_frame_alignment_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false, required: ["action", "frame_rate"],
|
||||
properties: {
|
||||
action: { type: "string", enum: ["align", "frame"] },
|
||||
frame_rate: { type: "number", minimum: 1, maximum: 240 },
|
||||
seconds: { type: "number", minimum: 0, maximum: 86400 },
|
||||
frame_count: { type: "integer", minimum: 0, maximum: 20736000 },
|
||||
},
|
||||
});
|
||||
expect(tools.calculate_tick_time_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false, required: ["operation", "base_ticks"],
|
||||
properties: {
|
||||
operation: { type: "string", enum: ["add", "subtract", "multiply", "divide"] },
|
||||
base_ticks: { type: "string", pattern: "^(?:0|-?[1-9][0-9]{0,17})$" },
|
||||
operand_ticks: { type: "string", pattern: "^(?:0|-?[1-9][0-9]{0,17})$" },
|
||||
factor: { type: "integer", minimum: -1_000_000, maximum: 1_000_000 },
|
||||
},
|
||||
});
|
||||
expect(tools.manage_sequence_preview_frame_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false, required: ["action", "sequence_id"],
|
||||
properties: {
|
||||
action: { type: "string", enum: ["inspect", "update"] },
|
||||
sequence_id: { type: "string", minLength: 1, maxLength: 128 },
|
||||
preview_width: { type: "integer", minimum: 16, maximum: 10240 },
|
||||
preview_height: { type: "integer", minimum: 16, maximum: 8192 },
|
||||
expected_snapshot: { type: "object", additionalProperties: false, required: ["project_guid", "sequence_id", "preview_width", "preview_height"] },
|
||||
confirm_set_preview_frame: { type: "boolean" }, operation_id: { type: "string" },
|
||||
},
|
||||
});
|
||||
expect(tools.inspect_source_media_provenance_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false, required: ["project_item_id"],
|
||||
properties: {
|
||||
project_item_id: { type: "string", minLength: 1, maxLength: 512 },
|
||||
include_media_file_path: { type: "boolean" },
|
||||
include_originating_project_path: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
expect(tools.inspect_source_proxy_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false, required: ["project_item_id"],
|
||||
properties: {
|
||||
project_item_id: { type: "string", minLength: 1, maxLength: 512 },
|
||||
include_proxy_path: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
expect(tools.create_empty_sequence_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["name", "confirm_non_undoable", "operation_id"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
confirm_non_undoable: { type: "boolean" },
|
||||
operation_id: { type: "string" },
|
||||
},
|
||||
});
|
||||
expect(tools.audit_object_masks_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
expected_project_guid: { type: "string", minLength: 1, maxLength: 512 },
|
||||
sequence_ids: {
|
||||
type: "array", minItems: 1, maxItems: 64, uniqueItems: true,
|
||||
items: { type: "string", minLength: 1, maxLength: 512 },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(tools.inspect_unique_object_identity_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false,
|
||||
properties: {
|
||||
project_item_id: { type: "string", minLength: 1, maxLength: 512 },
|
||||
sequence_guid: { type: "string", minLength: 1, maxLength: 512 },
|
||||
expected_project_guid: { type: "string", minLength: 1, maxLength: 512 },
|
||||
expected_unique_id: { type: "string", minLength: 1, maxLength: 512 },
|
||||
},
|
||||
oneOf: [{ required: ["project_item_id"] }, { required: ["sequence_guid"] }],
|
||||
});
|
||||
expect(tools.inspect_track_item_identity_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["media_type", "track_index", "clip_index"],
|
||||
properties: {
|
||||
media_type: { type: "string", enum: ["video", "audio"] },
|
||||
track_index: { type: "integer", minimum: 0 },
|
||||
clip_index: { type: "integer", minimum: 0 },
|
||||
expected_sequence_guid: { type: "string", maxLength: 512 },
|
||||
},
|
||||
});
|
||||
expect(tools.inspect_effect_parameter_catalog_uxp.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false,
|
||||
required: ["media_type", "track_index", "clip_index", "component_index"],
|
||||
properties: {
|
||||
media_type: { enum: ["video", "audio"] },
|
||||
track_index: { maximum: 511 }, clip_index: { maximum: 511 }, component_index: { maximum: 511 },
|
||||
expected_sequence_guid: { maxLength: 512 }, expected_component_id: { maxLength: 512 },
|
||||
},
|
||||
});
|
||||
expect(tools.has_transcript_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
properties: {
|
||||
project_item_id: { type: "string" },
|
||||
project_item_name: { type: "string" },
|
||||
},
|
||||
});
|
||||
expect(tools.export_aaf_uxp.parameters).toMatchObject({
|
||||
type: "object",
|
||||
required: ["output_file_path"],
|
||||
properties: {
|
||||
output_file_path: { type: "string" },
|
||||
operation_id: { type: "string" },
|
||||
options: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mixdown_video: { type: "boolean" },
|
||||
explode_to_mono: { type: "boolean" },
|
||||
embed_audio: { type: "boolean" },
|
||||
trim_sources: { type: "boolean" },
|
||||
render_audio_effects: { type: "boolean" },
|
||||
interleave_without_effects: { type: "boolean" },
|
||||
preserve_parent_folder: { type: "boolean" },
|
||||
sample_rate: { type: "integer", enum: [32000, 44100, 48000, 88200, 96000] },
|
||||
bits_per_sample: { type: "integer", enum: [16, 24, 32] },
|
||||
audio_file_format: { type: "string", enum: ["aiff", "wav"] },
|
||||
handle_frames: { type: "integer" },
|
||||
video_mixdown_preset_path: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("translates the public snake_case arguments to protocol arguments", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const { tools } = catalog(request);
|
||||
|
||||
await tools.rename_track_uxp.handler({
|
||||
track_type: "caption", track_index: 2, name: "Dialogue", operation_id: "track-1",
|
||||
});
|
||||
await tools.create_subclip_uxp.handler({
|
||||
project_item_id: "clip-17", name: "Sentence", start_seconds: 1.25, end_seconds: 4.75,
|
||||
hard_boundaries: true, take_video: false, take_audio: true, operation_id: "subclip-1",
|
||||
});
|
||||
await tools.list_markers_uxp.handler({
|
||||
scope: "project_item", project_item_id: "clip-17", filters: ["Comment", "Chapter"], include_web_links: true, include_color_values: true,
|
||||
});
|
||||
await tools.set_source_monitor_position_uxp.handler({ seconds: 12.5, operation_id: "source-1" });
|
||||
await tools.inspect_frame_alignment_uxp.handler({ action: "align", frame_rate: 24, seconds: 1.03 });
|
||||
await tools.calculate_tick_time_uxp.handler({ operation: "add", base_ticks: "1", operand_ticks: "2" });
|
||||
await tools.manage_sequence_preview_frame_uxp.handler({
|
||||
action: "update", sequence_id: "sequence-1", preview_width: 1920, preview_height: 1080,
|
||||
expected_snapshot: { project_guid: "project-1", sequence_id: "sequence-1", preview_width: 640, preview_height: 360 },
|
||||
confirm_set_preview_frame: true, operation_id: "preview-frame-1",
|
||||
});
|
||||
await tools.inspect_source_media_provenance_uxp.handler({
|
||||
project_item_id: "clip-17", include_media_file_path: true, include_originating_project_path: false,
|
||||
});
|
||||
await tools.inspect_source_proxy_uxp.handler({ project_item_id: "clip-17", include_proxy_path: true });
|
||||
await tools.create_empty_sequence_uxp.handler({
|
||||
name: "Empty Assembly", confirm_non_undoable: true, operation_id: "empty-1",
|
||||
});
|
||||
await tools.audit_object_masks_uxp.handler({
|
||||
expected_project_guid: "project-1", sequence_ids: ["sequence-2", "sequence-1"],
|
||||
});
|
||||
await tools.inspect_track_item_identity_uxp.handler({
|
||||
media_type: "video", track_index: 1, clip_index: 2, expected_sequence_guid: "sequence-1",
|
||||
});
|
||||
await tools.has_transcript_uxp.handler({ project_item_name: "Interview A" });
|
||||
await tools.export_aaf_uxp.handler({
|
||||
output_file_path: "/exports/turnover.aaf",
|
||||
options: {
|
||||
mixdown_video: true, explode_to_mono: true, embed_audio: false, trim_sources: true,
|
||||
render_audio_effects: true, interleave_without_effects: false, preserve_parent_folder: true,
|
||||
sample_rate: 48000, bits_per_sample: 24, audio_file_format: "wav", handle_frames: 12,
|
||||
video_mixdown_preset_path: "/presets/prores.epr",
|
||||
},
|
||||
operation_id: "aaf-1",
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "track.rename", {
|
||||
trackType: "caption", trackIndex: 2, name: "Dialogue", operationId: "track-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "subclip.create", {
|
||||
projectItemId: "clip-17", name: "Sentence", startSeconds: 1.25, endSeconds: 4.75,
|
||||
hasHardBoundaries: true, takeVideo: false, takeAudio: true, operationId: "subclip-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(3, "marker.list", {
|
||||
scope: "projectItem", projectItemId: "clip-17", filters: ["Comment", "Chapter"], includeWebLinks: true, includeColorValues: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(4, "sourceMonitor.position.set", {
|
||||
seconds: 12.5, operationId: "source-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(5, "time.frameAlignment.inspect", {
|
||||
action: "align", frameRate: 24, seconds: 1.03,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(6, "time.tickArithmetic.inspect", {
|
||||
operation: "add", baseTicks: "1", operandTicks: "2",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(7, "sequence.previewFrame.update", {
|
||||
sequenceId: "sequence-1", previewWidth: 1920, previewHeight: 1080, confirmSetPreviewFrame: true, operationId: "preview-frame-1",
|
||||
expectedSnapshot: { projectGuid: "project-1", sequenceId: "sequence-1", previewWidth: 640, previewHeight: 360 },
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(8, "source.provenance.inspect", {
|
||||
projectItemId: "clip-17", includeMediaFilePath: true, includeOriginatingProjectPath: false,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(9, "source.proxy.inspect", {
|
||||
projectItemId: "clip-17", includeProxyPath: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(10, "sequences.createEmpty", {
|
||||
name: "Empty Assembly", confirmNonUndoable: true, operationId: "empty-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(11, "objectMask.audit", {
|
||||
expectedProjectGuid: "project-1", sequenceIds: ["sequence-2", "sequence-1"],
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(12, "trackItem.identity.inspect", {
|
||||
mediaType: "video", trackIndex: 1, clipIndex: 2, expectedSequenceGuid: "sequence-1",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(13, "transcript.has", { projectItemName: "Interview A" });
|
||||
expect(request).toHaveBeenNthCalledWith(14, "interchange.aaf.export", {
|
||||
outputFilePath: "/exports/turnover.aaf",
|
||||
options: {
|
||||
mixdownVideo: true, explodeToMono: true, embedAudio: false, trimSources: true,
|
||||
renderAudioEffects: true, interleaveWithoutEffects: false, preserveParentFolder: true,
|
||||
sampleRate: 48000, bitsPerSample: 24, audioFileFormat: "wav", handleFrames: 12,
|
||||
videoMixdownPresetPath: "/presets/prores.epr",
|
||||
},
|
||||
operationId: "aaf-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockedExecFile, mockedExecFileAsync } = vi.hoisted(() => {
|
||||
const mockedExecFileAsync = vi.fn();
|
||||
const mockedExecFile = vi.fn();
|
||||
mockedExecFile[Symbol.for("nodejs.util.promisify.custom")] = mockedExecFileAsync;
|
||||
return { mockedExecFile, mockedExecFileAsync };
|
||||
});
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({ execFile: mockedExecFile }));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getAudioTools, parseEbur128Summary } from "../../src/tools/audio.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const tools = getAudioTools({ tempDir: "/tmp/test", timeoutMs: 1000 });
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function enqueueFfmpegResult(stderr = "") {
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr });
|
||||
}
|
||||
|
||||
function enqueueFfmpegFailure(error: Error) {
|
||||
mockedExecFileAsync.mockRejectedValueOnce(error);
|
||||
}
|
||||
|
||||
function createMediaFixture() {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-loudness-"));
|
||||
const mediaPath = join(directory, "dialogue.wav");
|
||||
writeFileSync(mediaPath, "audio-fixture");
|
||||
return mediaPath;
|
||||
}
|
||||
|
||||
const measurableSummary = `
|
||||
Summary:
|
||||
Integrated loudness:
|
||||
I: -16.2 LUFS
|
||||
Loudness range:
|
||||
LRA: 4.1 LU
|
||||
True peak:
|
||||
Peak: -1.3 dBFS
|
||||
`;
|
||||
|
||||
describe("parseEbur128Summary", () => {
|
||||
it("extracts the final integrated loudness, range, and true peak", () => {
|
||||
const output = `
|
||||
Summary:
|
||||
|
||||
Integrated loudness:
|
||||
I: -16.2 LUFS
|
||||
Threshold: -26.2 LUFS
|
||||
|
||||
Loudness range:
|
||||
LRA: 4.1 LU
|
||||
|
||||
True peak:
|
||||
Peak: -1.3 dBFS
|
||||
`;
|
||||
expect(parseEbur128Summary(output)).toEqual({
|
||||
integratedLufs: -16.2,
|
||||
loudnessRangeLu: 4.1,
|
||||
truePeakDbfs: -1.3,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports digital silence as unmeasurable instead of fabricating a number", () => {
|
||||
const output = `Integrated loudness:\n I: -inf LUFS\nLoudness range:\n LRA: 0.0 LU\nTrue peak:\n Peak: -inf dBFS`;
|
||||
expect(parseEbur128Summary(output)).toEqual({
|
||||
integratedLufs: null,
|
||||
loudnessRangeLu: 0,
|
||||
truePeakDbfs: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyze_loudness argument boundary", () => {
|
||||
it("rejects invalid targets before contacting Premiere or FFmpeg", async () => {
|
||||
await expect(tools.analyze_loudness.handler({ media_path: "missing.wav", target_lufs: 4 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("target_lufs") });
|
||||
await expect(tools.analyze_loudness.handler({ media_path: "missing.wav", tolerance_lu: -1 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("tolerance_lu") });
|
||||
await expect(tools.analyze_loudness.handler({ media_path: "missing.wav", max_true_peak_dbfs: 1 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("max_true_peak_dbfs") });
|
||||
await expect(tools.analyze_loudness.handler({}))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("media_path") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves project-item paths through one read-only bridge call", async () => {
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: false, error: "Project item not found" });
|
||||
const result = await tools.analyze_loudness.handler({ project_item_id: "missing" });
|
||||
expect(mockedSendCommand).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSendCommand.mock.calls[0][0]).toContain("getMediaPath");
|
||||
expect(result).toMatchObject({ success: false, error: "Project item not found" });
|
||||
});
|
||||
|
||||
it("returns a local EBU R128 measurement and evaluates an optional delivery target", async () => {
|
||||
const mediaPath = createMediaFixture();
|
||||
enqueueFfmpegResult();
|
||||
enqueueFfmpegResult(measurableSummary);
|
||||
|
||||
await expect(tools.analyze_loudness.handler({
|
||||
media_path: mediaPath,
|
||||
target_lufs: -16,
|
||||
tolerance_lu: 0.5,
|
||||
max_true_peak_dbfs: -1,
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
mediaPath,
|
||||
integratedLufs: -16.2,
|
||||
loudnessRangeLu: 4.1,
|
||||
truePeakDbfs: -1.3,
|
||||
target: {
|
||||
lufs: -16,
|
||||
toleranceLu: 0.5,
|
||||
deltaLu: -0.2,
|
||||
loudnessPass: true,
|
||||
truePeakPass: true,
|
||||
passes: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockedExecFileAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("resolves a project item to a local file before measuring it", async () => {
|
||||
const mediaPath = createMediaFixture();
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { mediaPath, name: "Dialogue" } });
|
||||
enqueueFfmpegResult();
|
||||
enqueueFfmpegResult(measurableSummary);
|
||||
|
||||
await expect(tools.analyze_loudness.handler({ project_item_id: "dialogue-item" }))
|
||||
.resolves.toMatchObject({ success: true, data: { mediaPath, projectItemName: "Dialogue", target: null } });
|
||||
expect(mockedSendCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports a failed target and true-peak check without changing the media", async () => {
|
||||
const mediaPath = createMediaFixture();
|
||||
enqueueFfmpegResult();
|
||||
enqueueFfmpegResult(measurableSummary);
|
||||
|
||||
await expect(tools.analyze_loudness.handler({
|
||||
media_path: mediaPath,
|
||||
target_lufs: -14,
|
||||
tolerance_lu: 0.1,
|
||||
max_true_peak_dbfs: -2,
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
target: {
|
||||
loudnessPass: false,
|
||||
truePeakPass: false,
|
||||
passes: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports unavailable analysis prerequisites, timeouts, and digital silence without making up a metric", async () => {
|
||||
const mediaPath = createMediaFixture();
|
||||
enqueueFfmpegFailure(new Error("not found"));
|
||||
await expect(tools.analyze_loudness.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("not found on PATH") });
|
||||
|
||||
enqueueFfmpegResult();
|
||||
enqueueFfmpegFailure(Object.assign(new Error("timed out"), { killed: true }));
|
||||
await expect(tools.analyze_loudness.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("timed out") });
|
||||
|
||||
enqueueFfmpegResult();
|
||||
enqueueFfmpegResult("Integrated loudness:\n I: -inf LUFS");
|
||||
await expect(tools.analyze_loudness.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("did not return measurable") });
|
||||
});
|
||||
|
||||
it("does not invoke FFmpeg for an unavailable file and preserves an analysis error detail", async () => {
|
||||
await expect(tools.analyze_loudness.handler({ media_path: "missing-dialogue.wav" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("Media file not found") });
|
||||
expect(mockedExecFileAsync).not.toHaveBeenCalled();
|
||||
|
||||
const mediaPath = createMediaFixture();
|
||||
enqueueFfmpegResult();
|
||||
enqueueFfmpegFailure(Object.assign(new Error("decode failed"), { stderr: "invalid stream" }));
|
||||
await expect(tools.analyze_loudness.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("invalid stream") });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockedExecFile, mockedExecFileAsync } = vi.hoisted(() => {
|
||||
const mockedExecFileAsync = vi.fn();
|
||||
const mockedExecFile = vi.fn();
|
||||
mockedExecFile[Symbol.for("nodejs.util.promisify.custom")] = mockedExecFileAsync;
|
||||
return { mockedExecFile, mockedExecFileAsync };
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({ execFile: mockedExecFile }));
|
||||
|
||||
import { getExportTools, parseVideoQcOutput } from "../../src/tools/export.js";
|
||||
|
||||
describe("parseVideoQcOutput", () => {
|
||||
it("returns exact black and freeze intervals", () => {
|
||||
const stderr = [
|
||||
"[blackdetect] black_start:0 black_end:1.25 black_duration:1.25",
|
||||
"[freezedetect] lavfi.freezedetect.freeze_start: 2.5",
|
||||
"[freezedetect] lavfi.freezedetect.freeze_duration: 1.5",
|
||||
"[freezedetect] lavfi.freezedetect.freeze_end: 4",
|
||||
].join("\n");
|
||||
expect(parseVideoQcOutput(stderr)).toEqual({
|
||||
blackFrames: [{ start: 0, end: 1.25, duration: 1.25 }],
|
||||
freezes: [{ start: 2.5, end: 4, duration: 1.5 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyze_video_qc boundary", () => {
|
||||
const tool = getExportTools({ tempDir: "/tmp/test" }).analyze_video_qc;
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("rejects invalid thresholds and missing files before FFmpeg", async () => {
|
||||
await expect(tool.handler({ media_path: "missing.mp4", minimum_black_seconds: 0 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("minimum_black_seconds") });
|
||||
await expect(tool.handler({ media_path: "missing.mp4" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("not found") });
|
||||
await expect(tool.handler({ media_path: "missing.mp4", minimum_freeze_seconds: 0 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("minimum_freeze_seconds") });
|
||||
});
|
||||
|
||||
it("reports decoded findings without modifying the delivery file", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-video-qc-"));
|
||||
const mediaPath = join(directory, "delivery.mp4");
|
||||
writeFileSync(mediaPath, "video-fixture");
|
||||
mockedExecFileAsync.mockResolvedValueOnce({
|
||||
stdout: "",
|
||||
stderr: [
|
||||
"black_start:0 black_end:1.25 black_duration:1.25",
|
||||
"lavfi.freezedetect.freeze_start: 2.5",
|
||||
"lavfi.freezedetect.freeze_end: 4",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
await expect(tool.handler({ media_path: mediaPath, minimum_black_seconds: 1, minimum_freeze_seconds: 1 }))
|
||||
.resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
mediaPath,
|
||||
passes: false,
|
||||
blackFrames: [{ start: 0, end: 1.25, duration: 1.25 }],
|
||||
freezes: [{ start: 2.5, end: 4, duration: 1.5 }],
|
||||
},
|
||||
});
|
||||
expect(mockedExecFileAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves a non-timeout FFmpeg failure detail", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-video-qc-error-"));
|
||||
const mediaPath = join(directory, "delivery.mp4");
|
||||
writeFileSync(mediaPath, "video-fixture");
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("decode failed"), { stderr: "invalid stream" }));
|
||||
|
||||
await expect(tool.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("invalid stream") });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const { mockExecFile } = vi.hoisted(() => ({
|
||||
mockExecFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({ execFile: mockExecFile }));
|
||||
vi.mock("node:util", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("node:util")>();
|
||||
return {
|
||||
...original,
|
||||
promisify: () => (...args: any[]) => new Promise((resolve, reject) => {
|
||||
mockExecFile(...args, (error: Error | null, stdout: string, stderr: string) => {
|
||||
if (error) reject(error);
|
||||
else resolve({ stdout, stderr });
|
||||
});
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getAudioTools } from "../../src/tools/audio.js";
|
||||
import { confirmationToken, getEditPlanTools, validateEditPlan } from "../../src/tools/edit-plans.js";
|
||||
import {
|
||||
getExportTools,
|
||||
inspectExportPresetFile,
|
||||
MAX_CAPTURE_FRAME_BYTES,
|
||||
verifyDeliveryFile,
|
||||
} from "../../src/tools/export.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const temporaryDirectories: string[] = [];
|
||||
const bridgeOptions = { tempDir: "/tmp/coverage-tools", timeoutMs: 500 };
|
||||
|
||||
function temporaryPath(name: string, contents = "test content"): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-tool-coverage-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const path = join(directory, name);
|
||||
writeFileSync(path, contents);
|
||||
return path;
|
||||
}
|
||||
|
||||
function temporaryDirectory(): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-tool-coverage-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function execSucceeds(stderr = ""): void {
|
||||
mockExecFile.mockImplementationOnce((...args: any[]) => {
|
||||
args.at(-1)(null, "", stderr);
|
||||
});
|
||||
}
|
||||
|
||||
function execFails(error: Error): void {
|
||||
mockExecFile.mockImplementationOnce((...args: any[]) => {
|
||||
args.at(-1)(error, "", "");
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedSendCommand.mockResolvedValue({ success: true, data: { host: "ok" } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("audio tool analysis coverage", () => {
|
||||
it("resolves a project item, runs ffmpeg, and returns silent and retained ranges", async () => {
|
||||
const mediaPath = temporaryPath("speech.mp4");
|
||||
mockedSendCommand.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: { mediaPath, name: "Interview" },
|
||||
});
|
||||
execSucceeds();
|
||||
execSucceeds([
|
||||
"Duration: 00:00:12.00, start: 0.000000",
|
||||
"[silencedetect] silence_start: 2",
|
||||
"[silencedetect] silence_end: 4.5 | silence_duration: 2.5",
|
||||
"[silencedetect] silence_start: 10",
|
||||
].join("\n"));
|
||||
|
||||
const result = await getAudioTools(bridgeOptions).detect_silence.handler({
|
||||
project_item_id: "interview-item",
|
||||
noise_threshold_db: -42,
|
||||
min_duration_seconds: 0.75,
|
||||
});
|
||||
|
||||
expect(mockedSendCommand).toHaveBeenCalledOnce();
|
||||
expect(mockedSendCommand.mock.calls[0][0]).toContain("getMediaPath");
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(2);
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
mediaPath,
|
||||
projectItemName: "Interview",
|
||||
noiseThresholdDb: -42,
|
||||
minDurationSeconds: 0.75,
|
||||
totalDurationSeconds: 12,
|
||||
silenceIntervals: [
|
||||
{ start: 2, end: 4.5, duration: 2.5 },
|
||||
{ start: 10, end: 12, duration: 2 },
|
||||
],
|
||||
segments: [
|
||||
{ start: 0, end: 2, duration: 2 },
|
||||
{ start: 4.5, end: 10, duration: 5.5 },
|
||||
],
|
||||
silentSeconds: 4.5,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports missing ffmpeg, timeouts, and useful analyser failure details", async () => {
|
||||
const mediaPath = temporaryPath("speech.mp4");
|
||||
const tools = getAudioTools(bridgeOptions);
|
||||
|
||||
execFails(new Error("ENOENT"));
|
||||
await expect(tools.detect_silence.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("ffmpeg was not found") });
|
||||
|
||||
execSucceeds();
|
||||
execFails(Object.assign(new Error("slow"), { killed: true }));
|
||||
await expect(tools.detect_silence.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("timed out after 300s") });
|
||||
|
||||
execSucceeds();
|
||||
execFails(Object.assign(new Error("failed"), {
|
||||
stderr: "first line\n\nsecond line\nthird line\nfourth line",
|
||||
}));
|
||||
await expect(tools.detect_silence.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("second line third line fourth line") });
|
||||
});
|
||||
|
||||
it("renders audio adjustment, keyframe, and unmute options into the bridge script", async () => {
|
||||
const tools = getAudioTools(bridgeOptions);
|
||||
|
||||
await tools.adjust_audio_levels.handler({ node_id: "clip\"id", level_db: -6 });
|
||||
// Premiere's Volume > Level uses +15 dB as its normalized maximum.
|
||||
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("0.08912509381337455");
|
||||
|
||||
await tools.add_audio_keyframes.handler({
|
||||
node_id: "clip",
|
||||
keyframes: [{ time_seconds: 1.25, level_db: -Infinity }],
|
||||
});
|
||||
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("1e-7");
|
||||
|
||||
await tools.mute_track.handler({ track_index: 3, muted: false });
|
||||
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("track.setMute(0)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit plan validation and apply coverage", () => {
|
||||
it.each([
|
||||
[null, "plan must be an object"],
|
||||
[{}, "plan.operations must contain at least one operation"],
|
||||
[{ operations: Array.from({ length: 101 }, () => ({ type: "remove_clip", node_id: "clip" })) }, "limited to 100"],
|
||||
[{ operations: [null] }, "operation 0 must be an object"],
|
||||
[{ operations: [{ type: "insert_clip", start_seconds: 0 }] }, "requires item_id"],
|
||||
[{ operations: [{ type: "insert_clip", item_id: "item", start_seconds: Number.NaN }] }, "non-negative number"],
|
||||
[{ operations: [{ type: "insert_clip", item_id: "item", start_seconds: 0, video_track_index: -1 }] }, "video_track_index"],
|
||||
[{ operations: [{ type: "insert_clip", item_id: "item", start_seconds: 0, audio_track_index: 1.5 }] }, "audio_track_index"],
|
||||
[{ operations: [{ type: "remove_clip", node_id: "" }] }, "requires node_id"],
|
||||
])("rejects %j", (value, message) => {
|
||||
expect(() => validateEditPlan(value)).toThrow(message);
|
||||
});
|
||||
|
||||
it("previews destructive and non-destructive work with stable confirmation", async () => {
|
||||
const plan = {
|
||||
operations: [
|
||||
{ type: "insert_clip" as const, item_id: "source", start_seconds: 2 },
|
||||
{ type: "remove_clip" as const, node_id: "old-clip", ripple: true },
|
||||
],
|
||||
};
|
||||
const tools = getEditPlanTools(bridgeOptions, {
|
||||
capabilities: { capabilities: new Set(["inspect"]), source: "explicit" },
|
||||
operationIdFactory: () => "preview-mixed",
|
||||
});
|
||||
|
||||
const result = await tools.preview_edit_plan.handler({ plan });
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
operationId: "preview-mixed",
|
||||
confirmationToken: confirmationToken(plan),
|
||||
changes: [
|
||||
{ type: "insert_clip", target: "source", destructive: false },
|
||||
{ type: "remove_clip", target: "old-clip", destructive: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(confirmationToken({ ...plan, sequence_id: "other" })).not.toBe(confirmationToken(plan));
|
||||
});
|
||||
|
||||
it("builds a remove plan against a named sequence and decorates bridge failures", async () => {
|
||||
const plan = {
|
||||
sequence_id: "seq\"one",
|
||||
operations: [
|
||||
{ type: "remove_clip" as const, node_id: "old-clip", ripple: true },
|
||||
{ type: "insert_clip" as const, item_id: "new-item", start_seconds: 3, video_track_index: 2, audio_track_index: 4 },
|
||||
],
|
||||
};
|
||||
const auditSink = vi.fn();
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: false, error: "Premiere rejected the edit" });
|
||||
const tools = getEditPlanTools(bridgeOptions, {
|
||||
capabilities: { capabilities: new Set(["inspect", "edit"]), source: "explicit" },
|
||||
auditSink,
|
||||
operationIdFactory: () => "apply-remove",
|
||||
});
|
||||
|
||||
const result = await tools.apply_edit_plan.handler({
|
||||
plan,
|
||||
confirmation_token: confirmationToken(plan),
|
||||
});
|
||||
const script = String(mockedSendCommand.mock.calls[0][0]);
|
||||
|
||||
expect(script).toContain('__findSequence("seq\\"one")');
|
||||
expect(script).toContain("function __planFindClip");
|
||||
expect(script).toContain("found0.remove(true, true)");
|
||||
expect(result).toEqual({ success: false, error: "Premiere rejected the edit (operation apply-remove)" });
|
||||
expect(auditSink.mock.calls.map(([event]) => event.outcome)).toEqual(["started", "failed"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("export verification and host-result coverage", () => {
|
||||
it("validates a SHA-512 delivery and rejects malformed size constraints", async () => {
|
||||
const delivery = temporaryPath("delivery.mov", "delivery data");
|
||||
const checksum = createHash("sha512").update("delivery data").digest("hex");
|
||||
|
||||
await expect(verifyDeliveryFile(delivery, { minimumSizeBytes: -1 })).rejects.toThrow("minimum_size_bytes");
|
||||
await expect(verifyDeliveryFile(delivery, { expectedSizeBytes: 1.5 })).rejects.toThrow("expected_size_bytes");
|
||||
await expect(verifyDeliveryFile(temporaryDirectory())).rejects.toThrow("not a regular file");
|
||||
|
||||
await expect(verifyDeliveryFile(delivery, {
|
||||
algorithm: "sha512",
|
||||
expectedChecksum: ` ${checksum.toUpperCase()} `,
|
||||
expectedSizeBytes: 13,
|
||||
minimumSizeBytes: 0,
|
||||
})).resolves.toMatchObject({
|
||||
checksum: { algorithm: "sha512", value: checksum },
|
||||
matchesExpectedChecksum: true,
|
||||
matchesExpectedSize: true,
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces local preset and delivery errors, then merges a host-validated preset response", async () => {
|
||||
const tools = getExportTools(bridgeOptions);
|
||||
const invalid = temporaryPath("preset.xml", "preset");
|
||||
await expect(tools.validate_export_preset.handler({ preset_path: invalid }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining(".epr") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
|
||||
const preset = temporaryPath("preset.epr", "preset");
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: false, error: "No active sequence" });
|
||||
await expect(tools.validate_export_preset.handler({ preset_path: preset }))
|
||||
.resolves.toEqual({ success: false, error: "No active sequence" });
|
||||
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { outputExtension: ".mov" } });
|
||||
await expect(tools.validate_export_preset.handler({ preset_path: preset }))
|
||||
.resolves.toMatchObject({ success: true, data: { path: preset, outputExtension: ".mov", hostValidated: true } });
|
||||
expect(inspectExportPresetFile(preset).sizeBytes).toBe(6);
|
||||
|
||||
await expect(tools.verify_delivery_file.handler({ output_path: join(tmpdir(), "missing-delivery.mov") }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("does not exist") });
|
||||
});
|
||||
|
||||
it("reads a host-exported frame, reports unreadable output, and covers proxy script choices", async () => {
|
||||
const tools = getExportTools(bridgeOptions);
|
||||
const frame = temporaryPath("frame.png", "png bytes");
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { outputPath: frame } });
|
||||
await expect(tools.capture_frame.handler({ time_seconds: 3 })).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { captured: true, mimeType: "image/png", base64: Buffer.from("png bytes").toString("base64") },
|
||||
});
|
||||
expect(existsSync(frame)).toBe(false);
|
||||
|
||||
const oversizedFrame = temporaryPath("oversized-frame.png", "x".repeat(MAX_CAPTURE_FRAME_BYTES + 1));
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { outputPath: oversizedFrame } });
|
||||
await expect(tools.capture_frame.handler({})).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("inline response limit"),
|
||||
});
|
||||
expect(existsSync(oversizedFrame)).toBe(false);
|
||||
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { outputPath: temporaryDirectory() } });
|
||||
await expect(tools.capture_frame.handler({})).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("Failed to read captured frame"),
|
||||
});
|
||||
|
||||
await tools.manage_proxies.handler({ item_id: "item", action: "create" });
|
||||
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("output_path is required");
|
||||
await tools.manage_proxies.handler({ item_id: "item", action: "create", output_path: "proxy.mov" });
|
||||
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("__findProxyPreset()");
|
||||
await tools.manage_proxies.handler({ item_id: "item", action: "create", output_path: "proxy.mov", preset_path: "proxy.epr" });
|
||||
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain('var presetPath = "proxy.epr"');
|
||||
await tools.manage_proxies.handler({ item_id: "item", action: "attach" });
|
||||
expect(mockedSendCommand.mock.calls.at(-1)?.[0]).toContain("proxy_path is required for attach action");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getAvSettingsTools } from "../../src/tools/av-settings.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const tools = getAvSettingsTools({ tempDir: "/tmp/test", timeoutMs: 1000 });
|
||||
|
||||
describe("AV feature support", () => {
|
||||
it("identifies documented support and UI-only boundaries without a host call", async () => {
|
||||
const result = await tools.get_av_feature_support.handler();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.supported.projectItemAudioChannelMapping.status).toBe("supported_with_limitations");
|
||||
expect(result.data.blocked.enhanceSpeech).toMatchObject({
|
||||
status: "ui_only",
|
||||
reason: expect.stringContaining("No documented"),
|
||||
});
|
||||
expect(result.data.blocked.audioTrackMixerRoutingAndSubmixes.status).toBe("unsupported");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AV inspection scripts", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("inspects documented sequence audio and color/render fields", async () => {
|
||||
await tools.inspect_sequence_av_settings.handler();
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("audioChannelCount");
|
||||
expect(script).toContain("autoToneMapEnabled");
|
||||
expect(script).toContain("compositeLinearColor");
|
||||
expect(script).toContain("sequenceWorkingColorSpace");
|
||||
});
|
||||
|
||||
it("inspects project item color spaces, LUTs, override options, and channel shape", async () => {
|
||||
await tools.inspect_project_item_av_metadata.handler({ item_id: 'clip "A"' });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('clip \\"A\\"');
|
||||
expect(script).toContain("getColorSpace");
|
||||
expect(script).toContain("getOriginalColorSpace");
|
||||
expect(script).toContain("getOverrideColorSpaceList");
|
||||
expect(script).toContain("getAudioChannelMapping");
|
||||
});
|
||||
});
|
||||
|
||||
describe("audio channel mapping", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("rejects non-integer and negative indexes before bridge execution", async () => {
|
||||
await expect(tools.set_project_item_audio_channel_mapping.handler({
|
||||
item_id: "clip", channel_index: -1, source_channel_index: 0,
|
||||
})).resolves.toEqual({ success: false, error: "channel_index must be a non-negative integer" });
|
||||
await expect(tools.set_project_item_audio_channel_mapping.handler({
|
||||
item_id: "clip", channel_index: 0, source_channel_index: 1.5,
|
||||
})).resolves.toEqual({ success: false, error: "source_channel_index must be a non-negative integer" });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires Premiere to accept the documented mapping call", async () => {
|
||||
await tools.set_project_item_audio_channel_mapping.handler({
|
||||
item_id: "clip", channel_index: 1, source_channel_index: 0,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("mapping.setMappingForChannel(1, 0)");
|
||||
expect(script).toContain("accepted !== true");
|
||||
expect(script).toContain('verification: "api-return"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({ sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }) }));
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getExportTools } from "../../src/tools/export.js";
|
||||
|
||||
const mocked = vi.mocked(sendCommand);
|
||||
const tool = getExportTools({ tempDir: "/tmp/test" }).export_sequence_clip_review_frames;
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("export_sequence_clip_review_frames", () => {
|
||||
it("samples up to twenty clip midpoints in one bridge round trip", async () => {
|
||||
await tool.handler({ output_dir: "/tmp/review" });
|
||||
expect(mocked).toHaveBeenCalledTimes(1);
|
||||
const script = mocked.mock.calls[0][0];
|
||||
expect(script).toContain("Math.min(track.clips.numItems, 20)");
|
||||
expect(script).toContain("clip.start.seconds + ((clip.end.seconds - clip.start.seconds) / 2)");
|
||||
expect(script).toContain("__exportStillFrame(");
|
||||
});
|
||||
|
||||
it("rejects unsafe bounds before contacting Premiere", async () => {
|
||||
await expect(tool.handler({ output_dir: "/tmp", track_index: -1 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tool.handler({ output_dir: "/tmp", limit: 51 })).resolves.toMatchObject({ success: false });
|
||||
expect(mocked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BridgeOptions } from "../../src/bridge/file-bridge.js";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getCompetitorGapTools } from "../../src/tools/competitor-gaps.js";
|
||||
|
||||
const bridgeOptions: BridgeOptions = { tempDir: "/tmp/competitor-gap-tests", timeoutMs: 5000 };
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
|
||||
describe("competitor-gap default-profile tools", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("exposes the requested competitor-parity names", () => {
|
||||
const tools = getCompetitorGapTools(bridgeOptions);
|
||||
expect(Object.keys(tools)).toEqual(expect.arrayContaining([
|
||||
"import_edl",
|
||||
"add_to_timeline_batch",
|
||||
"crop_clip",
|
||||
"setup_ducking",
|
||||
"validate_project_for_export",
|
||||
"read_sequence_captions",
|
||||
"set_clip_properties_batch",
|
||||
"detect_scene_edits",
|
||||
]));
|
||||
});
|
||||
|
||||
it("does not attempt an unattended EDL import", async () => {
|
||||
const result = await getCompetitorGapTools(bridgeOptions).import_edl.handler({ file_path: "D:/cuts/spot.edl" });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("No mutation was attempted");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects ambiguous batch insert positions before writing", async () => {
|
||||
const result = await getCompetitorGapTools(bridgeOptions).add_to_timeline_batch.handler({
|
||||
clips: [
|
||||
{ item_id: "a", track_index: 0, start_seconds: 4 },
|
||||
{ item_id: "b", track_index: 0, start_seconds: 4 },
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("shares video track");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preflights and reads back batch insertions", async () => {
|
||||
await getCompetitorGapTools(bridgeOptions).add_to_timeline_batch.handler({
|
||||
clips: [
|
||||
{ item_id: "later", track_index: 1, start_seconds: 10, audio_track_index: 0 },
|
||||
{ item_id: "first", track_index: 0, start_seconds: 2, audio_track_index: 0 },
|
||||
],
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("Preflight every mutable dependency");
|
||||
expect(script).toContain("seq.insertClip");
|
||||
expect(script).toContain("actualStart");
|
||||
expect(script.indexOf('itemId: "first"')).toBeLessThan(script.indexOf('itemId: "later"'));
|
||||
});
|
||||
|
||||
it("requires a meaningful crop change before adding an effect", async () => {
|
||||
const result = await getCompetitorGapTools(bridgeOptions).crop_clip.handler({ node_id: "video-1" });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("requires at least one");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the QE crop catalog only when needed and verifies requested values", async () => {
|
||||
await getCompetitorGapTools(bridgeOptions).crop_clip.handler({ node_id: "video-1", left: 12, zoom: true });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('__getQeEffectCatalog("video")');
|
||||
expect(script).toContain("addVideoEffect");
|
||||
expect(script).toContain('name: "Left"');
|
||||
expect(script).toContain("getValue");
|
||||
});
|
||||
|
||||
it("rejects overlapping ducking windows before writing automation", async () => {
|
||||
const result = await getCompetitorGapTools(bridgeOptions).setup_ducking.handler({
|
||||
node_id: "music-1",
|
||||
ducking_windows: [
|
||||
{ start_seconds: 1, end_seconds: 3, ducked_db: -20 },
|
||||
{ start_seconds: 2, end_seconds: 4, ducked_db: -24 },
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("must not overlap");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("generates verified relative audio keyframes for non-overlapping windows", async () => {
|
||||
await getCompetitorGapTools(bridgeOptions).setup_ducking.handler({
|
||||
node_id: "music-1",
|
||||
base_db: -4,
|
||||
fade_seconds: 0.25,
|
||||
ducking_windows: [{ start_seconds: 2, end_seconds: 4, ducked_db: -28 }],
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("setup_ducking only supports audio");
|
||||
expect(script).toContain("setTimeVarying");
|
||||
expect(script).toContain("getValueAtTime");
|
||||
expect(script).toContain("duckingWindowCount");
|
||||
});
|
||||
|
||||
it("generates a non-mutating export readiness audit", async () => {
|
||||
await getCompetitorGapTools(bridgeOptions).validate_project_for_export.handler({
|
||||
sequence_id: "edit-1",
|
||||
output_path: "D:/exports/final.mp4",
|
||||
preset_path: "D:/presets/h264.epr",
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("OFFLINE_MEDIA");
|
||||
expect(script).toContain("TIMELINE_GAPS");
|
||||
expect(script).toContain("readyForExport");
|
||||
expect(script).not.toContain("encodeSequence");
|
||||
});
|
||||
|
||||
it("makes caption-read limitations explicit instead of treating no tracks as no captions", async () => {
|
||||
await getCompetitorGapTools(bridgeOptions).read_sequence_captions.handler({});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("captionReadSupported");
|
||||
expect(script).toContain("do not prove this sequence has no captions");
|
||||
});
|
||||
|
||||
it("fails property batch speed requests before touching any clip", async () => {
|
||||
const result = await getCompetitorGapTools(bridgeOptions).set_clip_properties_batch.handler({
|
||||
items: [{ node_id: "clip-1", speed: 150 }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("speed");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preflights, verifies, and restores batch clip properties on mismatch", async () => {
|
||||
await getCompetitorGapTools(bridgeOptions).set_clip_properties_batch.handler({
|
||||
items: [{ node_id: "clip-1", opacity: 80, scale: 110, position_x: 400, rotation: 12 }],
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("No batch mutation was attempted");
|
||||
expect(script).toContain("restoreAll");
|
||||
expect(script).toContain("No partial batch is reported as verified");
|
||||
});
|
||||
|
||||
it("withholds unsafe CEP scene detection without an authenticated UXP bridge", async () => {
|
||||
const result = await getCompetitorGapTools(bridgeOptions).detect_scene_edits.handler({
|
||||
mode: "create_markers",
|
||||
confirm_non_undoable: true,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("authenticated Premiere UXP bridge");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes confirmed scene detection through UXP with a stable mode mapping", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ detected: true });
|
||||
const bridge = { request } as any;
|
||||
const result = await getCompetitorGapTools(bridgeOptions, bridge).detect_scene_edits.handler({
|
||||
mode: "create_subclips",
|
||||
confirm_non_undoable: true,
|
||||
operation_id: "scene-42",
|
||||
});
|
||||
expect(result).toMatchObject({ success: true, data: { backend: "uxp" } });
|
||||
expect(request).toHaveBeenCalledWith("sceneEdit.detect", { mode: "createSubclips", operationId: "scene-42" });
|
||||
});
|
||||
|
||||
it("rejects malformed or unsafe inputs before generating a CEP command", async () => {
|
||||
const tools = getCompetitorGapTools(bridgeOptions);
|
||||
|
||||
await expect(tools.add_to_timeline_batch.handler({ clips: [] })).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("clips must contain"),
|
||||
});
|
||||
await expect(tools.add_to_timeline_batch.handler({
|
||||
clips: Array.from({ length: 33 }, (_, index) => ({ item_id: `clip-${index}` })),
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("between 1 and 32") });
|
||||
await expect(tools.add_to_timeline_batch.handler({ clips: [{ item_id: "" }] })).resolves
|
||||
.toMatchObject({ success: false, error: expect.stringContaining("item_id") });
|
||||
await expect(tools.add_to_timeline_batch.handler({
|
||||
clips: [{ item_id: "clip", track_index: -1 }],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("non-negative integer") });
|
||||
await expect(tools.crop_clip.handler({ node_id: "clip", right: 101 })).resolves.toMatchObject({ success: false, error: expect.stringContaining("percentages") });
|
||||
await expect(tools.setup_ducking.handler({
|
||||
node_id: "music", fade_seconds: 0, ducking_windows: [],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("positive fade_seconds") });
|
||||
await expect(tools.setup_ducking.handler({
|
||||
node_id: "music", ducking_windows: Array.from({ length: 33 }, () => ({ start_seconds: 0, end_seconds: 1, ducked_db: -12 })),
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("0–32 ducking windows") });
|
||||
await expect(tools.setup_ducking.handler({
|
||||
node_id: "music", ducking_windows: [{ start_seconds: 3, end_seconds: 3, ducked_db: -12 }],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("greater than start_seconds") });
|
||||
await expect(tools.set_clip_properties_batch.handler({ items: [] })).resolves.toMatchObject({ success: false, error: expect.stringContaining("between 1 and 16") });
|
||||
await expect(tools.set_clip_properties_batch.handler({ items: [{ node_id: "" }] })).resolves
|
||||
.toMatchObject({ success: false, error: expect.stringContaining("node_id") });
|
||||
await expect(tools.set_clip_properties_batch.handler({
|
||||
items: [{ node_id: "clip" }, { node_id: "clip", opacity: 80 }],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("must specify at least one") });
|
||||
await expect(tools.set_clip_properties_batch.handler({
|
||||
items: [{ node_id: "clip", opacity: -1 }],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("invalid property") });
|
||||
await expect(tools.set_clip_properties_batch.handler({
|
||||
items: [{ node_id: "clip", opacity: 80 }, { node_id: "clip", opacity: 60 }],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("duplicate node_id") });
|
||||
await expect(tools.detect_scene_edits.handler({ mode: "create_markers", confirm_non_undoable: false })).resolves
|
||||
.toMatchObject({ success: false, error: expect.stringContaining("confirm_non_undoable") });
|
||||
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("covers optional export, caption, and UXP-scene request variants", async () => {
|
||||
const tools = getCompetitorGapTools(bridgeOptions);
|
||||
await tools.validate_project_for_export.handler({ require_non_empty_timeline: false, check_gaps: false });
|
||||
expect(String(mockedSendCommand.mock.calls[0][0])).toContain("requireNonEmptyTimeline: false");
|
||||
expect(String(mockedSendCommand.mock.calls[0][0])).toContain("PRESET_NOT_PROVIDED");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await tools.read_sequence_captions.handler({ sequence_id: "sequence-1" });
|
||||
expect(String(mockedSendCommand.mock.calls[0][0])).toContain('__findSequence("sequence-1")');
|
||||
|
||||
const rejectedBridge = { request: vi.fn().mockRejectedValue(new Error("UXP disconnected")) } as any;
|
||||
await expect(getCompetitorGapTools(bridgeOptions, rejectedBridge).detect_scene_edits.handler({
|
||||
mode: "apply_cuts", confirm_non_undoable: true,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("UXP disconnected") });
|
||||
await expect(getCompetitorGapTools(bridgeOptions, { request: vi.fn() } as any).detect_scene_edits.handler({
|
||||
mode: "not-supported" as any, confirm_non_undoable: true,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("Unsupported scene-edit mode") });
|
||||
|
||||
const bridge = { request: vi.fn().mockResolvedValue({ applied: true }) } as any;
|
||||
await getCompetitorGapTools(bridgeOptions, bridge).detect_scene_edits.handler({
|
||||
mode: "create_markers", confirm_non_undoable: true,
|
||||
});
|
||||
expect(bridge.request).toHaveBeenCalledWith("sceneEdit.detect", { mode: "createMarkers" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { appendFileSync, mkdtempSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockedExecFile, mockedExecFileAsync } = vi.hoisted(() => {
|
||||
const mockedExecFileAsync = vi.fn();
|
||||
const mockedExecFile = vi.fn();
|
||||
mockedExecFile[Symbol.for("nodejs.util.promisify.custom")] = mockedExecFileAsync;
|
||||
return { mockedExecFile, mockedExecFileAsync };
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({ execFile: mockedExecFile }));
|
||||
|
||||
import {
|
||||
evaluateDeliveryConformance,
|
||||
getExportTools,
|
||||
parseRationalRate,
|
||||
validateDeliveryConformanceContract,
|
||||
} from "../../src/tools/export.js";
|
||||
|
||||
const probe = {
|
||||
format: { format_name: "mov,mp4,m4a", duration: "10.020", bit_rate: "12000000" },
|
||||
streams: [
|
||||
{ codec_type: "video", codec_name: "h264", width: 1920, height: 1080, avg_frame_rate: "30000/1001", bit_rate: "10000000" },
|
||||
{ codec_type: "audio", codec_name: "aac", sample_rate: "48000", channels: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
describe("delivery conformance comparisons", () => {
|
||||
it("parses rational frame rates and rejects malformed contracts", () => {
|
||||
expect(parseRationalRate("30000/1001")).toBeCloseTo(29.97003, 5);
|
||||
expect(parseRationalRate("0/0")).toBeNull();
|
||||
expect(parseRationalRate("0/1")).toBeNull();
|
||||
expect(parseRationalRate(0)).toBeNull();
|
||||
expect(validateDeliveryConformanceContract({})).toContain("At least one");
|
||||
expect(validateDeliveryConformanceContract({ width: -1 })).toContain("positive integer");
|
||||
expect(validateDeliveryConformanceContract({ minimumVideoBitrateKbps: 20, maximumVideoBitrateKbps: 10 })).toContain("cannot exceed");
|
||||
expect(parseRationalRate(24)).toBe(24);
|
||||
expect(parseRationalRate("bad")).toBeNull();
|
||||
expect(validateDeliveryConformanceContract({ allowedContainerNames: [] })).toContain("container name");
|
||||
expect(validateDeliveryConformanceContract({ audioChannels: 1.5 })).toContain("positive integer");
|
||||
expect(validateDeliveryConformanceContract({ durationSeconds: 0 })).toContain("greater than 0");
|
||||
expect(validateDeliveryConformanceContract({ durationToleranceSeconds: -1 })).toContain("non-negative");
|
||||
expect(validateDeliveryConformanceContract({ targetLufs: 1 })).toContain("target_lufs");
|
||||
expect(validateDeliveryConformanceContract({ maximumTruePeakDbfs: 1 })).toContain("maximum_true_peak");
|
||||
expect(validateDeliveryConformanceContract({ frameRateTolerance: 0.01 })).toContain("frame_rate_tolerance requires frame_rate");
|
||||
expect(validateDeliveryConformanceContract({ durationToleranceSeconds: 0.01 })).toContain("duration_tolerance_seconds requires duration_seconds");
|
||||
expect(validateDeliveryConformanceContract({ loudnessToleranceLu: 1 })).toContain("loudness_tolerance_lu requires target_lufs");
|
||||
expect(validateDeliveryConformanceContract({ videoCodec: "h264", frameRateTolerance: 0.01 })).toContain("frame_rate_tolerance requires frame_rate");
|
||||
expect(validateDeliveryConformanceContract({ videoCodec: " " })).toContain("non-empty");
|
||||
expect(validateDeliveryConformanceContract({ audioCodec: "" })).toContain("non-empty");
|
||||
});
|
||||
|
||||
it("falls back from unusable frame-rate metadata and does not fail missing measurements", () => {
|
||||
const checks = evaluateDeliveryConformance({
|
||||
format: { bit_rate: "12000000" },
|
||||
streams: [{ codec_type: "video", avg_frame_rate: "0/1", r_frame_rate: "24/1", bit_rate: "N/A" }],
|
||||
}, {
|
||||
frameRate: 24, minimumVideoBitrateKbps: 11000, targetLufs: -23, maximumTruePeakDbfs: -1,
|
||||
}, { integratedLufs: -23, truePeakDbfs: null });
|
||||
expect(checks.find(check => check.id === "frame_rate")?.status).toBe("pass");
|
||||
expect(checks.find(check => check.id === "minimum_video_bitrate")).toMatchObject({
|
||||
status: "not_evaluated", detail: "Video bitrate metadata was unavailable",
|
||||
});
|
||||
expect(checks.find(check => check.id === "integrated_loudness")?.status).toBe("pass");
|
||||
expect(checks.find(check => check.id === "true_peak")?.status).toBe("not_evaluated");
|
||||
});
|
||||
|
||||
it("reports matching and mismatching fields independently", () => {
|
||||
const checks = evaluateDeliveryConformance(probe, {
|
||||
allowedContainerNames: ["mp4"], videoCodec: "h264", audioCodec: "pcm_s24le",
|
||||
width: 1920, height: 720, frameRate: 29.97, frameRateTolerance: 0.01,
|
||||
durationSeconds: 10, durationToleranceSeconds: 0.05,
|
||||
minimumVideoBitrateKbps: 9000, maximumVideoBitrateKbps: 11000,
|
||||
audioSampleRateHz: 48000, audioChannels: 2,
|
||||
});
|
||||
expect(checks.find(check => check.id === "container_demuxer_family")).toMatchObject({
|
||||
status: "pass", detail: expect.stringContaining("do not identify an exact container subtype"),
|
||||
});
|
||||
expect(checks.find(check => check.id === "audio_codec")?.status).toBe("fail");
|
||||
expect(checks.find(check => check.id === "height")?.status).toBe("fail");
|
||||
expect(checks.find(check => check.id === "frame_rate")?.status).toBe("pass");
|
||||
expect(checks.find(check => check.id === "maximum_video_bitrate")?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("fails expected streams that are missing and preserves unavailable loudness", () => {
|
||||
const checks = evaluateDeliveryConformance({ format: {}, streams: [] }, {
|
||||
videoCodec: "prores", audioChannels: 2, targetLufs: -23, maximumTruePeakDbfs: -1,
|
||||
}, null, "No audio stream was available for EBU R128 analysis");
|
||||
expect(checks.find(check => check.id === "video_codec")?.status).toBe("fail");
|
||||
expect(checks.find(check => check.id === "audio_channels")?.status).toBe("fail");
|
||||
expect(checks.find(check => check.id === "integrated_loudness")?.status).toBe("not_evaluated");
|
||||
expect(checks.find(check => check.id === "true_peak")?.status).toBe("not_evaluated");
|
||||
});
|
||||
|
||||
it("evaluates loudness failures and does not evaluate video bitrate without video", () => {
|
||||
const checks = evaluateDeliveryConformance({ format: { format_name: "matroska", bit_rate: "5000000" }, streams: [{ codec_type: "audio", codec_name: "aac" }] }, {
|
||||
allowedContainerNames: ["mov"], minimumVideoBitrateKbps: 1000, maximumVideoBitrateKbps: 2000,
|
||||
targetLufs: -23, loudnessToleranceLu: 0.5, maximumTruePeakDbfs: -1,
|
||||
}, { integratedLufs: -20, truePeakDbfs: -0.5 });
|
||||
expect(checks.find(check => check.id === "minimum_video_bitrate")).toMatchObject({
|
||||
status: "not_evaluated", detail: expect.stringContaining("No video stream"),
|
||||
});
|
||||
expect(checks.find(check => check.id === "maximum_video_bitrate")?.status).toBe("not_evaluated");
|
||||
expect(checks.filter(check => !check.id.includes("video_bitrate")).every(check => check.status === "fail")).toBe(true);
|
||||
|
||||
const missingBitrate = evaluateDeliveryConformance({ format: {}, streams: [{ codec_type: "video", bit_rate: "N/A" }] }, {
|
||||
minimumVideoBitrateKbps: 1000,
|
||||
});
|
||||
expect(missingBitrate.find(check => check.id === "minimum_video_bitrate")).toMatchObject({
|
||||
status: "not_evaluated", detail: "Video bitrate metadata was unavailable",
|
||||
});
|
||||
|
||||
const coverArtOnly = evaluateDeliveryConformance({
|
||||
format: { bit_rate: "5000000" },
|
||||
streams: [{ codec_type: "video", codec_name: "mjpeg", width: 1000, disposition: { attached_pic: 1 } }],
|
||||
}, { videoCodec: "mjpeg", width: 1000, minimumVideoBitrateKbps: 1000 });
|
||||
expect(coverArtOnly.find(check => check.id === "video_codec")?.status).toBe("fail");
|
||||
expect(coverArtOnly.find(check => check.id === "width")?.status).toBe("fail");
|
||||
expect(coverArtOnly.find(check => check.id === "minimum_video_bitrate")?.status).toBe("not_evaluated");
|
||||
});
|
||||
|
||||
it("does not treat unavailable numeric metadata as a mismatch", () => {
|
||||
const checks = evaluateDeliveryConformance({
|
||||
format: { duration: "N/A", bit_rate: "5000000" },
|
||||
streams: [{ codec_type: "video", width: "N/A", height: "N/A", avg_frame_rate: "0/0", r_frame_rate: "0/0" }],
|
||||
}, {
|
||||
width: 1920, height: 1080, frameRate: 24, durationSeconds: 10,
|
||||
minimumVideoBitrateKbps: 1000,
|
||||
});
|
||||
for (const id of ["width", "height", "frame_rate", "duration", "minimum_video_bitrate"]) {
|
||||
expect(checks.find(check => check.id === id)?.status).toBe("not_evaluated");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not evaluate video bitrate from aggregate format bitrate", () => {
|
||||
const checks = evaluateDeliveryConformance({
|
||||
format: { bit_rate: "5000000" },
|
||||
streams: [{ codec_type: "video", bit_rate: "1000000" }],
|
||||
}, { minimumVideoBitrateKbps: 3000, maximumVideoBitrateKbps: 3000 });
|
||||
expect(checks.find(check => check.id === "minimum_video_bitrate")?.status).toBe("fail");
|
||||
expect(checks.find(check => check.id === "maximum_video_bitrate")?.status).toBe("pass");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verify_delivery_conformance boundary", () => {
|
||||
const tool = getExportTools({ tempDir: "/tmp/test" }).verify_delivery_conformance;
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("reports its local filesystem-only operational boundary", () => {
|
||||
expect(tool.operationalCapability).toMatchObject({
|
||||
backend: "local", backends: ["local"], authority: "filesystem",
|
||||
verificationBoundary: "local_filesystem", hostVerificationRequired: false,
|
||||
minimumPremiereVersion: null,
|
||||
});
|
||||
});
|
||||
|
||||
function mediaFile() {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-delivery-conformance-"));
|
||||
const mediaPath = join(directory, "delivery.mp4");
|
||||
writeFileSync(mediaPath, "delivery-fixture");
|
||||
return mediaPath;
|
||||
}
|
||||
|
||||
it("returns an explicit failure for a missing baseline file", async () => {
|
||||
const missingPath = join(tmpdir(), `missing-delivery-${Date.now()}.mp4`);
|
||||
await expect(tool.handler({ output_path: missingPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("not found on disk") });
|
||||
expect(mockedExecFileAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a complete local conformance report", async () => {
|
||||
const mediaPath = mediaFile();
|
||||
mockedExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify(probe), stderr: "" })
|
||||
.mockResolvedValueOnce({ stdout: "", stderr: "Summary:\n Integrated loudness:\n I: -23.0 LUFS\n True peak:\n Peak: -2.0 dBFS" });
|
||||
await expect(tool.handler({
|
||||
output_path: mediaPath, allowed_container_names: ["mp4"], video_codec: "h264",
|
||||
frame_rate: 29.97, frame_rate_tolerance: 0.01, target_lufs: -23,
|
||||
maximum_true_peak_dbfs: -1,
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { mediaPath, conforms: true, evaluated: 5, notEvaluated: 0 },
|
||||
});
|
||||
expect(mockedExecFileAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockedExecFileAsync).toHaveBeenNthCalledWith(1, "ffprobe", expect.arrayContaining(["-protocol_whitelist", "file,crypto,data"]), expect.any(Object));
|
||||
expect(mockedExecFileAsync).toHaveBeenNthCalledWith(2, "ffmpeg", expect.arrayContaining(["-protocol_whitelist", "file,crypto,data", "-map", "0:a:0"]), expect.any(Object));
|
||||
});
|
||||
|
||||
it("keeps ffprobe process failures explicit", async () => {
|
||||
const mediaPath = mediaFile();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("probe failed"), { stderr: "invalid media" }));
|
||||
await expect(tool.handler({ output_path: mediaPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("invalid media") });
|
||||
|
||||
const emptyStderrPath = mediaFile();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("probe failed"), { stderr: "" }));
|
||||
await expect(tool.handler({ output_path: emptyStderrPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("probe failed") });
|
||||
|
||||
const noisyStderrPath = mediaFile();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("probe failed"), { stderr: "x".repeat(5_000_000) }));
|
||||
const result = await tool.handler({ output_path: noisyStderrPath, video_codec: "h264" });
|
||||
expect(result).toMatchObject({ success: false, error: expect.stringContaining("ffprobe delivery conformance inspection failed:") });
|
||||
expect(result.error).toHaveLength(4_096 + "ffprobe delivery conformance inspection failed: ".length);
|
||||
expect(result.error.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports ffprobe timeouts and refuses a file that changes during inspection", async () => {
|
||||
const timedOutPath = mediaFile();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("timeout"), { killed: true }));
|
||||
await expect(tool.handler({ output_path: timedOutPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("timed out") });
|
||||
|
||||
const changingPath = mediaFile();
|
||||
mockedExecFileAsync.mockImplementationOnce(async () => {
|
||||
appendFileSync(changingPath, "changed");
|
||||
return { stdout: JSON.stringify(probe), stderr: "" };
|
||||
});
|
||||
await expect(tool.handler({ output_path: changingPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("changed during") });
|
||||
|
||||
const disappearingPath = mediaFile();
|
||||
mockedExecFileAsync.mockImplementationOnce(async () => {
|
||||
unlinkSync(disappearingPath);
|
||||
return { stdout: JSON.stringify(probe), stderr: "" };
|
||||
});
|
||||
await expect(tool.handler({ output_path: disappearingPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("changed during") });
|
||||
|
||||
const rejectedAfterDeletionPath = mediaFile();
|
||||
mockedExecFileAsync.mockImplementationOnce(async () => {
|
||||
unlinkSync(rejectedAfterDeletionPath);
|
||||
throw Object.assign(new Error("probe failed"), { stderr: "invalid media" });
|
||||
});
|
||||
await expect(tool.handler({ output_path: rejectedAfterDeletionPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("changed during") });
|
||||
|
||||
const invalidAfterChangePath = mediaFile();
|
||||
mockedExecFileAsync.mockImplementationOnce(async () => {
|
||||
appendFileSync(invalidAfterChangePath, "changed");
|
||||
return { stdout: "null", stderr: "" };
|
||||
});
|
||||
await expect(tool.handler({ output_path: invalidAfterChangePath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("changed during") });
|
||||
});
|
||||
|
||||
it("handles missing tools, invalid probe JSON, and unavailable loudness without overclaiming", async () => {
|
||||
const missingToolPath = mediaFile();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" }));
|
||||
await expect(tool.handler({ output_path: missingToolPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("ffprobe was not found") });
|
||||
|
||||
const invalidJsonPath = mediaFile();
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "null", stderr: "" });
|
||||
await expect(tool.handler({ output_path: invalidJsonPath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("invalid delivery report") });
|
||||
|
||||
const incompleteProbePath = mediaFile();
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: JSON.stringify({ format: {} }), stderr: "" });
|
||||
await expect(tool.handler({ output_path: incompleteProbePath, video_codec: "h264" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("invalid delivery report") });
|
||||
|
||||
const silentPath = mediaFile();
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: JSON.stringify({ format: {}, streams: [] }), stderr: "" });
|
||||
await expect(tool.handler({ output_path: silentPath, target_lufs: -23 }))
|
||||
.resolves.toMatchObject({ success: true, data: { conforms: false, evaluated: 0, notEvaluated: 1 } });
|
||||
|
||||
const unreadableAudioPath = mediaFile();
|
||||
mockedExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify(probe), stderr: "" })
|
||||
.mockRejectedValueOnce(Object.assign(new Error("decode"), { stderr: "no measurement" }));
|
||||
await expect(tool.handler({ output_path: unreadableAudioPath, target_lufs: -23 }))
|
||||
.resolves.toMatchObject({ success: true, data: { conforms: false, notEvaluated: 1 } });
|
||||
|
||||
const partialAudioPath = mediaFile();
|
||||
mockedExecFileAsync
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify(probe), stderr: "" })
|
||||
.mockRejectedValueOnce(Object.assign(new Error("decode"), { stderr: "Summary:\n Integrated loudness:\n I: -23.0 LUFS\n True peak:\n Peak: -2.0 dBFS" }));
|
||||
await expect(tool.handler({ output_path: partialAudioPath, target_lufs: -23, maximum_true_peak_dbfs: -1 }))
|
||||
.resolves.toMatchObject({ success: true, data: { conforms: false, evaluated: 0, notEvaluated: 2 } });
|
||||
});
|
||||
});
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
deliveryFileChangedDuringHash,
|
||||
getExportTools,
|
||||
inspectExportPresetFile,
|
||||
verifyDeliveryFile,
|
||||
} from "../../src/tools/export.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
function temporaryFile(name: string, contents: string | Buffer): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-delivery-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const path = join(directory, name);
|
||||
writeFileSync(path, contents);
|
||||
return path;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("delivery file verification", () => {
|
||||
it("returns deterministic SHA-256 metadata", async () => {
|
||||
const path = temporaryFile("delivery.mov", "premiere delivery\n");
|
||||
const result = await verifyDeliveryFile(path);
|
||||
expect(result).toMatchObject({
|
||||
path,
|
||||
exists: true,
|
||||
regularFile: true,
|
||||
sizeBytes: 18,
|
||||
checksum: {
|
||||
algorithm: "sha256",
|
||||
value: "beabfc0259a7213ae09fffc062f2bdfc69fc0e702ab19ff44b50259b1f02d843",
|
||||
},
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports expected checksum and size mismatches without claiming validity", async () => {
|
||||
const path = temporaryFile("delivery.mp4", "video");
|
||||
const result = await verifyDeliveryFile(path, {
|
||||
expectedChecksum: "0".repeat(64),
|
||||
expectedSizeBytes: 99,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
matchesExpectedChecksum: false,
|
||||
matchesExpectedSize: false,
|
||||
valid: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects a file replacement or write during hashing", () => {
|
||||
const snapshot = { dev: 1, ino: 2, size: 100, mtimeMs: 500 };
|
||||
expect(deliveryFileChangedDuringHash(snapshot, snapshot)).toBe(false);
|
||||
expect(deliveryFileChangedDuringHash(snapshot, { ...snapshot, size: 101 })).toBe(true);
|
||||
expect(deliveryFileChangedDuringHash(snapshot, { ...snapshot, mtimeMs: 501 })).toBe(true);
|
||||
expect(deliveryFileChangedDuringHash(snapshot, { ...snapshot, ino: 3 })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns mismatch details through the MCP tool as a completed verification", async () => {
|
||||
const path = temporaryFile("delivery.mp4", "video");
|
||||
const result = await getExportTools({}).verify_delivery_file.handler({
|
||||
output_path: path,
|
||||
expected_checksum: "0".repeat(64),
|
||||
expected_size_bytes: 99,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
path,
|
||||
valid: false,
|
||||
matchesExpectedChecksum: false,
|
||||
matchesExpectedSize: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing, empty, and invalid delivery targets", async () => {
|
||||
await expect(verifyDeliveryFile("/definitely/missing/delivery.mov")).rejects.toThrow("does not exist");
|
||||
const empty = temporaryFile("empty.mov", "");
|
||||
await expect(verifyDeliveryFile(empty)).rejects.toThrow("expected at least 1");
|
||||
const path = temporaryFile("delivery.mov", "content");
|
||||
await expect(verifyDeliveryFile(path, { algorithm: "md5" as never })).rejects.toThrow("sha256");
|
||||
await expect(verifyDeliveryFile(path, { expectedChecksum: "not-hex" })).rejects.toThrow("64-character");
|
||||
});
|
||||
});
|
||||
|
||||
describe("export preset inspection", () => {
|
||||
it("accepts a non-empty regular .epr file", () => {
|
||||
const path = temporaryFile("YouTube.epr", "<preset />");
|
||||
expect(inspectExportPresetFile(path)).toMatchObject({
|
||||
path,
|
||||
exists: true,
|
||||
regularFile: true,
|
||||
sizeBytes: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects wrong extensions and empty presets", () => {
|
||||
const wrongExtension = temporaryFile("preset.xml", "<preset />");
|
||||
expect(() => inspectExportPresetFile(wrongExtension)).toThrow(".epr");
|
||||
const empty = temporaryFile("empty.epr", "");
|
||||
expect(() => inspectExportPresetFile(empty)).toThrow("empty");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockedExecFile, mockedExecFileAsync } = vi.hoisted(() => {
|
||||
const mockedExecFileAsync = vi.fn();
|
||||
const mockedExecFile = vi.fn();
|
||||
mockedExecFile[Symbol.for("nodejs.util.promisify.custom")] = mockedExecFileAsync;
|
||||
return { mockedExecFile, mockedExecFileAsync };
|
||||
});
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({ sendCommand: vi.fn() }));
|
||||
vi.mock("node:child_process", () => ({ execFile: mockedExecFile }));
|
||||
|
||||
import { analyzeBeatPcm, getAudioTools } from "../../src/tools/audio.js";
|
||||
|
||||
const tools = getAudioTools({ tempDir: "/tmp/beat-tests" });
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function pulsePcm(seconds = 12) {
|
||||
const sampleRate = 200;
|
||||
const samples = new Int16Array(sampleRate * seconds);
|
||||
for (let at = 0; at < samples.length; at += sampleRate / 2) {
|
||||
for (let width = 0; width < 4; width++) samples[at + width] = 30_000;
|
||||
}
|
||||
return { sampleRate, samples };
|
||||
}
|
||||
|
||||
function sampleBuffer(samples: Int16Array) {
|
||||
const buffer = Buffer.alloc(samples.length * 2);
|
||||
for (let index = 0; index < samples.length; index++) buffer.writeInt16LE(samples[index], index * 2);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function createMediaFixture() {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-beats-"));
|
||||
const mediaPath = join(directory, "music.wav");
|
||||
writeFileSync(mediaPath, "audio-fixture");
|
||||
return mediaPath;
|
||||
}
|
||||
|
||||
describe("detect_beats analysis", () => {
|
||||
it("recovers a phase-aligned 120 BPM pulse train", () => {
|
||||
const { sampleRate, samples } = pulsePcm();
|
||||
const result = analyzeBeatPcm(samples, sampleRate);
|
||||
expect(result.bpm).toBeCloseTo(120, 0);
|
||||
expect(result.confidence).toBeGreaterThan(0.5);
|
||||
expect(result.beatTimesSeconds.slice(0, 4)).toEqual([0, 0.5, 1, 1.5]);
|
||||
});
|
||||
|
||||
it("rejects short or silent audio instead of fabricating a grid", () => {
|
||||
expect(() => analyzeBeatPcm(new Int16Array(100), 200)).toThrow("at least two seconds");
|
||||
expect(() => analyzeBeatPcm(new Int16Array(800), 200)).toThrow("No repeating beat evidence");
|
||||
});
|
||||
|
||||
it("validates each input before invoking FFmpeg", async () => {
|
||||
await expect(tools.detect_beats.handler({ media_path: "" }))
|
||||
.resolves.toMatchObject({ success: false, error: "media_path is required." });
|
||||
await expect(tools.detect_beats.handler({ media_path: "missing.wav", max_beats: 0 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("max_beats") });
|
||||
await expect(tools.detect_beats.handler({ media_path: "definitely-missing.wav" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("not found") });
|
||||
expect(mockedExecFileAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("decodes a bounded stream and reports truncation without mutating Premiere", async () => {
|
||||
const mediaPath = createMediaFixture();
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: sampleBuffer(pulsePcm().samples), stderr: Buffer.alloc(0) });
|
||||
await expect(tools.detect_beats.handler({ media_path: mediaPath, max_beats: 3 })).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { bpm: 120, beatTimesSeconds: [0, 0.5, 1], beatTimesTruncated: true, reliable: true },
|
||||
});
|
||||
expect(mockedExecFileAsync).toHaveBeenCalledTimes(1);
|
||||
const args = mockedExecFileAsync.mock.calls[0][1] as string[];
|
||||
expect(args).toEqual(expect.arrayContaining(["-t", "1800", "-ar", "200", "pipe:1"]));
|
||||
});
|
||||
|
||||
it("distinguishes missing FFmpeg, timeout, and decode failures", async () => {
|
||||
const mediaPath = createMediaFixture();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" }));
|
||||
await expect(tools.detect_beats.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("not found on PATH") });
|
||||
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("timeout"), { killed: true }));
|
||||
await expect(tools.detect_beats.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("timed out") });
|
||||
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("decode"), { stderr: "invalid stream" }));
|
||||
await expect(tools.detect_beats.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("invalid stream") });
|
||||
});
|
||||
});
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import {
|
||||
getAudioTools,
|
||||
invertToSegments,
|
||||
mapSilenceIntervalsToTimeline,
|
||||
parseDurationSeconds,
|
||||
parseSilenceDetectOutput,
|
||||
} from "../../src/tools/audio.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const tools = getAudioTools({ tempDir: "/tmp/test", timeoutMs: 1000 });
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("parseSilenceDetectOutput", () => {
|
||||
it("pairs each silence_start with its silence_end", () => {
|
||||
const stderr = [
|
||||
"[silencedetect @ 0x1] silence_start: 2.5",
|
||||
"[silencedetect @ 0x1] silence_end: 6.25 | silence_duration: 3.75",
|
||||
"[silencedetect @ 0x1] silence_start: 10",
|
||||
"[silencedetect @ 0x1] silence_end: 12.5 | silence_duration: 2.5",
|
||||
].join("\n");
|
||||
|
||||
expect(parseSilenceDetectOutput(stderr, 20)).toEqual([
|
||||
{ start: 2.5, end: 6.25, duration: 3.75 },
|
||||
{ start: 10, end: 12.5, duration: 2.5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("closes a silence that runs to the end of the file at the media duration", () => {
|
||||
// ffmpeg emits no silence_end when the file ends mid-silence.
|
||||
const stderr = [
|
||||
"[silencedetect @ 0x1] silence_start: 2",
|
||||
"[silencedetect @ 0x1] silence_end: 5 | silence_duration: 3",
|
||||
"[silencedetect @ 0x1] silence_start: 17.5",
|
||||
].join("\n");
|
||||
|
||||
expect(parseSilenceDetectOutput(stderr, 20)).toEqual([
|
||||
{ start: 2, end: 5, duration: 3 },
|
||||
{ start: 17.5, end: 20, duration: 2.5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops an unterminated silence when the duration is unknown", () => {
|
||||
const stderr = "[silencedetect @ 0x1] silence_start: 17.5";
|
||||
expect(parseSilenceDetectOutput(stderr, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("clamps a negative silence_start to zero", () => {
|
||||
const stderr = [
|
||||
"[silencedetect @ 0x1] silence_start: -0.001",
|
||||
"[silencedetect @ 0x1] silence_end: 4 | silence_duration: 4",
|
||||
].join("\n");
|
||||
expect(parseSilenceDetectOutput(stderr, 20)[0].start).toBe(0);
|
||||
});
|
||||
|
||||
it("returns nothing for output containing no silence markers", () => {
|
||||
expect(parseSilenceDetectOutput("frame= 480 fps=0.0 q=-0.0", 20)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDurationSeconds", () => {
|
||||
it("reads the duration banner", () => {
|
||||
expect(parseDurationSeconds(" Duration: 00:00:20.00, start: 0.000000")).toBe(20);
|
||||
});
|
||||
|
||||
it("handles hours and fractional seconds", () => {
|
||||
expect(parseDurationSeconds(" Duration: 01:02:03.50, bitrate: 1000 kb/s")).toBeCloseTo(3723.5, 3);
|
||||
});
|
||||
|
||||
it("returns null when no duration is present", () => {
|
||||
expect(parseDurationSeconds("no banner here")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invertToSegments", () => {
|
||||
it("returns the ranges between silences", () => {
|
||||
const silences = [
|
||||
{ start: 2, end: 5, duration: 3 },
|
||||
{ start: 10, end: 12, duration: 2 },
|
||||
];
|
||||
expect(invertToSegments(silences, 20)).toEqual([
|
||||
{ start: 0, end: 2, duration: 2 },
|
||||
{ start: 5, end: 10, duration: 5 },
|
||||
{ start: 12, end: 20, duration: 8 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits a leading segment when the media opens on silence", () => {
|
||||
const silences = [{ start: 0, end: 4, duration: 4 }];
|
||||
expect(invertToSegments(silences, 20)).toEqual([
|
||||
{ start: 4, end: 20, duration: 16 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits a trailing segment when the media ends on silence", () => {
|
||||
const silences = [{ start: 16, end: 20, duration: 4 }];
|
||||
expect(invertToSegments(silences, 20)).toEqual([
|
||||
{ start: 0, end: 16, duration: 16 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the whole media when there is no silence", () => {
|
||||
expect(invertToSegments([], 20)).toEqual([
|
||||
{ start: 0, end: 20, duration: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns nothing usable when the duration is unknown", () => {
|
||||
expect(invertToSegments([], null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("collapses fully silent media to no segments", () => {
|
||||
const silences = [{ start: 0, end: 20, duration: 20 }];
|
||||
expect(invertToSegments(silences, 20)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapSilenceIntervalsToTimeline", () => {
|
||||
it("clips source ranges and maps the retained intervals onto a 1x placement", () => {
|
||||
expect(mapSilenceIntervalsToTimeline([
|
||||
{ start: 0, end: 4, duration: 4 },
|
||||
{ start: 7, end: 12, duration: 5 },
|
||||
], {
|
||||
sourceInSeconds: 2,
|
||||
sourceOutSeconds: 10,
|
||||
timelineStartSeconds: 100,
|
||||
maxCandidates: 50,
|
||||
})).toEqual({
|
||||
totalCandidateCount: 2,
|
||||
truncated: false,
|
||||
candidates: [
|
||||
{
|
||||
sourceStartSeconds: 2,
|
||||
sourceEndSeconds: 4,
|
||||
durationSeconds: 2,
|
||||
timelineStartSeconds: 100,
|
||||
timelineEndSeconds: 102,
|
||||
},
|
||||
{
|
||||
sourceStartSeconds: 7,
|
||||
sourceEndSeconds: 10,
|
||||
durationSeconds: 3,
|
||||
timelineStartSeconds: 105,
|
||||
timelineEndSeconds: 108,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps counting bounded candidates after its response limit", () => {
|
||||
const result = mapSilenceIntervalsToTimeline([
|
||||
{ start: 1, end: 2, duration: 1 },
|
||||
{ start: 3, end: 4, duration: 1 },
|
||||
], {
|
||||
sourceInSeconds: 0,
|
||||
sourceOutSeconds: 10,
|
||||
timelineStartSeconds: 0,
|
||||
maxCandidates: 1,
|
||||
});
|
||||
expect(result.totalCandidateCount).toBe(2);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.candidates).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detect_silence argument handling", () => {
|
||||
it("requires either a media path or a project item", async () => {
|
||||
const result = await tools.detect_silence.handler({});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("media_path or project_item_id");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a positive noise threshold", async () => {
|
||||
const result = await tools.detect_silence.handler({
|
||||
media_path: "/tmp/whatever.mp4",
|
||||
noise_threshold_db: 12,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("dBFS");
|
||||
});
|
||||
|
||||
it("rejects a non-positive minimum duration", async () => {
|
||||
const result = await tools.detect_silence.handler({
|
||||
media_path: "/tmp/whatever.mp4",
|
||||
min_duration_seconds: 0,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("greater than 0");
|
||||
});
|
||||
|
||||
it("reports a missing file before shelling out to ffmpeg", async () => {
|
||||
const result = await tools.detect_silence.handler({
|
||||
media_path: "/tmp/definitely-not-here-9f3a2b.mp4",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("not found on disk");
|
||||
});
|
||||
|
||||
it("resolves a project item's media path through the bridge", async () => {
|
||||
mockedSendCommand.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: "Project item not found: missing-clip",
|
||||
});
|
||||
const result = await tools.detect_silence.handler({
|
||||
project_item_id: "missing-clip",
|
||||
});
|
||||
expect(mockedSendCommand).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSendCommand.mock.calls[0][0]).toContain("getMediaPath");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("missing-clip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan_silence_review_markers argument handling", () => {
|
||||
const plan = tools.plan_silence_review_markers;
|
||||
|
||||
it("rejects invalid placement or response bounds before decoding media", async () => {
|
||||
await expect(plan.handler({
|
||||
media_path: "/tmp/does-not-matter.mov",
|
||||
timeline_start_seconds: -1,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("timeline_start_seconds") });
|
||||
await expect(plan.handler({
|
||||
media_path: "/tmp/does-not-matter.mov",
|
||||
timeline_start_seconds: 0,
|
||||
source_in_seconds: 5,
|
||||
source_out_seconds: 5,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("source_out_seconds") });
|
||||
await expect(plan.handler({
|
||||
media_path: "/tmp/does-not-matter.mov",
|
||||
timeline_start_seconds: 0,
|
||||
max_candidates: 201,
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("max_candidates") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("detect_silence distribution prerequisites", () => {
|
||||
it("installs ffmpeg in the production container", () => {
|
||||
const dockerfile = readFileSync(join(process.cwd(), "Dockerfile"), "utf8");
|
||||
expect(dockerfile).toMatch(/apk add --no-cache ffmpeg/);
|
||||
});
|
||||
|
||||
it("documents the local ffmpeg prerequisite", () => {
|
||||
const readme = readFileSync(join(process.cwd(), "README.md"), "utf8");
|
||||
expect(readme).toContain("`detect_silence`");
|
||||
expect(readme).toContain("winget install Gyan.FFmpeg");
|
||||
expect(readme).toContain("brew install ffmpeg");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { analyzeDialogueEditCandidates, getDialogueAnalysisTools } from "../../src/tools/dialogue-analysis.js";
|
||||
|
||||
const revision = `sha256:${"a".repeat(64)}`;
|
||||
describe("dialogue candidate analysis", () => {
|
||||
it("returns stable review candidates without applying edits", () => {
|
||||
const input = { segments: [
|
||||
{ id: "a", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 0, end_seconds: 1, text: "Um hello world" },
|
||||
{ id: "b", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 1, end_seconds: 2, text: "hello world again" },
|
||||
{ id: "c", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 2, end_seconds: 3, text: "hello world again" },
|
||||
], fillerWords: ["um"], silenceRanges: [{ source_project_item_id: "clip", start_seconds: 3, end_seconds: 4 }], minimumSilenceSeconds: .7 };
|
||||
const first = analyzeDialogueEditCandidates(input), second = analyzeDialogueEditCandidates(input);
|
||||
expect(first.analysis_revision).toBe(second.analysis_revision);
|
||||
expect(first.candidates.map((x) => x.reason)).toEqual(["filler_word", "repeated_phrase", "long_silence"]);
|
||||
expect(first.applied).toBe(false);
|
||||
});
|
||||
it("rejects unbound transcript input", () => expect(() => analyzeDialogueEditCandidates({ segments: [{ id: "a", source_project_item_id: "clip", transcript_revision: "bad", start_seconds: 0, end_seconds: 1, text: "hello" }] })).toThrow(/sha256/));
|
||||
it.each([
|
||||
[[], /between 1/], [[null], /must be an object/],
|
||||
[[{ id: "a", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 0, end_seconds: 1, text: "x", extra: 1 }], /unknown field/],
|
||||
[[{ id: "a", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 1, end_seconds: 1, text: "x" }], /greater/],
|
||||
[[{ id: "a", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 0, end_seconds: 1, text: 1 }], /text must/],
|
||||
])("rejects malformed segments", (segments, message) => expect(() => analyzeDialogueEditCandidates({ segments })).toThrow(message));
|
||||
it("validates optional analysis controls", () => {
|
||||
const segments = [{ id: "a", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 0, end_seconds: 1, text: "okay" }];
|
||||
expect(() => analyzeDialogueEditCandidates({ segments, fillerWords: "um" })).toThrow(/filler_words/);
|
||||
expect(() => analyzeDialogueEditCandidates({ segments, fillerWords: ["um", "um"] })).toThrow(/duplicates/);
|
||||
expect(() => analyzeDialogueEditCandidates({ segments, minimumSilenceSeconds: 40 })).toThrow(/between/);
|
||||
expect(() => analyzeDialogueEditCandidates({ segments, silenceRanges: "bad" })).toThrow(/silence_ranges/);
|
||||
expect(() => analyzeDialogueEditCandidates({ segments, silenceRanges: [null] })).toThrow(/must be an object/);
|
||||
expect(() => analyzeDialogueEditCandidates({ segments, silenceRanges: [{ source_project_item_id: "clip", start_seconds: 0, end_seconds: 1, extra: true }] })).toThrow(/unknown field/);
|
||||
});
|
||||
it("deduplicates time removal and ignores short silence", () => {
|
||||
const segments = [
|
||||
{ id: "a", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 0, end_seconds: 2, text: "um okay" },
|
||||
{ id: "b", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 3, end_seconds: 4, text: "different" },
|
||||
];
|
||||
const result = analyzeDialogueEditCandidates({ segments, fillerWords: ["um"], silenceRanges: [{ source_project_item_id: "clip", start_seconds: 1, end_seconds: 3 }, { source_project_item_id: "clip", start_seconds: 5, end_seconds: 5.2 }], minimumSilenceSeconds: .5 });
|
||||
expect(result.projected_removed_seconds).toBe(3);
|
||||
expect(result.candidate_count).toBe(2);
|
||||
});
|
||||
it("rejects duplicate ids and invalid labels", () => {
|
||||
const base = { id: "a", source_project_item_id: "clip", transcript_revision: revision, start_seconds: 0, end_seconds: 1, text: "x" };
|
||||
expect(() => analyzeDialogueEditCandidates({ segments: [base, { ...base, start_seconds: 1, end_seconds: 2 }] })).toThrow(/duplicate id/);
|
||||
expect(() => analyzeDialogueEditCandidates({ segments: [{ ...base, speaker_label: "" }] })).toThrow(/speaker_label/);
|
||||
});
|
||||
it("bounds very large candidate sets", () => {
|
||||
const segments = Array.from({ length: 520 }, (_, index) => ({ id: String(index), source_project_item_id: "clip", transcript_revision: revision, start_seconds: index, end_seconds: index + .5, text: "um repeated phrase" }));
|
||||
const result = analyzeDialogueEditCandidates({ segments, fillerWords: ["um"] });
|
||||
expect(result.candidate_count).toBe(512);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
it("normalizes handler failures", async () => {
|
||||
expect((await getDialogueAnalysisTools().analyze_dialogue_edit_candidates.handler({ segments: [] })).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({ sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }) }));
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getInspectionTools } from "../../src/tools/inspection.js";
|
||||
|
||||
const mocked = vi.mocked(sendCommand);
|
||||
const tool = getInspectionTools({ tempDir: "/tmp/test" }).inspect_edit_readiness;
|
||||
const reviewReport = getInspectionTools({ tempDir: "/tmp/test" }).inspect_sequence_review_report;
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("inspect_edit_readiness", () => {
|
||||
it("checks five structural defect classes in one read-only bridge call", async () => {
|
||||
await tool.handler({ primary_video_track: 0, maximum_scale_percent: 125 });
|
||||
expect(mocked).toHaveBeenCalledTimes(1);
|
||||
const script = mocked.mock.calls[0][0];
|
||||
for (const marker of ["PRIMARY_TRACK_GAP", "EMPTY_VIDEO_TIMELINE", "DISABLED_CLIP", "MUTED_TRACK", "EXCESSIVE_SCALE"]) {
|
||||
expect(script).toContain(marker);
|
||||
}
|
||||
expect(script).not.toMatch(/setMute\(|setValue\(|remove\(|insertClip\(/);
|
||||
});
|
||||
|
||||
it("rejects invalid thresholds locally", async () => {
|
||||
await expect(tool.handler({ primary_video_track: -1 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tool.handler({ maximum_scale_percent: 0 })).resolves.toMatchObject({ success: false });
|
||||
expect(mocked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("inspect_sequence_review_report", () => {
|
||||
it("builds one read-only, privacy-bounded handoff report", async () => {
|
||||
await reviewReport.handler({ sequence_id: "Final Cut", primary_video_track: 1, max_markers: 25 });
|
||||
expect(mocked).toHaveBeenCalledTimes(1);
|
||||
const script = mocked.mock.calls[0][0];
|
||||
for (const marker of [
|
||||
"sequence-review-handoff",
|
||||
"PRIMARY_VIDEO_TRACK_UNAVAILABLE",
|
||||
"DISABLED_CLIP",
|
||||
"MUTED_TRACK",
|
||||
"isOffline",
|
||||
"markersTruncated",
|
||||
]) {
|
||||
expect(script).toContain(marker);
|
||||
}
|
||||
expect(script).not.toContain("getMediaPath");
|
||||
expect(script).not.toContain("entry.comments");
|
||||
expect(script).not.toMatch(/setValue\(|remove\(|insertClip\(|save\(/);
|
||||
});
|
||||
|
||||
it("includes private marker comments only after explicit opt-in", async () => {
|
||||
await reviewReport.handler({ include_marker_comments: true });
|
||||
expect(mocked.mock.calls[0][0]).toContain("entry.comments");
|
||||
});
|
||||
|
||||
it("rejects invalid bounded input before using the bridge", async () => {
|
||||
await expect(reviewReport.handler({ primary_video_track: -1 })).resolves.toMatchObject({ success: false });
|
||||
await expect(reviewReport.handler({ max_markers: 201 })).resolves.toMatchObject({ success: false });
|
||||
expect(mocked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../src/bridge/file-bridge.js")>();
|
||||
return { ...actual, sendCommand: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("../../src/telemetry.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../src/telemetry.js")>();
|
||||
return { ...actual, captureActivationEvent: vi.fn() };
|
||||
});
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getHealthTools } from "../../src/tools/health.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("health connection diagnostics", () => {
|
||||
it("returns an explicit local feature-support report and safely surfaces invalid version input", async () => {
|
||||
const tools = getHealthTools(
|
||||
{ tempDir: "/tmp/health" },
|
||||
undefined,
|
||||
() => ({}),
|
||||
);
|
||||
await expect(tools.get_advanced_feature_support.handler({
|
||||
backend: "uxp",
|
||||
premiere_version: "26.3.0",
|
||||
frameio_entitled: true,
|
||||
generative_ai_entitled: true,
|
||||
network_available: true,
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
context: { backend: "uxp", premiereVersion: "26.3.0" },
|
||||
features: { productions: { staticEligibility: { eligible: true } } },
|
||||
},
|
||||
});
|
||||
await expect(tools.get_advanced_feature_support.handler({
|
||||
premiere_version: "26.beta",
|
||||
})).resolves.toEqual({
|
||||
success: false,
|
||||
error: "premiere_version must contain only numeric version components",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an unavailable UXP bridge without falling back to CEP", async () => {
|
||||
const telemetry = { enabled: true, capture: vi.fn(), shutdown: vi.fn() };
|
||||
const result = await getHealthTools({ tempDir: "/tmp/health" }, undefined, undefined, { telemetry })
|
||||
.verify_premiere_connection.handler({ backend: "uxp" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
backend: "uxp",
|
||||
overall: "needs_attention",
|
||||
components: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "premiere_connector", state: "needs_attention" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
expect(telemetry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a malformed UXP state response as a reachable host with no open project", async () => {
|
||||
const request = vi.fn().mockResolvedValue(null);
|
||||
const result = await getHealthTools(
|
||||
{ tempDir: "/tmp/health" },
|
||||
undefined,
|
||||
undefined,
|
||||
{ uxpBridge: { request } as any },
|
||||
).verify_premiere_connection.handler({ backend: "uxp" });
|
||||
|
||||
expect(request).toHaveBeenCalledWith("state.get");
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
backend: "uxp",
|
||||
overall: "needs_attention",
|
||||
components: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "premiere_connector", state: "ready" }),
|
||||
expect.objectContaining({ id: "active_project", state: "needs_attention" }),
|
||||
expect.objectContaining({ id: "active_sequence", state: "needs_attention" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an unreachable UXP bridge when its read-only request rejects", async () => {
|
||||
const request = vi.fn().mockRejectedValue(new Error("panel closed"));
|
||||
const result = await getHealthTools(
|
||||
{ tempDir: "/tmp/health" },
|
||||
undefined,
|
||||
undefined,
|
||||
{ uxpBridge: { request } as any },
|
||||
).verify_premiere_connection.handler({ backend: "uxp" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: { backend: "uxp", overall: "needs_attention" },
|
||||
});
|
||||
});
|
||||
|
||||
it("handles failed and thrown CEP probes as unavailable diagnostics", async () => {
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: false, error: "CEP unavailable" });
|
||||
const tools = getHealthTools({ tempDir: "/tmp/health" });
|
||||
await expect(tools.verify_premiere_connection.handler({ backend: "cep" }))
|
||||
.resolves.toMatchObject({ success: true, data: { backend: "cep", overall: "needs_attention" } });
|
||||
|
||||
mockedSendCommand.mockRejectedValueOnce(new Error("bridge directory unreadable"));
|
||||
await expect(tools.verify_premiere_connection.handler({ backend: "cep" }))
|
||||
.resolves.toMatchObject({ success: true, data: { backend: "cep", overall: "needs_attention" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({ sendCommand: vi.fn().mockResolvedValue({ success: true }) }));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getEffectsTools } from "../../src/tools/effects.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const tool = getEffectsTools({ tempDir: "/tmp/test" }).inspect_stabilizer_status;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("inspect_stabilizer_status", () => {
|
||||
it("builds a read-only all-clips inspection with an explicit unknown-state boundary", async () => {
|
||||
await tool.handler({});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('requestedNodeId = ""');
|
||||
expect(script).toContain("analysisStatus = \"unknown\"");
|
||||
expect(script).toContain("unknown is not proof of a completed solve");
|
||||
expect(script).not.toContain("setValue(");
|
||||
expect(script).not.toContain("addVideoEffect(");
|
||||
});
|
||||
|
||||
it("escapes and limits inspection to a requested video clip", async () => {
|
||||
await tool.handler({ node_id: 'clip"1' });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('requestedNodeId = "clip\\\"1"');
|
||||
expect(script).toContain("__findClip(requestedNodeId)");
|
||||
expect(script).toContain('found.trackType !== "video"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareCmx3600Edls,
|
||||
getInterchangeAnalysisTools,
|
||||
inspectFcpxml,
|
||||
parseCmx3600Edl,
|
||||
timecodeToFrames,
|
||||
validateCmx3600Edl,
|
||||
} from "../../src/tools/interchange-analysis.js";
|
||||
|
||||
const tools = getInterchangeAnalysisTools({ tempDir: "/tmp/interchange-analysis" });
|
||||
|
||||
const BASE_EDL = [
|
||||
"TITLE: Round trip",
|
||||
"FCM: NON-DROP FRAME",
|
||||
"001 CAM_A V C 00:00:00:00 00:00:01:00 01:00:00:00 01:00:01:00",
|
||||
"002 CAM_B V D 024 00:00:01:00 00:00:02:00 01:00:01:00 01:00:02:00",
|
||||
].join("\n");
|
||||
|
||||
function writeFixture(name: string, contents: string): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-interchange-"));
|
||||
const path = join(directory, name);
|
||||
writeFileSync(path, contents);
|
||||
return path;
|
||||
}
|
||||
|
||||
describe("CMX 3600 parsing and validation", () => {
|
||||
it("parses CMX headers, cut and dissolve events, and bounded malformed numeric lines", () => {
|
||||
const parsed = parseCmx3600Edl(`${BASE_EDL}\n003 incomplete event`);
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
title: "Round trip",
|
||||
frameCodeMode: "NON-DROP FRAME",
|
||||
events: [
|
||||
{ eventNumber: 1, reel: "CAM_A", transition: "C", transitionDurationFrames: null },
|
||||
{ eventNumber: 2, reel: "CAM_B", transition: "D", transitionDurationFrames: 24 },
|
||||
],
|
||||
unrecognizedEventLines: [{ line: 5, text: "003 incomplete event" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("converts non-drop and valid drop-frame timecodes while rejecting invalid forms", () => {
|
||||
expect(timecodeToFrames("00:00:01:00", 24)).toBe(24);
|
||||
expect(timecodeToFrames("00:01:00;02", 29.97)).toBe(1_800);
|
||||
expect(timecodeToFrames("00:01:00;04", 59.94)).toBe(3_600);
|
||||
expect(() => timecodeToFrames("00:00:00;00", 24)).toThrow("Drop-frame separator");
|
||||
expect(() => timecodeToFrames("00:01:00;00", 29.97)).toThrow("Invalid dropped frame");
|
||||
expect(() => timecodeToFrames("00:00:60:00", 24)).toThrow("Invalid timecode fields");
|
||||
expect(() => timecodeToFrames("not timecode", 24)).toThrow("Invalid timecode");
|
||||
});
|
||||
|
||||
it("detects duplicate IDs, invalid durations, overlaps, gaps, and invalid event timecodes", () => {
|
||||
const parsed = parseCmx3600Edl([
|
||||
"001 CAM_A V C 00:00:01:00 00:00:00:00 01:00:00:00 01:00:01:00",
|
||||
"001 CAM_B V C 00:00:00:00 00:00:01:00 01:00:00:12 01:00:01:12",
|
||||
"003 CAM_C V C 00:00:00:00 00:00:01:00 01:00:02:00 01:00:03:00",
|
||||
"004 CAM_D V C 00:00:60:00 00:00:01:00 01:00:03:00 01:00:04:00",
|
||||
].join("\n"));
|
||||
const validation = validateCmx3600Edl(parsed, 24);
|
||||
|
||||
expect(validation.valid).toBe(false);
|
||||
expect(validation.errors.join(" ")).toContain("duplicated");
|
||||
expect(validation.errors.join(" ")).toContain("non-positive source");
|
||||
expect(validation.errors.join(" ")).toContain("overlaps");
|
||||
expect(validation.errors.join(" ")).toContain("Invalid timecode fields");
|
||||
expect(validation.warnings.join(" ")).toContain("record gap");
|
||||
|
||||
expect(validateCmx3600Edl(parseCmx3600Edl(BASE_EDL), 24)).toMatchObject({
|
||||
valid: true,
|
||||
totalRecordFrames: 48,
|
||||
});
|
||||
expect(validateCmx3600Edl(parseCmx3600Edl("001 malformed"), 24)).toMatchObject({
|
||||
valid: false,
|
||||
errors: ["No CMX 3600 event records were found"],
|
||||
warnings: ["1 numeric line(s) did not match the supported CMX 3600 event grammar"],
|
||||
totalRecordFrames: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("compares event additions, removals, and edits without mutating either EDL", () => {
|
||||
const before = parseCmx3600Edl(BASE_EDL);
|
||||
const after = parseCmx3600Edl([
|
||||
"001 CAM_A V C 00:00:00:00 00:00:01:00 01:00:00:00 01:00:01:00",
|
||||
"002 CAM_B V D 012 00:00:01:00 00:00:02:00 01:00:01:00 01:00:02:00",
|
||||
"004 CAM_D A C 00:00:02:00 00:00:03:00 01:00:02:00 01:00:03:00",
|
||||
].join("\n"));
|
||||
|
||||
expect(compareCmx3600Edls(before, after)).toEqual({
|
||||
beforeEventCount: 2,
|
||||
afterEventCount: 3,
|
||||
changes: [
|
||||
{ eventNumber: 2, type: "changed" },
|
||||
{ eventNumber: 4, type: "added" },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("FCPXML inspection and approved-root reference verification", () => {
|
||||
it("inspects text only, reports entities defensively, and keeps FCPXML facts bounded", () => {
|
||||
const report = inspectFcpxml([
|
||||
"<!DOCTYPE fcpxml [<!ENTITY media SYSTEM 'untrusted'>]>",
|
||||
"<fcpxml version=\"1.10\"><resources>",
|
||||
"<asset id=\"r1\" name=\"Camera\" src=\"file:///safe.mov\" />",
|
||||
"<asset id=\"r2\" name=\"Generator\" />",
|
||||
"</resources><library><event><project><sequence><spine>",
|
||||
"<asset-clip ref=\"r1\" /><video /><audio />",
|
||||
"</spine></sequence></project></event></library></fcpxml>",
|
||||
].join(""));
|
||||
|
||||
expect(report).toMatchObject({
|
||||
format: "FCPXML",
|
||||
version: "1.10",
|
||||
sequenceCount: 1,
|
||||
clipElementCount: 3,
|
||||
assetCount: 2,
|
||||
assets: [
|
||||
{ id: "r1", name: "Camera", source: "file:///safe.mov" },
|
||||
{ id: "r2", name: "Generator", source: null },
|
||||
],
|
||||
warnings: ["DOCTYPE is present; this tool inspects text only and never resolves entities"],
|
||||
});
|
||||
expect(inspectFcpxml("<sequence />").warnings).toContain("No <fcpxml> root element was found");
|
||||
});
|
||||
|
||||
it("verifies only file URLs inside caller-approved roots and hides denied paths", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "premiere-interchange-root-"));
|
||||
const allowedMedia = join(root, "media.mov");
|
||||
writeFileSync(allowedMedia, "media");
|
||||
const nested = join(root, "nested");
|
||||
mkdirSync(nested);
|
||||
const missingMedia = join(nested, "missing.mov");
|
||||
const outsideMedia = writeFixture("private.mov", "not for inspection");
|
||||
const fcpxml = writeFixture("roundtrip.fcpxml", [
|
||||
"<fcpxml version=\"1.10\"><resources>",
|
||||
`<asset id=\"r1\" name=\"inside\" src=\"${pathToFileURL(allowedMedia)}\" />`,
|
||||
`<asset id=\"r2\" name=\"missing\" src=\"${pathToFileURL(missingMedia)}\" />`,
|
||||
`<asset id=\"r3\" name=\"outside\" src=\"${pathToFileURL(outsideMedia)}\" />`,
|
||||
"<asset id=\"r4\" name=\"remote\" src=\"https://example.test/media.mov\" />",
|
||||
"<asset id=\"r5\" name=\"unset\" /></resources></fcpxml>",
|
||||
].join(""));
|
||||
|
||||
const result = await tools.verify_fcpxml_media_references.handler({ path: fcpxml, allowed_roots: [root] });
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
checkedReferenceCount: 5,
|
||||
references: [
|
||||
{ assetId: "r1", status: "available", path: allowedMedia },
|
||||
{ assetId: "r2", status: "missing", path: missingMedia },
|
||||
{ assetId: "r3", status: "outside_allowed_roots" },
|
||||
{ assetId: "r4", status: "non_file_url" },
|
||||
{ assetId: "r5", status: "missing_source" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain(outsideMedia);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interchange tool contracts", () => {
|
||||
it("returns parsed, validated, compared, and inspected local results", async () => {
|
||||
const beforePath = writeFixture("before.edl", BASE_EDL);
|
||||
const afterPath = writeFixture("after.edl", BASE_EDL.replace("CAM_B", "CAM_C"));
|
||||
const xmlPath = writeFixture("before.xml", "<fcpxml version=\"1.8\"><sequence /></fcpxml>");
|
||||
|
||||
await expect(tools.inspect_cmx3600_edl.handler({ path: beforePath })).resolves.toMatchObject({ success: true, data: { eventCount: 2 } });
|
||||
await expect(tools.validate_cmx3600_edl.handler({ path: beforePath, frame_rate: 24 })).resolves.toMatchObject({ success: true, data: { valid: true } });
|
||||
await expect(tools.compare_cmx3600_edls.handler({ before_path: beforePath, after_path: afterPath })).resolves.toMatchObject({ success: true, data: { changes: [{ eventNumber: 2, type: "changed" }] } });
|
||||
await expect(tools.inspect_fcpxml_interchange.handler({ path: xmlPath })).resolves.toMatchObject({ success: true, data: { version: "1.8" } });
|
||||
});
|
||||
|
||||
it("fails closed for invalid rates, unsafe roots, missing paths, and incorrect extensions", async () => {
|
||||
const textPath = writeFixture("not-interchange.txt", "not interchange");
|
||||
await expect(tools.inspect_cmx3600_edl.handler({ path: "missing.edl" })).resolves.toMatchObject({ success: false, error: expect.stringContaining("does not exist") });
|
||||
await expect(tools.inspect_cmx3600_edl.handler({ path: textPath })).resolves.toMatchObject({ success: false, error: expect.stringContaining(".edl") });
|
||||
await expect(tools.validate_cmx3600_edl.handler({ path: "missing.edl", frame_rate: 23 as never })).resolves.toEqual({ success: false, error: "frame_rate must be 24, 25, 29.97, 30, 50, 59.94, or 60" });
|
||||
await expect(tools.compare_cmx3600_edls.handler({ before_path: textPath, after_path: textPath })).resolves.toMatchObject({ success: false, error: expect.stringContaining(".edl") });
|
||||
await expect(tools.inspect_fcpxml_interchange.handler({ path: textPath })).resolves.toMatchObject({ success: false, error: expect.stringContaining(".fcpxml") });
|
||||
await expect(tools.verify_fcpxml_media_references.handler({ path: textPath, allowed_roots: [] })).resolves.toMatchObject({ success: false, error: expect.stringContaining(".fcpxml") });
|
||||
|
||||
const xmlPath = writeFixture("empty.fcpxml", "<fcpxml />");
|
||||
await expect(tools.verify_fcpxml_media_references.handler({ path: xmlPath, allowed_roots: ["relative-root"] })).resolves.toMatchObject({ success: false, error: expect.stringContaining("absolute directory") });
|
||||
await expect(tools.verify_fcpxml_media_references.handler({ path: xmlPath, allowed_roots: [join(tmpdir(), "missing-root")] })).resolves.toMatchObject({ success: false, error: expect.stringContaining("not an existing directory") });
|
||||
});
|
||||
});
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getHelpersSource } from "../../src/bridge/script-builder.js";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getClipboardTools } from "../../src/tools/clipboard.js";
|
||||
import { getDiscoveryTools } from "../../src/tools/discovery.js";
|
||||
import { getHealthTools } from "../../src/tools/health.js";
|
||||
import { getMetadataTools } from "../../src/tools/metadata.js";
|
||||
import { getProjectTools } from "../../src/tools/project.js";
|
||||
import { getSequenceTools } from "../../src/tools/sequence.js";
|
||||
import { getUtilityTools } from "../../src/tools/utility.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const bridgeOptions = { tempDir: "/tmp/test-bridge", timeoutMs: 5_000 };
|
||||
|
||||
async function scriptFor(tool: { handler: (args: any) => Promise<unknown> }, args: unknown) {
|
||||
mockedSendCommand.mockClear();
|
||||
await tool.handler(args);
|
||||
expect(mockedSendCommand).toHaveBeenCalledTimes(1);
|
||||
return String(mockedSendCommand.mock.calls[0][0]);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("issue #239 — metadata writes require a readback-capable payload", () => {
|
||||
const metadata = getMetadataTools(bridgeOptions);
|
||||
const project = getProjectTools(bridgeOptions);
|
||||
|
||||
it("refuses the old field/value form before it reaches Premiere", async () => {
|
||||
const result = await metadata.set_metadata.handler({
|
||||
item_id: "clip-1",
|
||||
field_name: "Column.Intrinsic.Description",
|
||||
value: "unique value",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ success: false, error: expect.stringContaining("partial field_name/value") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes complete Project Metadata XML only with field paths and exact readback", async () => {
|
||||
const script = await scriptFor(metadata.set_metadata, {
|
||||
item_id: "clip-1",
|
||||
metadata_xml: "<xmpmeta><Description>unique value</Description></xmpmeta>",
|
||||
updated_fields: ["Column.Intrinsic.Description"],
|
||||
});
|
||||
|
||||
expect(script).toContain("item.setProjectMetadata(requestedMetadata, updatedFields)");
|
||||
expect(script).toContain("String(readback) !== requestedMetadata");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("verifies project-panel metadata and labels schema creation as non-value-writing", async () => {
|
||||
const panelScript = await scriptFor(project.set_project_panel_metadata, { metadata_xml: "<columns />" });
|
||||
expect(panelScript).toContain("app.project.getProjectPanelMetadata()");
|
||||
expect(panelScript).toContain("String(readback) !== requestedMetadata");
|
||||
|
||||
const schemaScript = await scriptFor(project.add_custom_metadata_field, {
|
||||
field_name: "ReviewStatus", field_label: "Review Status", field_type: 2,
|
||||
});
|
||||
expect(schemaScript).toContain("outcome: \"committed_unverified\"");
|
||||
expect(schemaScript).toContain("perItemValue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue #240 — legacy structural tools verify or refuse the operation", () => {
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
const project = getProjectTools(bridgeOptions);
|
||||
const sequence = getSequenceTools(bridgeOptions);
|
||||
|
||||
it("uses documented bin/sequence deletion paths and checks target absence", async () => {
|
||||
const itemScript = await scriptFor(utility.delete_project_item, { item_id: "item-1" });
|
||||
expect(itemScript).not.toContain("deleteProjectItem(");
|
||||
expect(itemScript).toContain("item.deleteBin()");
|
||||
expect(itemScript).toContain("app.project.deleteSequence(sequence)");
|
||||
expect(itemScript).toContain("if (__findProjectItem(nodeId))");
|
||||
|
||||
const manyScript = await scriptFor(utility.delete_multiple_project_items, { item_ids: ["item-1", "item-2"] });
|
||||
expect(manyScript).toContain("No project items were deleted");
|
||||
expect(manyScript).toContain("The batch is not reported as successful");
|
||||
|
||||
const binScript = await scriptFor(project.delete_bin, { bin_id: "bin-1" });
|
||||
expect(binScript).toContain("if (__findProjectItem(nodeId))");
|
||||
|
||||
const sequenceScript = await scriptFor(sequence.delete_sequence, { sequence_id: "sequence-1" });
|
||||
expect(sequenceScript).toContain("var seq = __findSequence");
|
||||
expect(sequenceScript).toContain("if (__findSequence(sequenceId))");
|
||||
});
|
||||
|
||||
it("does not misrepresent createSubsequence as timeline nesting", async () => {
|
||||
const subsequenceScript = await scriptFor(sequence.create_subsequence, {});
|
||||
expect(subsequenceScript).toContain("nested: false");
|
||||
expect(subsequenceScript).toContain("The source timeline selection is intentionally unchanged");
|
||||
expect(subsequenceScript).toContain("before.indexOf(newId) !== -1");
|
||||
|
||||
mockedSendCommand.mockClear();
|
||||
const result = await utility.nest_clips.handler({ name: "Verified nest" });
|
||||
expect(result).toMatchObject({ success: false, error: expect.stringContaining("does not replace the original clips") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a linked A/V unnest before it can leave an audio reference behind", async () => {
|
||||
const script = await scriptFor(sequence.unnest_sequence, { node_id: "nested-clip" });
|
||||
expect(script).toContain("collectLinkedReferences(seq.videoTracks, \"video\")");
|
||||
expect(script).toContain("collectLinkedReferences(seq.audioTracks, \"audio\")");
|
||||
expect(script).toContain("linkedReferences.length !== 1");
|
||||
expect(script).toContain("No clips were changed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue #241 — sequence rates use Time durations and readback", () => {
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
|
||||
it("converts a requested audio Hz value to ticks per sample and verifies it", async () => {
|
||||
const script = await scriptFor(utility.set_sequence_audio_settings, { sample_rate: 48_000 });
|
||||
expect(script).toContain("TICKS_PER_SECOND / requestedSampleRate");
|
||||
expect(script).toContain("var sampleDuration = new Time()");
|
||||
expect(script).toContain("settings.audioSampleRate = sampleDuration");
|
||||
expect(script).toContain("Math.abs(appliedTicksPerSample - requestedTicksPerSample) > 1");
|
||||
expect(script).not.toContain("settings.audioSampleRate = 48000");
|
||||
});
|
||||
|
||||
it("rejects unsafe audio-rate inputs without contacting Premiere", async () => {
|
||||
const result = await utility.set_sequence_audio_settings.handler({ sample_rate: 0 });
|
||||
expect(result).toMatchObject({ success: false, error: expect.stringContaining("between 1 and 768000") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue #242 — stale active sequences are not surfaced or trusted", () => {
|
||||
const discovery = getDiscoveryTools(bridgeOptions);
|
||||
const health = getHealthTools(bridgeOptions);
|
||||
const project = getProjectTools(bridgeOptions);
|
||||
|
||||
it("defines one current-project active-sequence guard", () => {
|
||||
const helpers = getHelpersSource();
|
||||
expect(helpers).toContain("function __isCurrentProjectSequence(sequence)");
|
||||
expect(helpers).toContain("function __getCurrentActiveSequence()");
|
||||
});
|
||||
|
||||
it("uses the guard in project info, active-sequence inspection, and ping", async () => {
|
||||
const projectInfo = await scriptFor(discovery.get_project_info, {});
|
||||
expect(projectInfo).toContain("var activeSeq = __getCurrentActiveSequence()");
|
||||
|
||||
const activeSequence = await scriptFor(discovery.get_active_sequence, {});
|
||||
expect(activeSequence).toContain("var seq = __getCurrentActiveSequence()");
|
||||
expect(activeSequence).toContain("No active sequence in the current project");
|
||||
|
||||
const ping = await scriptFor(health.ping, {});
|
||||
expect(ping).toContain("var activeSequence = app.project ? __getCurrentActiveSequence() : null");
|
||||
});
|
||||
|
||||
it("verifies project creation and sequence activation before reporting success", async () => {
|
||||
const create = await scriptFor(project.create_project, { path: "/tmp/New project.prproj" });
|
||||
expect(create).toContain("project.sequences.numSequences === 0 && active");
|
||||
expect(create).toContain("activeSequence: active ?");
|
||||
|
||||
const activate = await scriptFor(project.set_active_sequence, { sequence_id: "sequence-1" });
|
||||
expect(activate).toContain("String(active.sequenceID) !== String(seq.sequenceID)");
|
||||
expect(activate).toContain("verified: true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue #243 — copy_effect_values refuses unsafe Blend Mode writes", () => {
|
||||
const clipboard = getClipboardTools(bridgeOptions);
|
||||
|
||||
it("does not copy Blend Mode through the generic enum setter and verifies every copied scalar", async () => {
|
||||
const script = await scriptFor(clipboard.copy_effect_values, {
|
||||
source_node_id: "source-clip", target_node_id: "target-clip", effect_name: "Opacity",
|
||||
});
|
||||
|
||||
expect(script).toContain('srcProp.displayName === "Blend Mode"');
|
||||
expect(script).toContain("Legacy CEP enum writes can corrupt Blend Mode; no write was attempted.");
|
||||
expect(script).toContain("var readback = tgtComp.properties[q].getValue(0, 0)");
|
||||
expect(script).toContain("Effect-value copy was not fully verified");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BridgeOptions } from "../../src/bridge/file-bridge.js";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: { covered: true } }),
|
||||
sendRawCommand: vi.fn().mockResolvedValue({ success: true, data: { covered: true } }),
|
||||
}));
|
||||
|
||||
import { sendCommand, sendRawCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getAdvancedTools } from "../../src/tools/advanced.js";
|
||||
import { getAudioTools } from "../../src/tools/audio.js";
|
||||
import { getExportTools } from "../../src/tools/export.js";
|
||||
import { getHealthTools } from "../../src/tools/health.js";
|
||||
import { getInspectionTools } from "../../src/tools/inspection.js";
|
||||
import { getClipboardTools } from "../../src/tools/clipboard.js";
|
||||
import { getCaptionTools } from "../../src/tools/captions.js";
|
||||
import { getDiscoveryTools } from "../../src/tools/discovery.js";
|
||||
import { getEffectsTools } from "../../src/tools/effects.js";
|
||||
import { getMarkerTools } from "../../src/tools/markers.js";
|
||||
import { getMediaTools } from "../../src/tools/media.js";
|
||||
import { getMetadataTools } from "../../src/tools/metadata.js";
|
||||
import { getProjectManagerTools } from "../../src/tools/project-manager.js";
|
||||
import { getProjectTools } from "../../src/tools/project.js";
|
||||
import { getRecoveryTools } from "../../src/tools/recovery.js";
|
||||
import { getScriptingTools } from "../../src/tools/scripting.js";
|
||||
import { getSelectionTools } from "../../src/tools/selection.js";
|
||||
import { getSequenceTools } from "../../src/tools/sequence.js";
|
||||
import { getSourceMonitorTools } from "../../src/tools/source-monitor.js";
|
||||
import { getTextTools } from "../../src/tools/text.js";
|
||||
import { getTimelineTools } from "../../src/tools/timeline.js";
|
||||
import { getTrackTools } from "../../src/tools/tracks.js";
|
||||
import { getTrackTargetingTools } from "../../src/tools/track-targeting.js";
|
||||
import { getUtilityTools } from "../../src/tools/utility.js";
|
||||
|
||||
type Schema = {
|
||||
type?: string;
|
||||
enum?: unknown[];
|
||||
properties?: Record<string, Schema>;
|
||||
required?: string[];
|
||||
items?: Schema;
|
||||
default?: unknown;
|
||||
};
|
||||
|
||||
type Tool = {
|
||||
parameters: Schema;
|
||||
handler: (args: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const bridgeOptions: BridgeOptions = { tempDir: "/tmp/handler-coverage", timeoutMs: 25 };
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const mockedSendRawCommand = vi.mocked(sendRawCommand);
|
||||
|
||||
const FIELD_VALUES: Record<string, unknown> = {
|
||||
path: "/tmp/coverage.prproj",
|
||||
file_path: "/tmp/coverage.mov",
|
||||
output_path: "/tmp/coverage.json",
|
||||
folder_path: "/tmp",
|
||||
node_id: "coverage-node",
|
||||
clip_node_id: "coverage-node",
|
||||
project_item_node_id: "coverage-item",
|
||||
sequence_id: "coverage-sequence",
|
||||
sequence_name: "Coverage Sequence",
|
||||
track_index: 1,
|
||||
source_track_index: 0,
|
||||
destination_track_index: 1,
|
||||
time_seconds: 2.5,
|
||||
start_seconds: 1,
|
||||
end_seconds: 3,
|
||||
duration_seconds: 2,
|
||||
frame_number: 12,
|
||||
name: "Coverage Name",
|
||||
text: "Coverage text",
|
||||
script: "return __result({ covered: true });",
|
||||
code: "return __result({ covered: true });",
|
||||
};
|
||||
|
||||
function valueFor(schema: Schema, field: string, includeOptional: boolean): unknown {
|
||||
if (field in FIELD_VALUES) return FIELD_VALUES[field];
|
||||
if (schema.enum?.length) return includeOptional ? schema.enum.at(-1) : schema.enum[0];
|
||||
if (schema.default !== undefined) return schema.default;
|
||||
switch (schema.type) {
|
||||
case "boolean": return includeOptional;
|
||||
case "number": return 1;
|
||||
case "array": return [valueFor(schema.items ?? { type: "string" }, field, includeOptional)];
|
||||
case "object": return argsFor(schema, includeOptional);
|
||||
default: return `coverage-${field}`;
|
||||
}
|
||||
}
|
||||
|
||||
function argsFor(schema: Schema, includeOptional: boolean): Record<string, unknown> {
|
||||
const args: Record<string, unknown> = {};
|
||||
const required = new Set(schema.required ?? []);
|
||||
for (const [field, property] of Object.entries(schema.properties ?? {})) {
|
||||
if (includeOptional || required.has(field)) args[field] = valueFor(property, field, includeOptional);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
const modules: Array<[string, () => Record<string, Tool>, Set<string>?]> = [
|
||||
["advanced", () => getAdvancedTools(bridgeOptions) as Record<string, Tool>],
|
||||
["audio", () => getAudioTools(bridgeOptions) as Record<string, Tool>, new Set(["detect_silence", "detect_beats", "analyze_loudness", "normalize_loudness_file"])],
|
||||
["export", () => getExportTools(bridgeOptions) as Record<string, Tool>, new Set(["validate_export_preset", "verify_delivery_file", "analyze_video_qc", "detect_source_scene_changes"])],
|
||||
["clipboard", () => getClipboardTools(bridgeOptions) as Record<string, Tool>],
|
||||
["captions", () => getCaptionTools(bridgeOptions) as Record<string, Tool>],
|
||||
["discovery", () => getDiscoveryTools(bridgeOptions) as Record<string, Tool>],
|
||||
["effects", () => getEffectsTools(bridgeOptions) as Record<string, Tool>],
|
||||
["inspection", () => getInspectionTools(bridgeOptions) as Record<string, Tool>],
|
||||
["markers", () => getMarkerTools(bridgeOptions) as Record<string, Tool>],
|
||||
["media", () => getMediaTools(bridgeOptions) as Record<string, Tool>],
|
||||
["metadata", () => getMetadataTools(bridgeOptions) as Record<string, Tool>],
|
||||
["project-manager", () => getProjectManagerTools(bridgeOptions) as Record<string, Tool>],
|
||||
["project", () => getProjectTools(bridgeOptions) as Record<string, Tool>],
|
||||
["recovery", () => getRecoveryTools(bridgeOptions) as Record<string, Tool>, new Set(["get_bridge_telemetry"])],
|
||||
["scripting", () => getScriptingTools(bridgeOptions) as Record<string, Tool>],
|
||||
["selection", () => getSelectionTools(bridgeOptions) as Record<string, Tool>],
|
||||
["sequence", () => getSequenceTools(bridgeOptions) as Record<string, Tool>],
|
||||
["source-monitor", () => getSourceMonitorTools(bridgeOptions) as Record<string, Tool>],
|
||||
["text", () => getTextTools(bridgeOptions) as Record<string, Tool>],
|
||||
["timeline", () => getTimelineTools(bridgeOptions) as Record<string, Tool>],
|
||||
["track-targeting", () => getTrackTargetingTools(bridgeOptions) as Record<string, Tool>],
|
||||
["tracks", () => getTrackTools(bridgeOptions) as Record<string, Tool>],
|
||||
["utility", () => getUtilityTools(bridgeOptions) as Record<string, Tool>],
|
||||
];
|
||||
|
||||
describe("large tool handler coverage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedSendCommand.mockResolvedValue({ success: true, data: { covered: true } });
|
||||
mockedSendRawCommand.mockResolvedValue({ success: true, data: { covered: true } });
|
||||
});
|
||||
|
||||
for (const [moduleName, getTools, excludedTools = new Set<string>()] of modules) {
|
||||
describe(moduleName, () => {
|
||||
for (const [toolName, tool] of Object.entries(getTools())) {
|
||||
if (excludedTools.has(toolName)) continue;
|
||||
it.each([
|
||||
["required arguments", false],
|
||||
["all arguments", true],
|
||||
])(`${toolName} handles %s`, async (_label, includeOptional) => {
|
||||
const commandCount = mockedSendCommand.mock.calls.length + mockedSendRawCommand.mock.calls.length;
|
||||
const result = await tool.handler(argsFor(tool.parameters, includeOptional));
|
||||
|
||||
expect(result).toBeDefined();
|
||||
const nextCommandCount = mockedSendCommand.mock.calls.length + mockedSendRawCommand.mock.calls.length;
|
||||
if (nextCommandCount === commandCount) {
|
||||
expect(result).toMatchObject({ success: false });
|
||||
return;
|
||||
}
|
||||
const script = mockedSendCommand.mock.calls.at(-1)?.[0]
|
||||
?? mockedSendRawCommand.mock.calls.at(-1)?.[0];
|
||||
expect(script).toEqual(expect.any(String));
|
||||
expect(script.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
for (const [field, property] of Object.entries(tool.parameters.properties ?? {})) {
|
||||
for (const enumValue of property.enum?.slice(1, -1) ?? []) {
|
||||
it(`${toolName} handles ${field}=${String(enumValue)}`, async () => {
|
||||
const args = argsFor(tool.parameters, true);
|
||||
args[field] = enumValue;
|
||||
const commandCount = mockedSendCommand.mock.calls.length + mockedSendRawCommand.mock.calls.length;
|
||||
const result = await tool.handler(args);
|
||||
const nextCommandCount = mockedSendCommand.mock.calls.length + mockedSendRawCommand.mock.calls.length;
|
||||
if (nextCommandCount === commandCount) {
|
||||
expect(result).toMatchObject({ success: false });
|
||||
return;
|
||||
}
|
||||
expect(nextCommandCount).toBeGreaterThan(commandCount);
|
||||
});
|
||||
}
|
||||
if (property.type === "boolean") {
|
||||
it(`${toolName} handles ${field}=false`, async () => {
|
||||
const args = argsFor(tool.parameters, true);
|
||||
args[field] = false;
|
||||
const commandCount = mockedSendCommand.mock.calls.length + mockedSendRawCommand.mock.calls.length;
|
||||
const result = await tool.handler(args);
|
||||
const nextCommandCount = mockedSendCommand.mock.calls.length + mockedSendRawCommand.mock.calls.length;
|
||||
if (nextCommandCount === commandCount) {
|
||||
expect(result).toMatchObject({ success: false });
|
||||
return;
|
||||
}
|
||||
expect(nextCommandCount).toBeGreaterThan(commandCount);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it.each([Number.NaN, 0, 241])("rejects invalid sequence frame rate %s locally", async (frameRate) => {
|
||||
const tool = getUtilityTools(bridgeOptions).set_sequence_frame_rate;
|
||||
const result = await tool.handler({ frame_rate: frameRate });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "frame_rate must be a finite value between 1 and 240 fps",
|
||||
});
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["smoothness only", { node_id: "coverage-node", smoothness: 25 }],
|
||||
["method only", { node_id: "coverage-node", method: "Subspace Warp" }],
|
||||
])("stabilizes with %s", async (_label, args) => {
|
||||
await getEffectsTools(bridgeOptions).stabilize_clip.handler(args);
|
||||
expect(mockedSendCommand).toHaveBeenCalledWith(expect.stringContaining("Warp Stabilizer"), bridgeOptions);
|
||||
});
|
||||
|
||||
describe("health diagnostic failure branches", () => {
|
||||
it("reports successful, primitive, and rejected UXP diagnostic responses", async () => {
|
||||
const request = vi.fn()
|
||||
.mockResolvedValueOnce({ projectOpen: true, sequenceOpen: true })
|
||||
.mockResolvedValueOnce("unexpected")
|
||||
.mockRejectedValueOnce(new Error("offline"));
|
||||
const tools = getHealthTools(bridgeOptions, undefined, undefined, {
|
||||
uxpBridge: { request } as never,
|
||||
});
|
||||
|
||||
await expect(tools.verify_premiere_connection.handler({ backend: "uxp" }))
|
||||
.resolves.toMatchObject({ success: true, data: { overall: "ready" } });
|
||||
await expect(tools.verify_premiere_connection.handler({ backend: "uxp" }))
|
||||
.resolves.toMatchObject({ success: true, data: { overall: "needs_attention" } });
|
||||
await expect(tools.verify_premiere_connection.handler({ backend: "uxp" }))
|
||||
.resolves.toMatchObject({ success: true, data: { overall: "needs_attention" } });
|
||||
});
|
||||
|
||||
it("turns a thrown CEP diagnostic into a completed needs-attention report", async () => {
|
||||
mockedSendCommand.mockRejectedValueOnce(new Error("CEP unavailable"));
|
||||
const result = await getHealthTools(bridgeOptions).verify_premiere_connection.handler({ backend: "cep" });
|
||||
expect(result).toMatchObject({ success: true, data: { overall: "needs_attention" } });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getExportTools } from "../../src/tools/export.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const tool = getExportTools({ tempDir: "/tmp/test", timeoutMs: 1000 }).export_sequence_marker_review_frames;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("export_sequence_marker_review_frames", () => {
|
||||
it("exports chronological marker anchors in one bounded bridge request", async () => {
|
||||
await tool.handler({
|
||||
output_dir: "/tmp/review",
|
||||
marker_type: "Comment",
|
||||
start_seconds: 2,
|
||||
end_seconds: 18,
|
||||
limit: 8,
|
||||
});
|
||||
|
||||
expect(mockedSendCommand).toHaveBeenCalledTimes(1);
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('var requiredType = "Comment"');
|
||||
expect(script).toContain("matched.sort(function(a, b)");
|
||||
expect(script).toContain("var requested = Math.min(matched.length, 8)");
|
||||
expect(script).toContain("marker_review_");
|
||||
expect(script).toContain("__exportStillFrame(");
|
||||
expect(script).toContain("truncated: matched.length > requested");
|
||||
});
|
||||
|
||||
it("rejects invalid filters and bounds before contacting Premiere", async () => {
|
||||
await expect(tool.handler({ output_dir: "/tmp/review", limit: 25 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("1 through 24") });
|
||||
await expect(tool.handler({ output_dir: "/tmp/review", marker_type: " " }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("non-empty") });
|
||||
await expect(tool.handler({ output_dir: "/tmp/review", start_seconds: 9, end_seconds: 2 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("greater than") });
|
||||
await expect(tool.handler({ output_dir: "", limit: 1 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("non-empty") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports file verification and partial-export boundaries in the generated request", async () => {
|
||||
await tool.handler({ output_dir: "/tmp/review", limit: 2 });
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("failures.push");
|
||||
expect(script).toContain("complete: frames.length === requested");
|
||||
expect(script).toContain("verified on disk by the Premiere bridge at the matched marker start");
|
||||
});
|
||||
});
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockedExecFile, mockedExecFileAsync } = vi.hoisted(() => {
|
||||
const mockedExecFileAsync = vi.fn();
|
||||
const mockedExecFile = vi.fn();
|
||||
mockedExecFile[Symbol.for("nodejs.util.promisify.custom")] = mockedExecFileAsync;
|
||||
return { mockedExecFile, mockedExecFileAsync };
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({ execFile: mockedExecFile }));
|
||||
|
||||
import {
|
||||
analyzeRgbScopes,
|
||||
compareScopeReadings,
|
||||
getMediaAnalysisTools,
|
||||
parseCropDetectOutput,
|
||||
parseIdetOutput,
|
||||
parseMediaProbeJson,
|
||||
parseMotionPeakCandidates,
|
||||
parseTransientCandidates,
|
||||
} from "../../src/tools/media-analysis.js";
|
||||
|
||||
const tools = getMediaAnalysisTools({ tempDir: "/tmp/test" });
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function fixture(extension = ".mov") {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-media-analysis-"));
|
||||
const path = join(directory, `fixture${extension}`);
|
||||
writeFileSync(path, "fixture");
|
||||
return path;
|
||||
}
|
||||
|
||||
describe("media analysis parsers", () => {
|
||||
it("normalizes ffprobe JSON", () => {
|
||||
expect(parseMediaProbeJson(JSON.stringify({
|
||||
format: { duration: "2.0" }, streams: [{ codec_type: "video" }, null], chapters: [{ id: 1 }],
|
||||
}))).toEqual({ format: { duration: "2.0" }, streams: [{ codec_type: "video" }], chapters: [{ id: 1 }] });
|
||||
expect(parseMediaProbeJson("{}")).toEqual({ format: {}, streams: [], chapters: [] });
|
||||
expect(parseMediaProbeJson(JSON.stringify({ format: "bad", streams: "bad", chapters: [null, "bad"] })))
|
||||
.toEqual({ format: {}, streams: [], chapters: [] });
|
||||
});
|
||||
|
||||
it("keeps the strongest transient inside the minimum interval", () => {
|
||||
const output = [
|
||||
"frame:0 pts_time:0.10", "lavfi.astats.Overall.Peak_level=-8",
|
||||
"frame:1 pts_time:0.20", "lavfi.astats.Overall.Peak_level=-3",
|
||||
"frame:2 pts_time:1.00", "lavfi.astats.Overall.Peak_level=-20",
|
||||
"frame:3 pts_time:1.50", "lavfi.astats.Overall.Peak_level=-6",
|
||||
].join("\n");
|
||||
expect(parseTransientCandidates(output, -12, 0.25)).toEqual([
|
||||
{ timeSeconds: 0.2, peakDbfs: -3 }, { timeSeconds: 1.5, peakDbfs: -6 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps local motion maxima and the strongest peak inside the minimum interval", () => {
|
||||
const output = [
|
||||
"frame:0 pts_time:0", "lavfi.signalstats.YAVG=2",
|
||||
"frame:1 pts_time:0.25", "lavfi.signalstats.YAVG=15",
|
||||
"frame:2 pts_time:0.5", "lavfi.signalstats.YAVG=2",
|
||||
"frame:3 pts_time:0.75", "lavfi.signalstats.YAVG=20",
|
||||
"frame:4 pts_time:1", "lavfi.signalstats.YAVG=2",
|
||||
"frame:5 pts_time:2", "lavfi.signalstats.YAVG=18",
|
||||
].join("\n");
|
||||
expect(parseMotionPeakCandidates(output, 12, 1)).toEqual([
|
||||
{ timeSeconds: 0.75, difference: 20 }, { timeSeconds: 2, difference: 18 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("represents an equal-valued motion plateau with one peak", () => {
|
||||
const output = [
|
||||
"frame:0 pts_time:0", "lavfi.signalstats.YAVG=2",
|
||||
"frame:1 pts_time:0.25", "lavfi.signalstats.YAVG=15",
|
||||
"frame:2 pts_time:0.5", "lavfi.signalstats.YAVG=15",
|
||||
"frame:3 pts_time:0.75", "lavfi.signalstats.YAVG=2",
|
||||
].join("\n");
|
||||
expect(parseMotionPeakCandidates(output, 12, 0.1)).toEqual([
|
||||
{ timeSeconds: 0.25, difference: 15 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("computes waveform, parade, saturation, and RGB endpoint occupancy", () => {
|
||||
const reading = analyzeRgbScopes(Uint8Array.from([0, 0, 0, 255, 255, 255, 255, 0, 0]));
|
||||
expect(reading).toMatchObject({
|
||||
pixels: 3,
|
||||
waveform: { black: 0, white: 100 },
|
||||
rgbParade: { red: { high: 100 }, green: { low: 0 }, blue: { median: 0 } },
|
||||
saturation: { high: 100 },
|
||||
rgbExtremes: { nearBlackPercent: 33.33, nearWhitePercent: 33.33 },
|
||||
});
|
||||
expect(() => analyzeRgbScopes(Uint8Array.from([0, 1]))).toThrow("complete RGB24 pixels");
|
||||
});
|
||||
|
||||
it("compares scope readings with target-minus-reference deltas and coarse directions", () => {
|
||||
const reference = analyzeRgbScopes(Uint8Array.from([100, 100, 100, 120, 120, 120]));
|
||||
const target = analyzeRgbScopes(Uint8Array.from([60, 70, 80, 70, 80, 90]));
|
||||
expect(compareScopeReadings(reference, target)).toMatchObject({
|
||||
targetMinusReference: { medianLuma: expect.any(Number), redBlueBalance: expect.any(Number) },
|
||||
suggestedDirections: { exposure: "raise", warmth: "warmer", saturation: "lower" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the warmth direction stable across proportional exposure changes", () => {
|
||||
const reference = analyzeRgbScopes(Uint8Array.from([100, 75, 50]));
|
||||
const target = analyzeRgbScopes(Uint8Array.from([200, 150, 100]));
|
||||
expect(compareScopeReadings(reference, target)).toMatchObject({
|
||||
targetMinusReference: { redBlueBalance: -0.01 },
|
||||
suggestedDirections: { exposure: "lower", warmth: "hold", saturation: "hold" },
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies progressive, mixed, and absent idet summaries", () => {
|
||||
expect(parseIdetOutput("Multi frame detection: TFF: 1 BFF: 0 Progressive: 99 Undetermined: 0").classification).toBe("progressive");
|
||||
expect(parseIdetOutput("Multi frame detection: TFF: 50 BFF: 0 Progressive: 50 Undetermined: 0").classification).toBe("mixed");
|
||||
expect(parseIdetOutput("").classification).toBe("undetermined");
|
||||
expect(parseIdetOutput("Multi frame detection: TFF: 90 BFF: 5 Progressive: 5 Undetermined: 1").classification).toBe("tff");
|
||||
expect(parseIdetOutput("Multi frame detection: TFF: 5 BFF: 90 Progressive: 5 Undetermined: 1").classification).toBe("bff");
|
||||
});
|
||||
|
||||
it("selects the most frequent active-picture rectangle", () => {
|
||||
const output = "crop=1920:800:0:140\ncrop=1920:800:0:140\ncrop=1900:800:10:140";
|
||||
expect(parseCropDetectOutput(output)).toEqual({ width: 1920, height: 800, x: 0, y: 140, samples: 2 });
|
||||
expect(parseCropDetectOutput("no crop data")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("media analysis tool contracts", () => {
|
||||
it("rejects missing files and unsafe output arguments before spawning", async () => {
|
||||
await expect(tools.inspect_media_streams.handler({ media_path: "" })).resolves.toMatchObject({ success: false });
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-media-directory-"));
|
||||
mkdirSync(join(directory, "nested"));
|
||||
await expect(tools.inspect_media_streams.handler({ media_path: join(directory, "nested") })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.inspect_media_streams.handler({ media_path: "missing.mov" })).resolves.toMatchObject({ success: false });
|
||||
const mediaPath = fixture();
|
||||
await expect(tools.generate_media_contact_sheet.handler({ media_path: mediaPath, output_path: "sheet.jpg" })).resolves.toMatchObject({ success: false, error: expect.stringContaining(".png") });
|
||||
const existingOutput = fixture(".png");
|
||||
await expect(tools.generate_media_contact_sheet.handler({ media_path: mediaPath, output_path: existingOutput })).resolves.toMatchObject({ success: false, error: expect.stringContaining("already exists") });
|
||||
await expect(tools.generate_media_contact_sheet.handler({ media_path: mediaPath, output_path: join(directory, "sheet.png"), columns: 1 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.generate_media_contact_sheet.handler({ media_path: mediaPath, output_path: join(directory, "sheet.png"), thumbnail_width: 20 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_audio_transients.handler({ media_path: mediaPath, threshold_dbfs: 1 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_audio_transients.handler({ media_path: mediaPath, minimum_interval_seconds: 20 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_audio_transients.handler({ media_path: mediaPath, maximum_events: 0 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_motion_peaks.handler({ media_path: mediaPath, sample_seconds: 0 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_motion_peaks.handler({ media_path: mediaPath, samples_per_second: 11 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_motion_peaks.handler({ media_path: mediaPath, threshold: 256 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_motion_peaks.handler({ media_path: mediaPath, minimum_interval_seconds: 0 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_motion_peaks.handler({ media_path: mediaPath, maximum_events: 0 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.read_video_scopes.handler({ media_path: mediaPath, time_seconds: -1 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.plan_shot_match.handler({ reference_media_path: mediaPath, target_media_path: mediaPath, target_time_seconds: -1 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.analyze_video_interlacing.handler({ media_path: mediaPath, sample_seconds: 0 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_active_picture_bounds.handler({ media_path: mediaPath, sample_seconds: 301 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tools.detect_active_picture_bounds.handler({ media_path: mediaPath, limit: 300 })).resolves.toMatchObject({ success: false });
|
||||
expect(mockedExecFileAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns structured media inspection", async () => {
|
||||
const mediaPath = fixture();
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: JSON.stringify({ format: { format_name: "mov" }, streams: [{ codec_name: "h264" }] }), stderr: "" });
|
||||
await expect(tools.inspect_media_streams.handler({ media_path: mediaPath })).resolves.toMatchObject({
|
||||
success: true, data: { mediaPath, streamCount: 1, format: { format_name: "mov" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns bounded transient, interlace, and picture findings", async () => {
|
||||
const mediaPath = fixture();
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "pts_time:1\nlavfi.astats.Overall.Peak_level=-4" });
|
||||
await expect(tools.detect_audio_transients.handler({ media_path: mediaPath, maximum_events: 1 })).resolves.toMatchObject({ success: true, data: { candidates: [{ timeSeconds: 1, peakDbfs: -4 }] } });
|
||||
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "pts_time:1\nlavfi.signalstats.YAVG=20" });
|
||||
await expect(tools.detect_motion_peaks.handler({ media_path: mediaPath, threshold: 12, maximum_events: 1 })).resolves.toMatchObject({ success: true, data: { candidates: [{ timeSeconds: 1, difference: 20 }] } });
|
||||
expect(mockedExecFileAsync.mock.calls.at(-1)?.[1]).toEqual(expect.arrayContaining(["-t", "60", "-an", "-f", "null", "-"]));
|
||||
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: Buffer.alloc(320 * 180 * 3, 128), stderr: Buffer.alloc(0) });
|
||||
await expect(tools.read_video_scopes.handler({ media_path: mediaPath, time_seconds: 2 })).resolves.toMatchObject({ success: true, data: { timeSeconds: 2, pixels: 57_600, sampleSize: { width: 320, height: 180 } } });
|
||||
expect(mockedExecFileAsync.mock.calls.at(-1)?.[1]).toEqual(expect.arrayContaining(["-ss", "2", "-frames:v", "1", "rgb24", "pipe:1"]));
|
||||
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("timeout"), { killed: true }));
|
||||
await expect(tools.read_video_scopes.handler({ media_path: mediaPath })).resolves.toMatchObject({ success: false, error: expect.stringContaining("timed out after 60 seconds") });
|
||||
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("decode"), { stderr: Buffer.from("invalid video stream") }));
|
||||
await expect(tools.read_video_scopes.handler({ media_path: mediaPath })).resolves.toMatchObject({ success: false, error: expect.stringContaining("invalid video stream") });
|
||||
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: Buffer.alloc(320 * 180 * 3, 100), stderr: Buffer.alloc(0) });
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: Buffer.alloc(320 * 180 * 3, 80), stderr: Buffer.alloc(0) });
|
||||
await expect(tools.plan_shot_match.handler({ reference_media_path: mediaPath, target_media_path: mediaPath })).resolves.toMatchObject({ success: true, data: { comparison: { suggestedDirections: { exposure: "raise" } } } });
|
||||
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "Multi frame detection: TFF: 0 BFF: 0 Progressive: 20 Undetermined: 0" });
|
||||
await expect(tools.analyze_video_interlacing.handler({ media_path: mediaPath })).resolves.toMatchObject({ success: true, data: { classification: "progressive", passesProgressiveDelivery: true } });
|
||||
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "crop=1920:800:0:140" });
|
||||
await expect(tools.detect_active_picture_bounds.handler({ media_path: mediaPath })).resolves.toMatchObject({ success: true, data: { activePicture: { width: 1920, height: 800 } } });
|
||||
});
|
||||
|
||||
it("creates and verifies a new contact sheet without overwriting", async () => {
|
||||
const mediaPath = fixture();
|
||||
const outputPath = join(mkdtempSync(join(tmpdir(), "premiere-contact-sheet-")), "sheet.png");
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "4", stderr: "" });
|
||||
mockedExecFileAsync.mockImplementationOnce(async (_command: string, args: string[]) => {
|
||||
writeFileSync(args.at(-1)!, "png-output");
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
await expect(tools.generate_media_contact_sheet.handler({ media_path: mediaPath, output_path: outputPath }))
|
||||
.resolves.toMatchObject({ success: true, data: { outputPath, verified: true, grid: { requestedFrames: 12 } } });
|
||||
});
|
||||
|
||||
it("fails closed when contact-sheet duration or output verification is unavailable", async () => {
|
||||
const mediaPath = fixture();
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-contact-sheet-failure-"));
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "not-a-duration", stderr: "" });
|
||||
await expect(tools.generate_media_contact_sheet.handler({ media_path: mediaPath, output_path: join(directory, "duration.png") }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("positive media duration") });
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "2", stderr: "" }).mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||
await expect(tools.generate_media_contact_sheet.handler({ media_path: mediaPath, output_path: join(directory, "missing.png") }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("non-empty") });
|
||||
});
|
||||
|
||||
it("fails closed on tool errors and missing measurements", async () => {
|
||||
const mediaPath = fixture();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("timed out"), { killed: true }));
|
||||
await expect(tools.inspect_media_streams.handler({ media_path: mediaPath })).resolves.toMatchObject({ success: false, error: expect.stringContaining("timed out") });
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||
await expect(tools.detect_active_picture_bounds.handler({ media_path: mediaPath })).resolves.toMatchObject({ success: false, error: expect.stringContaining("no active-picture") });
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("decode failed"), { stderr: "bad stream" }));
|
||||
await expect(tools.analyze_video_interlacing.handler({ media_path: mediaPath })).resolves.toMatchObject({ success: false, error: expect.stringContaining("bad stream") });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { mkdirSync, mkdtempSync, utimesSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { MediaWatchRegistry, getMediaWatchTools } from "../../src/tools/media-watch.js";
|
||||
|
||||
const registries: MediaWatchRegistry[] = [];
|
||||
afterEach(() => registries.splice(0).forEach((registry) => registry.close()));
|
||||
describe("media watch", () => {
|
||||
it("proposes new media without disclosing paths by default", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "premiere-watch-"));
|
||||
writeFileSync(path.join(root, "existing.mp4"), "old");
|
||||
const registry = new MediaWatchRegistry(); registries.push(registry);
|
||||
const started = registry.start({ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"] }) as any;
|
||||
writeFileSync(path.join(root, "new.mp4"), "new");
|
||||
const preview = registry.preview({ watch_id: started.watch_id }) as any;
|
||||
expect(preview.proposed_count).toBe(1);
|
||||
expect(preview.proposed_imports[0]).not.toHaveProperty("media_path");
|
||||
expect(preview.applied).toBe(false);
|
||||
});
|
||||
it("supports disclosure, rescan, stop, and handler failures", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "premiere-watch-"));
|
||||
const registry = new MediaWatchRegistry(); registries.push(registry);
|
||||
const started = registry.start({ approved_workspace_path: root, watch_path: root, allowed_extensions: [".MP4"], recursive: false }) as any;
|
||||
writeFileSync(path.join(root, "new.mp4"), "new");
|
||||
const preview = registry.preview({ watch_id: started.watch_id, include_paths: true, known_media_path_hashes: [] }) as any;
|
||||
expect(preview.proposed_imports[0].media_path).toContain("new.mp4");
|
||||
expect((registry.rescan() as any).baseline_file_count).toBe(1);
|
||||
registry.close(); expect(registry.status()).toMatchObject({ active: false });
|
||||
expect(() => registry.preview({ watch_id: started.watch_id })).toThrow(/no media watch/);
|
||||
});
|
||||
it("rejects unsafe starts and stale preview arguments", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "premiere-watch-")), outside = mkdtempSync(path.join(tmpdir(), "outside-"));
|
||||
const registry = new MediaWatchRegistry(); registries.push(registry);
|
||||
expect(() => registry.start({ approved_workspace_path: root, watch_path: outside, allowed_extensions: ["mp4"] })).toThrow(/contained/);
|
||||
const started = registry.start({ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"] }) as any;
|
||||
expect(() => registry.start({})).toThrow(/already active/);
|
||||
expect(() => registry.preview({ watch_id: "wrong" })).toThrow(/does not match/);
|
||||
expect(() => registry.preview({ watch_id: started.watch_id, include_paths: "yes" })).toThrow(/boolean/);
|
||||
expect(() => registry.preview({ watch_id: started.watch_id, known_media_path_hashes: ["bad"] })).toThrow(/sha256/);
|
||||
});
|
||||
it("validates paths, extensions, recursion, bins, and inactive scans", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "premiere-watch-"));
|
||||
for (const args of [
|
||||
{ approved_workspace_path: "relative", watch_path: root, allowed_extensions: ["mp4"] },
|
||||
{ approved_workspace_path: root, watch_path: root, allowed_extensions: [] },
|
||||
{ approved_workspace_path: root, watch_path: root, allowed_extensions: [1] },
|
||||
{ approved_workspace_path: root, watch_path: root, allowed_extensions: ["bad!"] },
|
||||
{ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4", ".MP4"] },
|
||||
{ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"], recursive: "yes" },
|
||||
{ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"], target_bin_id: "" },
|
||||
{ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"], target_bin_id: "x".repeat(513) },
|
||||
]) expect(() => new MediaWatchRegistry().start(args)).toThrow();
|
||||
expect(() => new MediaWatchRegistry().rescan()).toThrow(/no media watch/);
|
||||
});
|
||||
it("routes every management action through the public handlers", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "premiere-watch-")), registry = new MediaWatchRegistry(); registries.push(registry);
|
||||
const tools = getMediaWatchTools(registry);
|
||||
const started = await tools.manage_media_watch.handler({ action: "start", approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"] }) as any;
|
||||
expect((await tools.manage_media_watch.handler({ action: "status" })).success).toBe(true);
|
||||
expect((await tools.manage_media_watch.handler({ action: "scan" })).success).toBe(true);
|
||||
expect((await tools.preview_watched_media_import.handler({ watch_id: started.data.watch_id })).success).toBe(true);
|
||||
expect((await tools.manage_media_watch.handler({ action: "unknown" })).success).toBe(false);
|
||||
expect((await tools.manage_media_watch.handler({ action: "stop" })).success).toBe(true);
|
||||
expect((await tools.preview_watched_media_import.handler({ watch_id: started.data.watch_id })).success).toBe(false);
|
||||
});
|
||||
it("filters known hashes and proposes changed baseline files to a bin", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "premiere-watch-")), file = path.join(root, "clip.mp4"); writeFileSync(file, "a");
|
||||
const registry = new MediaWatchRegistry(); registries.push(registry);
|
||||
const started = registry.start({ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"], target_bin_id: "bin" }) as any;
|
||||
writeFileSync(file, "changed-size");
|
||||
const first = registry.preview({ watch_id: started.watch_id }) as any, hash = first.proposed_imports[0].path_hash;
|
||||
expect(first.proposed_imports[0].target_bin_id).toBe("bin");
|
||||
expect((registry.preview({ watch_id: started.watch_id, known_media_path_hashes: [hash] }) as any).proposed_count).toBe(0);
|
||||
expect(() => registry.preview({ watch_id: started.watch_id, known_media_path_hashes: "bad" })).toThrow(/at most 5000/);
|
||||
});
|
||||
it("does not propose an unchanged baseline file", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "premiere-watch-")); writeFileSync(path.join(root, "clip.mp4"), "same");
|
||||
const registry = new MediaWatchRegistry(); registries.push(registry);
|
||||
const started = registry.start({ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"] }) as any;
|
||||
expect((registry.preview({ watch_id: started.watch_id }) as any).proposed_count).toBe(0);
|
||||
});
|
||||
it("detects same-size timestamp changes and recursively scans subfolders", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "premiere-watch-")), nested = path.join(root, "nested"); mkdirSync(nested);
|
||||
const file = path.join(nested, "clip.mp4"); writeFileSync(file, "same");
|
||||
const registry = new MediaWatchRegistry(); registries.push(registry);
|
||||
const started = registry.start({ approved_workspace_path: root, watch_path: root, allowed_extensions: ["mp4"], recursive: true }) as any;
|
||||
const changed = new Date(Date.now() + 10_000); utimesSync(file, changed, changed);
|
||||
expect((registry.preview({ watch_id: started.watch_id }) as any).proposed_count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getMogrtAuthoringTools } from "../../src/tools/mogrt-authoring.js";
|
||||
|
||||
const bridgeOptions = { tempDir: "D:/PremiereBridge", timeoutMs: 5000 };
|
||||
|
||||
describe("MOGRT authoring tools", () => {
|
||||
it("issues a bounded lower-third preview and consumes its token exactly once", async () => {
|
||||
const scripts: string[] = [];
|
||||
const tools = getMogrtAuthoringTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
tokenFactory: () => "preview-token",
|
||||
send: async (script) => {
|
||||
scripts.push(script);
|
||||
return { success: true, data: { exportRequested: true, hostExportReturn: false } };
|
||||
},
|
||||
artifactStatus: () => ({ exists: false, size_bytes: null, zip_header_valid: false }),
|
||||
operationIdFactory: () => "operation-1",
|
||||
});
|
||||
|
||||
const preview = await tools.preview_mogrt_recipe.handler({
|
||||
recipe: "lower_third",
|
||||
template_name: "Launch Title",
|
||||
headline: "A headline",
|
||||
subtitle: "A subtitle",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
});
|
||||
expect(preview).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
applied: false,
|
||||
previewToken: "preview-token",
|
||||
plan: { output_path: "D:\\Approved\\templates\\Launch Title.mogrt" },
|
||||
},
|
||||
});
|
||||
|
||||
const created = await tools.create_mogrt_recipe.handler({
|
||||
preview_token: "preview-token",
|
||||
confirm_export: true,
|
||||
});
|
||||
expect(created).toMatchObject({ success: true, data: { operationId: "operation-1" } });
|
||||
expect(scripts).toHaveLength(1);
|
||||
expect(scripts[0]).toContain("exportAsMotionGraphicsTemplate");
|
||||
expect(scripts[0]).toContain("addToMotionGraphicsTemplate");
|
||||
expect(scripts[0]).toContain("Open a saved After Effects project inside approved_workspace_path");
|
||||
expect(scripts[0]).toContain("existing.exists");
|
||||
|
||||
await expect(tools.create_mogrt_recipe.handler({
|
||||
preview_token: "preview-token",
|
||||
confirm_export: true,
|
||||
})).rejects.toThrow("already used");
|
||||
});
|
||||
|
||||
it("fails closed when an output path leaves its approved workspace", async () => {
|
||||
const tools = getMogrtAuthoringTools(bridgeOptions, { directoryExists: () => true });
|
||||
await expect(tools.preview_mogrt_recipe.handler({
|
||||
template_name: "Outside",
|
||||
headline: "Nope",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Elsewhere",
|
||||
})).rejects.toThrow("inside approved_workspace_path");
|
||||
});
|
||||
|
||||
it("supports only the bounded recipe library and applies contained brand-kit defaults", async () => {
|
||||
const tools = getMogrtAuthoringTools(bridgeOptions, { directoryExists: () => true });
|
||||
await expect(tools.preview_mogrt_recipe.handler({
|
||||
recipe: "social_end_card",
|
||||
template_name: "Brand End Card",
|
||||
headline: "Follow us",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
brand_kit: { name: "Brand", name_prefix: "Brand", accent_color: "#102030", text_color: "#FFFFFF", safe_margin_percent: 0.1 },
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { plan: { recipe: "social_end_card", accent_color: "#102030", text_color: "#FFFFFF" } },
|
||||
});
|
||||
await expect(tools.preview_mogrt_recipe.handler({
|
||||
recipe: "arbitrary_script",
|
||||
template_name: "Nope",
|
||||
headline: "Nope",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
})).rejects.toThrow("recipe must be one of");
|
||||
});
|
||||
|
||||
it("does not report a valid ZIP header as visual or import verification", async () => {
|
||||
const tools = getMogrtAuthoringTools(bridgeOptions, {
|
||||
artifactStatus: () => ({ exists: true, size_bytes: 42, zip_header_valid: true }),
|
||||
operationIdFactory: () => "operation-verify",
|
||||
});
|
||||
await expect(tools.verify_mogrt_artifact.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { visualVerified: false, importVerified: false, artifact: { zip_header_valid: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unapproved preview inputs before a connector call", async () => {
|
||||
const tools = getMogrtAuthoringTools(bridgeOptions, { directoryExists: () => false });
|
||||
await expect(tools.preview_mogrt_recipe.handler(null)).rejects.toThrow("arguments must be an object");
|
||||
await expect(tools.preview_mogrt_recipe.handler({
|
||||
template_name: "Missing output",
|
||||
headline: "No file",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
})).rejects.toThrow("must already exist");
|
||||
await expect(tools.preview_mogrt_recipe.handler({
|
||||
template_name: "Bad.",
|
||||
headline: "No file",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
})).rejects.toThrow("template_name");
|
||||
await expect(tools.preview_mogrt_recipe.handler({
|
||||
template_name: "Safe",
|
||||
headline: "No file",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
unexpected: true,
|
||||
})).rejects.toThrow("unsupported field");
|
||||
|
||||
const existingDirectory = getMogrtAuthoringTools(bridgeOptions, { directoryExists: () => true });
|
||||
await expect(existingDirectory.preview_mogrt_recipe.handler({
|
||||
recipe: "not_a_recipe",
|
||||
template_name: "Safe",
|
||||
headline: "No file",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
})).rejects.toThrow("recipe must be one of");
|
||||
await expect(existingDirectory.preview_mogrt_recipe.handler({
|
||||
template_name: "Safe",
|
||||
headline: "No file",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "/approved/templates",
|
||||
})).rejects.toThrow("same path format");
|
||||
await expect(existingDirectory.preview_mogrt_recipe.handler({
|
||||
template_name: "Safe",
|
||||
headline: "No file",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
frame_rate: 31,
|
||||
})).rejects.toThrow("frame_rate must be one of");
|
||||
});
|
||||
|
||||
it("requires confirmation, preserves bridge errors, and reports local artifact failures", async () => {
|
||||
let now = 0;
|
||||
const tools = getMogrtAuthoringTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
now: () => now,
|
||||
tokenFactory: () => "expiring-token",
|
||||
operationIdFactory: () => "operation-error",
|
||||
send: async () => ({ success: false, error: "connector unavailable" }),
|
||||
artifactStatus: () => ({ exists: true, size_bytes: 10, zip_header_valid: false }),
|
||||
});
|
||||
await tools.preview_mogrt_recipe.handler({
|
||||
template_name: "Safe",
|
||||
headline: "A headline",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
});
|
||||
await expect(tools.create_mogrt_recipe.handler({
|
||||
preview_token: "expiring-token",
|
||||
confirm_export: false,
|
||||
})).rejects.toThrow("confirm_export");
|
||||
await expect(tools.create_mogrt_recipe.handler({
|
||||
preview_token: "expiring-token",
|
||||
confirm_export: true,
|
||||
})).resolves.toMatchObject({ success: false, error: "connector unavailable (operation operation-error)" });
|
||||
|
||||
await expect(tools.verify_mogrt_artifact.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
})).resolves.toMatchObject({ success: false, error: "The artifact is not a recognizable ZIP-based .mogrt file" });
|
||||
|
||||
await tools.preview_mogrt_recipe.handler({
|
||||
template_name: "Expired",
|
||||
headline: "A headline",
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
});
|
||||
now = 10 * 60 * 1000;
|
||||
await expect(tools.create_mogrt_recipe.handler({
|
||||
preview_token: "expiring-token",
|
||||
confirm_export: true,
|
||||
})).rejects.toThrow("expired");
|
||||
});
|
||||
});
|
||||
+485
@@ -0,0 +1,485 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getMogrtStudioTools } from "../../src/tools/mogrt-studio.js";
|
||||
|
||||
const bridgeOptions = { tempDir: "D:/PremiereBridge", timeoutMs: 5000 };
|
||||
const artifact = { exists: true, size_bytes: 64, zip_header_valid: true };
|
||||
|
||||
describe("MOGRT studio tools", () => {
|
||||
it("validates a contained brand kit and serially creates a bounded JSON batch", async () => {
|
||||
const afterEffectsScripts: string[] = [];
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate.endsWith("templates.json"),
|
||||
readText: () => JSON.stringify([
|
||||
{ recipe: "title_card", template_name: "Brand Intro", headline: "Hello" },
|
||||
{ recipe: "callout", template_name: "Brand Tip", headline: "Tip" },
|
||||
]),
|
||||
artifactStatus: () => artifact,
|
||||
tokenFactory: (() => { const tokens = ["batch-preview", "unused"]; return () => tokens.shift() ?? "token"; })(),
|
||||
operationIdFactory: () => "operation-1",
|
||||
sendAfterEffects: async (script) => {
|
||||
afterEffectsScripts.push(script);
|
||||
return { success: true, data: { exportRequested: true } };
|
||||
},
|
||||
});
|
||||
|
||||
await expect(tools.validate_mogrt_brand_kit.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
brand_kit: { name: "Brand", name_prefix: "Brand", font_family: "Inter", accent_color: "#112233", text_color: "#FFFFFF", safe_margin_percent: 0.12 },
|
||||
})).resolves.toMatchObject({ success: true, data: { brandKit: { name: "Brand", accent_color: "#112233", safe_margin_percent: 0.12 } } });
|
||||
|
||||
const preview = await tools.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "D:/Approved/data/templates.json",
|
||||
brand_kit: { name: "Brand", name_prefix: "Brand", accent_color: "#112233", safe_margin_percent: 0.1 },
|
||||
});
|
||||
expect(preview).toMatchObject({ success: true, data: { previewToken: "batch-preview", plans: [{ recipe: "title_card", accent_color: "#112233" }, { recipe: "callout", accent_color: "#112233" }] } });
|
||||
|
||||
await expect(tools.create_mogrt_batch.handler({ preview_token: "batch-preview", confirm_export: true })).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { completed: [{ templateName: "Brand Intro" }, { templateName: "Brand Tip" }], batchAtomic: false },
|
||||
});
|
||||
expect(afterEffectsScripts).toHaveLength(2);
|
||||
expect(afterEffectsScripts[0]).toContain('recipe: "title_card"');
|
||||
expect(afterEffectsScripts[1]).toContain('recipe: "callout"');
|
||||
});
|
||||
|
||||
it("publishes only the previewed artifact to a new immutable local-library version", async () => {
|
||||
const copied: Array<[string, string]> = [];
|
||||
const directories: string[] = [];
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate === "D:\\Approved\\templates\\Launch.mogrt",
|
||||
artifactStatus: () => artifact,
|
||||
tokenFactory: () => "library-preview",
|
||||
operationIdFactory: () => "operation-library",
|
||||
copyFile: (source, destination) => copied.push([source, destination]),
|
||||
makeDirectory: (candidate) => directories.push(candidate),
|
||||
});
|
||||
|
||||
const preview = await tools.preview_mogrt_library_publish.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
library_directory: "D:/Approved/library",
|
||||
template_name: "Launch",
|
||||
});
|
||||
expect(preview).toMatchObject({ success: true, data: { previewToken: "library-preview", version: "v001" } });
|
||||
|
||||
await expect(tools.publish_mogrt_to_library.handler({ preview_token: "library-preview", confirm_publish: true })).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { version: "v001", overwrite: false },
|
||||
});
|
||||
expect(directories[0]).toContain("v001");
|
||||
expect(copied[0]?.[1]).toContain("Launch.mogrt");
|
||||
});
|
||||
|
||||
it("requires a disposable named Premiere target and reports import rather than visual verification", async () => {
|
||||
const premiereScripts: string[] = [];
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
artifactStatus: () => artifact,
|
||||
tokenFactory: () => "handoff-preview",
|
||||
operationIdFactory: () => "operation-handoff",
|
||||
sendPremiere: async (script) => {
|
||||
premiereScripts.push(script);
|
||||
return { success: true, data: { imported: true, visualVerified: false, exposedControlDescriptors: [] } };
|
||||
},
|
||||
});
|
||||
|
||||
const preview = await tools.preview_mogrt_premiere_handoff.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
sequence_id: "sequence-1",
|
||||
disposable_sequence_name: "MOGRT Verify - Launch",
|
||||
});
|
||||
expect(preview).toMatchObject({ success: true, data: { previewToken: "handoff-preview" } });
|
||||
await expect(tools.apply_mogrt_premiere_handoff.handler({ preview_token: "handoff-preview", confirm_import: true })).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { visualVerified: false },
|
||||
});
|
||||
expect(premiereScripts[0]).toContain("MOGRT Verify - Launch");
|
||||
expect(premiereScripts[0]).toContain("titleTrack.clips.numItems !== 0");
|
||||
});
|
||||
|
||||
it("previews then queues an existing composition without starting a render", async () => {
|
||||
const scripts: string[] = [];
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: () => false,
|
||||
tokenFactory: () => "render-preview",
|
||||
operationIdFactory: () => "operation-render",
|
||||
sendAfterEffects: async (script) => {
|
||||
scripts.push(script);
|
||||
return { success: true, data: { queued: true, renderStarted: false } };
|
||||
},
|
||||
});
|
||||
const preview = await tools.preview_after_effects_render.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
composition_name: "Launch",
|
||||
output_path: "D:/Approved/renders/Launch.mov",
|
||||
render_settings_template: "Best Settings",
|
||||
output_module_template: "Lossless",
|
||||
});
|
||||
expect(preview).toMatchObject({ success: true, data: { previewToken: "render-preview" } });
|
||||
await expect(tools.enqueue_after_effects_render.handler({ preview_token: "render-preview", confirm_enqueue: true })).resolves.toMatchObject({ success: true, data: { renderStarted: false } });
|
||||
expect(scripts[0]).toContain("project.renderQueue.items.add(comp)");
|
||||
expect(scripts[0]).toContain("renderStarted: false");
|
||||
});
|
||||
|
||||
it("uses AE controller-name readback only when the host exposes that API", async () => {
|
||||
const scripts: string[] = [];
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
operationIdFactory: () => "operation-inspect",
|
||||
sendAfterEffects: async (script) => {
|
||||
scripts.push(script);
|
||||
return { success: true, data: { exposedControlsReadbackAvailable: true, exposedControlNames: ["Headline"] } };
|
||||
},
|
||||
});
|
||||
await expect(tools.inspect_after_effects_template_source.handler({ composition_name: "Launch" })).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { exposedControlsReadbackAvailable: true, exposedControlNames: ["Headline"] },
|
||||
});
|
||||
expect(scripts[0]).toContain("motionGraphicsTemplateControllerCount");
|
||||
expect(scripts[0]).toContain("getMotionGraphicsTemplateControllerName");
|
||||
});
|
||||
|
||||
it("rejects unsafe batch input and returns partial progress without promising rollback", async () => {
|
||||
const sent: string[] = [];
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate.endsWith("batch.json"),
|
||||
readText: () => JSON.stringify([
|
||||
{ recipe: "title_card", template_name: "First", headline: "One" },
|
||||
{ recipe: "quote_card", template_name: "Second", headline: "Two" },
|
||||
]),
|
||||
tokenFactory: () => "batch-token",
|
||||
operationIdFactory: () => "operation-batch-failure",
|
||||
artifactStatus: () => artifact,
|
||||
sendAfterEffects: async (script) => {
|
||||
sent.push(script);
|
||||
return sent.length === 1
|
||||
? { success: true, data: { exportRequested: true } }
|
||||
: { success: false, error: "After Effects refused the second export" };
|
||||
},
|
||||
});
|
||||
|
||||
await expect(tools.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "D:/Approved/data/batch.txt",
|
||||
})).rejects.toThrow(".json or .csv");
|
||||
await expect(tools.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "D:/Approved/data/missing.json",
|
||||
})).rejects.toThrow("does not exist");
|
||||
|
||||
await tools.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "D:/Approved/data/batch.json",
|
||||
});
|
||||
await expect(tools.create_mogrt_batch.handler({ preview_token: "batch-token", confirm_export: false })).rejects.toThrow("confirm_export must be true");
|
||||
await expect(tools.create_mogrt_batch.handler({ preview_token: "batch-token", confirm_export: true })).resolves.toMatchObject({
|
||||
success: false,
|
||||
data: { completed: [{ templateName: "First" }], failedTemplate: "Second", batchAtomic: false },
|
||||
});
|
||||
await expect(tools.create_mogrt_batch.handler({ preview_token: "batch-token", confirm_export: true })).rejects.toThrow("already used");
|
||||
});
|
||||
|
||||
it("parses bounded CSV batches and rejects duplicate output artifacts", async () => {
|
||||
const csvTools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate.endsWith("batch.csv"),
|
||||
readText: () => "recipe,template_name,headline,duration_seconds\nquote_card,Quote,Hello,7\n",
|
||||
tokenFactory: () => "csv-preview",
|
||||
operationIdFactory: () => "operation-csv",
|
||||
});
|
||||
await expect(csvTools.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "D:/Approved/data/batch.csv",
|
||||
})).resolves.toMatchObject({ success: true, data: { plans: [{ recipe: "quote_card", duration_seconds: 7 }] } });
|
||||
|
||||
const duplicateTools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate.endsWith("duplicate.json"),
|
||||
readText: () => JSON.stringify([
|
||||
{ template_name: "Same", headline: "One" },
|
||||
{ template_name: "Same", headline: "Two" },
|
||||
]),
|
||||
});
|
||||
await expect(duplicateTools.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "D:/Approved/data/duplicate.json",
|
||||
})).rejects.toThrow("unique template_name");
|
||||
});
|
||||
|
||||
it("keeps library publishing immutable when the source or destination changes", async () => {
|
||||
let sourceIsValid = true;
|
||||
let destinationExists = false;
|
||||
const tokens = ["library-one", "library-two"];
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate === "D:\\Approved\\templates\\Launch.mogrt" || destinationExists,
|
||||
artifactStatus: () => ({ ...artifact, zip_header_valid: sourceIsValid }),
|
||||
tokenFactory: () => tokens.shift() ?? "library-extra",
|
||||
operationIdFactory: () => "operation-library-race",
|
||||
});
|
||||
const request = {
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
library_directory: "D:/Approved/library",
|
||||
template_name: "Launch",
|
||||
};
|
||||
await tools.preview_mogrt_library_publish.handler(request);
|
||||
sourceIsValid = false;
|
||||
await expect(tools.publish_mogrt_to_library.handler({ preview_token: "library-one", confirm_publish: true })).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("no longer a recognizable ZIP"),
|
||||
});
|
||||
|
||||
sourceIsValid = true;
|
||||
await tools.preview_mogrt_library_publish.handler(request);
|
||||
destinationExists = true;
|
||||
await expect(tools.publish_mogrt_to_library.handler({ preview_token: "library-two", confirm_publish: true })).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("destination now exists"),
|
||||
});
|
||||
});
|
||||
|
||||
it("lists an existing library but never treats a missing root as a publishable target", async () => {
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: (candidate) => !candidate.endsWith("missing") && !candidate.endsWith("Flat"),
|
||||
listDirectory: (candidate) => candidate.endsWith("library") ? ["Launch", "Flat"] : ["v001", "notes", "v002"],
|
||||
operationIdFactory: () => "operation-library-inspect",
|
||||
});
|
||||
await expect(tools.inspect_mogrt_library.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
library_directory: "D:/Approved/missing",
|
||||
})).resolves.toMatchObject({ success: false, error: "library_directory does not exist" });
|
||||
await expect(tools.inspect_mogrt_library.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
library_directory: "D:/Approved/library",
|
||||
})).resolves.toMatchObject({ success: true, data: { templates: [{ name: "Launch", versions: ["v001", "v002"] }, { name: "Flat", versions: [] }] } });
|
||||
});
|
||||
|
||||
it("requires confirmation and reports host failures for render enqueueing", async () => {
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: () => false,
|
||||
tokenFactory: () => "render-token",
|
||||
operationIdFactory: () => "operation-render-failure",
|
||||
sendAfterEffects: async () => ({ success: false, error: "No matching composition" }),
|
||||
});
|
||||
const request = {
|
||||
approved_workspace_path: "D:/Approved",
|
||||
composition_name: "Launch",
|
||||
output_path: "D:/Approved/renders/Launch.mov",
|
||||
render_settings_template: "Best Settings",
|
||||
output_module_template: "Lossless",
|
||||
};
|
||||
await tools.preview_after_effects_render.handler(request);
|
||||
await expect(tools.enqueue_after_effects_render.handler({ preview_token: "render-token", confirm_enqueue: false })).rejects.toThrow("confirm_enqueue must be true");
|
||||
await expect(tools.enqueue_after_effects_render.handler({ preview_token: "render-token", confirm_enqueue: true })).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("No matching composition"),
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a handoff artifact changes or the disposable target is invalid", async () => {
|
||||
let artifactIsValid = true;
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
artifactStatus: () => ({ ...artifact, zip_header_valid: artifactIsValid }),
|
||||
tokenFactory: () => "handoff-token",
|
||||
operationIdFactory: () => "operation-handoff-failure",
|
||||
});
|
||||
const base = {
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
sequence_id: "sequence-1",
|
||||
};
|
||||
await expect(tools.preview_mogrt_premiere_handoff.handler({ ...base, disposable_sequence_name: "Launch" })).rejects.toThrow("must begin");
|
||||
await tools.preview_mogrt_premiere_handoff.handler({ ...base, disposable_sequence_name: "MOGRT Verify - Launch" });
|
||||
await expect(tools.apply_mogrt_premiere_handoff.handler({ preview_token: "handoff-token", confirm_import: false })).rejects.toThrow("confirm_import must be true");
|
||||
artifactIsValid = false;
|
||||
await expect(tools.apply_mogrt_premiere_handoff.handler({ preview_token: "handoff-token", confirm_import: true })).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("no longer a recognizable ZIP"),
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves inspection failure boundaries without creating render queue items", async () => {
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
operationIdFactory: () => "operation-inspection-failure",
|
||||
sendAfterEffects: async () => ({ success: false, error: "No open After Effects project" }),
|
||||
});
|
||||
await expect(tools.inspect_after_effects_template_source.handler({ composition_name: "Launch" })).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("No open After Effects project"),
|
||||
});
|
||||
await expect(tools.inspect_after_effects_render_templates.handler()).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("No open After Effects project"),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed batch files and accepts escaped CSV values without changing a project", async () => {
|
||||
const preview = (contents: string) => getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate.endsWith("rows.csv"),
|
||||
readText: () => contents,
|
||||
tokenFactory: () => "csv-token",
|
||||
}).preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "D:/Approved/data/rows.csv",
|
||||
});
|
||||
|
||||
await expect(preview("recipe,template_name,headline\r\nquote_card,Quote,\"Hello, \"\"world\"\"\"\r\n")).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { plans: [{ headline: "Hello, \"world\"" }] },
|
||||
});
|
||||
await expect(preview("recipe,unsupported\nquote_card,nope\n")).rejects.toThrow("unsupported column");
|
||||
await expect(preview("recipe,template_name,headline\n\"quote_card,Quote,broken")).rejects.toThrow("unterminated quoted field");
|
||||
await expect(preview("recipe,template_name,headline\n")).rejects.toThrow("header row and at least one template row");
|
||||
});
|
||||
|
||||
it("rejects malformed arguments, cross-format paths, and out-of-range handoff coordinates", async () => {
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate.endsWith("rows.json"),
|
||||
readText: () => "{}",
|
||||
artifactStatus: () => artifact,
|
||||
});
|
||||
await expect(tools.preview_mogrt_batch.handler(null)).rejects.toThrow("arguments must be an object");
|
||||
await expect(tools.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "relative",
|
||||
output_directory: "relative/templates",
|
||||
data_file_path: "relative/rows.json",
|
||||
})).rejects.toThrow("Path must be absolute");
|
||||
await expect(tools.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "/Approved/data/rows.json",
|
||||
})).rejects.toThrow("same path format");
|
||||
|
||||
const handoff = {
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
sequence_id: "sequence-1",
|
||||
disposable_sequence_name: "MOGRT Verify - Launch",
|
||||
};
|
||||
await expect(tools.preview_mogrt_premiere_handoff.handler({ ...handoff, mogrt_path: "D:/Approved/templates/Launch.txt" })).rejects.toThrow("must end in .mogrt");
|
||||
await expect(tools.preview_mogrt_premiere_handoff.handler({ ...handoff, video_track_index: 1.5 })).rejects.toThrow("must be an integer");
|
||||
await expect(tools.preview_mogrt_premiere_handoff.handler({ ...handoff, start_seconds: -1 })).rejects.toThrow("must be a finite number");
|
||||
});
|
||||
|
||||
it("rejects missing render and library roots before issuing a confirmation token", async () => {
|
||||
const renderTools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => false,
|
||||
fileExists: () => false,
|
||||
});
|
||||
await expect(renderTools.preview_after_effects_render.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
composition_name: "Launch",
|
||||
output_path: "D:/Approved/renders/Launch.mov",
|
||||
render_settings_template: "Best Settings",
|
||||
output_module_template: "Lossless",
|
||||
})).rejects.toThrow("parent directory must already exist");
|
||||
|
||||
const libraryTools = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => false,
|
||||
fileExists: (candidate) => candidate === "D:\\Approved\\templates\\Launch.mogrt",
|
||||
artifactStatus: () => artifact,
|
||||
});
|
||||
await expect(libraryTools.preview_mogrt_library_publish.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
library_directory: "D:/Approved/library",
|
||||
template_name: "Launch",
|
||||
})).rejects.toThrow("will not create a new library root");
|
||||
});
|
||||
|
||||
it("returns host errors after consuming only a valid Premiere handoff preview", async () => {
|
||||
const tools = getMogrtStudioTools(bridgeOptions, {
|
||||
artifactStatus: () => artifact,
|
||||
tokenFactory: () => "handoff-host-error",
|
||||
operationIdFactory: () => "operation-handoff-host-error",
|
||||
sendPremiere: async () => ({ success: false, error: "The verification track is occupied" }),
|
||||
sendAfterEffects: async () => ({ success: true, data: { readOnly: true } }),
|
||||
});
|
||||
await expect(tools.inspect_after_effects_template_source.handler()).resolves.toMatchObject({ success: true, data: { readOnly: true } });
|
||||
await expect(tools.inspect_after_effects_render_templates.handler()).resolves.toMatchObject({ success: true, data: { readOnly: true } });
|
||||
await tools.preview_mogrt_premiere_handoff.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Launch.mogrt",
|
||||
sequence_id: "sequence-1",
|
||||
disposable_sequence_name: "MOGRT Verify - Launch",
|
||||
video_track_index: 2,
|
||||
audio_track_index: 1,
|
||||
start_seconds: 12.5,
|
||||
});
|
||||
await expect(tools.apply_mogrt_premiere_handoff.handler({ preview_token: "handoff-host-error", confirm_import: true })).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("verification track is occupied"),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed authoring boundaries and expires abandoned render previews", async () => {
|
||||
const guarded = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: (candidate) => candidate.endsWith("logo.png") || candidate.endsWith("Existing.mov"),
|
||||
readText: () => "[]",
|
||||
artifactStatus: () => ({ exists: false, size_bytes: null, zip_header_valid: false }),
|
||||
});
|
||||
await expect(guarded.validate_mogrt_brand_kit.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
brand_kit: { name: "Brand", logo_path: "D:/Approved/logo.png" },
|
||||
})).resolves.toMatchObject({ success: true, data: { logoPathExists: true } });
|
||||
await expect(guarded.preview_mogrt_batch.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
output_directory: "D:/Approved/templates",
|
||||
data_file_path: "D:/Approved/data/rows.json",
|
||||
unsupported: true,
|
||||
})).rejects.toThrow("unsupported field");
|
||||
await expect(guarded.preview_after_effects_render.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
composition_name: "Bad.",
|
||||
output_path: "D:/Approved/renders/New.mov",
|
||||
render_settings_template: "Best Settings",
|
||||
output_module_template: "Lossless",
|
||||
})).rejects.toThrow("composition_name must use");
|
||||
await expect(guarded.preview_after_effects_render.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
composition_name: "Existing",
|
||||
output_path: "D:/Approved/renders/Existing.mov",
|
||||
render_settings_template: "Best Settings",
|
||||
output_module_template: "Lossless",
|
||||
})).rejects.toThrow("already exists");
|
||||
await expect(guarded.preview_mogrt_premiere_handoff.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
mogrt_path: "D:/Approved/templates/Invalid.mogrt",
|
||||
sequence_id: "sequence-1",
|
||||
disposable_sequence_name: "MOGRT Verify - Invalid",
|
||||
})).rejects.toThrow("must exist and have a ZIP header");
|
||||
|
||||
let now = 0;
|
||||
const expiring = getMogrtStudioTools(bridgeOptions, {
|
||||
directoryExists: () => true,
|
||||
fileExists: () => false,
|
||||
tokenFactory: () => "expired-render",
|
||||
now: () => now,
|
||||
});
|
||||
await expiring.preview_after_effects_render.handler({
|
||||
approved_workspace_path: "D:/Approved",
|
||||
composition_name: "Launch",
|
||||
output_path: "D:/Approved/renders/Launch.mov",
|
||||
render_settings_template: "Best Settings",
|
||||
output_module_template: "Lossless",
|
||||
});
|
||||
now = 600_000;
|
||||
await expect(expiring.enqueue_after_effects_render.handler({ preview_token: "expired-render", confirm_enqueue: true })).rejects.toThrow("expired");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockedExecFile, mockedExecFileAsync } = vi.hoisted(() => {
|
||||
const mockedExecFileAsync = vi.fn();
|
||||
const mockedExecFile = vi.fn();
|
||||
mockedExecFile[Symbol.for("nodejs.util.promisify.custom")] = mockedExecFileAsync;
|
||||
return { mockedExecFile, mockedExecFileAsync };
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({ execFile: mockedExecFile }));
|
||||
|
||||
import { getAudioTools } from "../../src/tools/audio.js";
|
||||
|
||||
const tool = getAudioTools({ tempDir: "/tmp/test" }).normalize_loudness_file;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function fixture() {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-normalize-"));
|
||||
const inputPath = join(directory, "input.wav");
|
||||
const outputPath = join(directory, "normalized.wav");
|
||||
writeFileSync(inputPath, "input-audio");
|
||||
return { inputPath, outputPath };
|
||||
}
|
||||
|
||||
function measuredSummary(integratedLufs = -16.2, truePeakDbfs = -1.5) {
|
||||
return `Summary:\n Integrated loudness:\n I: ${integratedLufs} LUFS\n Loudness range:\n LRA: 4.1 LU\n True peak:\n Peak: ${truePeakDbfs} dBFS`;
|
||||
}
|
||||
|
||||
describe("normalize_loudness_file safety boundary", () => {
|
||||
it("rejects invalid standards values before filesystem or FFmpeg work", async () => {
|
||||
await expect(tool.handler({ input_path: "in.wav", output_path: "out.wav", target_lufs: 0 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("target_lufs") });
|
||||
await expect(tool.handler({ input_path: "in.wav", output_path: "out.wav", max_true_peak_dbfs: 2 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("max_true_peak") });
|
||||
});
|
||||
|
||||
it("never permits in-place normalization", async () => {
|
||||
await expect(tool.handler({ input_path: "same.wav", output_path: "same.wav" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("differ") });
|
||||
});
|
||||
|
||||
it("fails before FFmpeg when the input is missing", async () => {
|
||||
await expect(tool.handler({ input_path: "missing.wav", output_path: "new.wav" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("not found") });
|
||||
});
|
||||
|
||||
it("writes a new derivative and remeasures that exact output", async () => {
|
||||
const { inputPath, outputPath } = fixture();
|
||||
mockedExecFileAsync.mockImplementationOnce(async () => {
|
||||
writeFileSync(outputPath, "normalized-audio");
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: measuredSummary() });
|
||||
|
||||
await expect(tool.handler({
|
||||
input_path: inputPath,
|
||||
output_path: outputPath,
|
||||
target_lufs: -16,
|
||||
max_true_peak_dbfs: -1,
|
||||
tolerance_lu: 0.5,
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
inputPath,
|
||||
outputPath,
|
||||
verified: true,
|
||||
loudnessPass: true,
|
||||
truePeakPass: true,
|
||||
},
|
||||
});
|
||||
expect(mockedExecFileAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports a new but out-of-target derivative without claiming verification", async () => {
|
||||
const { inputPath, outputPath } = fixture();
|
||||
mockedExecFileAsync.mockImplementationOnce(async () => {
|
||||
writeFileSync(outputPath, "normalized-audio");
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: measuredSummary(-12, -0.5) });
|
||||
|
||||
await expect(tool.handler({
|
||||
input_path: inputPath,
|
||||
output_path: outputPath,
|
||||
target_lufs: -16,
|
||||
max_true_peak_dbfs: -1,
|
||||
tolerance_lu: 0.5,
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { verified: false, loudnessPass: false, truePeakPass: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("makes missing tools, timeouts, failed writes, and unverifiable outputs explicit", async () => {
|
||||
const missingTool = fixture();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
await expect(tool.handler({ input_path: missingTool.inputPath, output_path: missingTool.outputPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("not found on PATH") });
|
||||
|
||||
const timeout = fixture();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("timeout"), { killed: true }));
|
||||
await expect(tool.handler({ input_path: timeout.inputPath, output_path: timeout.outputPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("timed out") });
|
||||
|
||||
const noOutput = fixture();
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||
await expect(tool.handler({ input_path: noOutput.inputPath, output_path: noOutput.outputPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("no non-empty output") });
|
||||
|
||||
const noMeasurement = fixture();
|
||||
mockedExecFileAsync.mockImplementationOnce(async () => {
|
||||
writeFileSync(noMeasurement.outputPath, "normalized-audio");
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
mockedExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: measuredSummary(Number.NEGATIVE_INFINITY) });
|
||||
await expect(tool.handler({ input_path: noMeasurement.inputPath, output_path: noMeasurement.outputPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("not measurable") });
|
||||
});
|
||||
|
||||
it("rejects unsafe destinations and preserves an FFmpeg failure detail", async () => {
|
||||
const unsafe = fixture();
|
||||
await expect(tool.handler({ input_path: unsafe.inputPath, output_path: unsafe.outputPath, tolerance_lu: -1 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("tolerance_lu") });
|
||||
|
||||
writeFileSync(unsafe.outputPath, "do-not-overwrite");
|
||||
await expect(tool.handler({ input_path: unsafe.inputPath, output_path: unsafe.outputPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("will not be overwritten") });
|
||||
|
||||
const failure = fixture();
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("decode failed"), { stderr: "bad audio stream" }));
|
||||
await expect(tool.handler({ input_path: failure.inputPath, output_path: failure.outputPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("bad audio stream") });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getProjectIntakeTools, projectIntakeCaptureScript } from "../../src/tools/project-intake.js";
|
||||
|
||||
const template = {
|
||||
schemaVersion: 1,
|
||||
id: "facility-intake",
|
||||
version: "1",
|
||||
requiredBins: [{ name: "Incoming" }],
|
||||
allowedExtensions: ["mov"],
|
||||
allowedFrameRates: [],
|
||||
proxyPolicy: "ignore",
|
||||
approvedPathPrefixes: [],
|
||||
requiredEvidence: [],
|
||||
organizationRules: [{ id: "camera", destinationBinName: "Camera", match: { extensions: ["mov"] } }],
|
||||
};
|
||||
|
||||
describe("preview_project_intake tool", () => {
|
||||
it("returns a bounded read-only report without requiring an active sequence", async () => {
|
||||
const captureSnapshot = vi.fn(async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
project: { id: "project-1", name: "Feature" },
|
||||
items: [
|
||||
{ id: "bin-1", name: "Incoming", type: "bin", parentId: "root", treePath: "Incoming" },
|
||||
{ id: "clip-1", name: "A001.mov", type: "clip", parentId: "bin-1", mediaPath: "D:/Secret/A001.mov", offline: false },
|
||||
],
|
||||
truncated: false,
|
||||
unavailableEvidence: [],
|
||||
},
|
||||
}));
|
||||
const tools = getProjectIntakeTools({}, { captureSnapshot });
|
||||
const result = await tools.preview_project_intake.handler({ template, max_items: 25 });
|
||||
|
||||
expect(captureSnapshot).toHaveBeenCalledWith(25);
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
applied: false,
|
||||
capture: { pathDisclosure: "redacted" },
|
||||
organizationPlan: { applied: false, proposedMoves: [{ projectItemId: "clip-1" }] },
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("D:/Secret");
|
||||
});
|
||||
|
||||
it("does not abort the preview when one host item has malformed frame-rate evidence", async () => {
|
||||
const captureSnapshot = vi.fn(async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
project: { id: "project-1", name: "Feature" },
|
||||
items: [{ id: "clip-1", name: "A001.mov", type: "clip", frameRate: null }],
|
||||
truncated: false,
|
||||
unavailableEvidence: [],
|
||||
},
|
||||
}));
|
||||
const tools = getProjectIntakeTools({}, { captureSnapshot });
|
||||
const result = await tools.preview_project_intake.handler({
|
||||
template: { ...template, allowedFrameRates: [29.97], requiredEvidence: ["frame_rate"] },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
status: "incomplete",
|
||||
findings: expect.arrayContaining([
|
||||
expect.objectContaining({ code: "FRAME_RATE_UNSUPPORTED", itemId: "clip-1" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates bridge failures and rejects invalid bounds before capture", async () => {
|
||||
const captureSnapshot = vi.fn(async () => ({ success: false, error: "No project is open" }));
|
||||
const tools = getProjectIntakeTools({}, { captureSnapshot });
|
||||
|
||||
await expect(tools.preview_project_intake.handler({ template })).resolves.toEqual({ success: false, error: "No project is open" });
|
||||
await expect(tools.preview_project_intake.handler({ template, max_items: 0 })).resolves.toMatchObject({ success: false, error: expect.stringContaining("max_items") });
|
||||
expect(captureSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("builds a read-only bounded ExtendScript capture", () => {
|
||||
const script = projectIntakeCaptureScript(17);
|
||||
expect(script).toContain("var maximumItems = 17");
|
||||
expect(script).toContain("No project is open");
|
||||
expect(script).toContain("items.length >= maximumItems");
|
||||
expect(script).not.toContain("activeSequence");
|
||||
expect(script).not.toMatch(/\.moveBin\(|\.createBin\(|\.deleteBin\(/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fsFaults = vi.hoisted(() => ({
|
||||
inaccessibleDirectories: new Set<string>(),
|
||||
vanishedFiles: new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
readdirSync: (...args: Parameters<typeof actual.readdirSync>) => {
|
||||
if (fsFaults.inaccessibleDirectories.has(resolve(String(args[0])))) {
|
||||
const error = Object.assign(new Error("permission denied"), { code: "EACCES" });
|
||||
throw error;
|
||||
}
|
||||
return actual.readdirSync(...args);
|
||||
},
|
||||
statSync: (...args: Parameters<typeof actual.statSync>) => {
|
||||
if (fsFaults.vanishedFiles.has(resolve(String(args[0])))) {
|
||||
const error = Object.assign(new Error("file disappeared"), { code: "ENOENT" });
|
||||
throw error;
|
||||
}
|
||||
return actual.statSync(...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../src/bridge/file-bridge.js")>();
|
||||
return { ...actual, sendCommand: vi.fn() };
|
||||
});
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import {
|
||||
collectBridgeTelemetry,
|
||||
discoverAdjacentRecoveryCandidates,
|
||||
getRecoveryTools,
|
||||
} from "../../src/tools/recovery.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
function temporaryDirectory(): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-recovery-edge-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
fsFaults.inaccessibleDirectories.clear();
|
||||
fsFaults.vanishedFiles.clear();
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("recovery discovery filesystem edges", () => {
|
||||
it("keeps an autosave for a missing project path but marks its project timestamp as unknown", () => {
|
||||
const directory = temporaryDirectory();
|
||||
const missingProject = join(directory, "Rough Cut.prproj");
|
||||
const autosaveDirectory = join(directory, "Premiere Pro Auto-Save");
|
||||
mkdirSync(autosaveDirectory);
|
||||
const autosave = join(autosaveDirectory, "Rough Cut backup.PRPROJ");
|
||||
writeFileSync(autosave, "autosave");
|
||||
mkdirSync(join(autosaveDirectory, "Rough Cut folder.prproj"));
|
||||
|
||||
const candidates = discoverAdjacentRecoveryCandidates(missingProject);
|
||||
|
||||
expect(candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
path: autosave,
|
||||
fileName: "Rough Cut backup.PRPROJ",
|
||||
newerThanProjectFile: null,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits inaccessible autosave directories and candidates that disappear during inspection", () => {
|
||||
const directory = temporaryDirectory();
|
||||
const project = join(directory, "Episode.prproj");
|
||||
writeFileSync(project, "project");
|
||||
const inaccessibleAutosaves = join(directory, "Adobe Premiere Pro Auto-Save");
|
||||
mkdirSync(inaccessibleAutosaves);
|
||||
writeFileSync(join(inaccessibleAutosaves, "Episode inaccessible.prproj"), "autosave");
|
||||
fsFaults.inaccessibleDirectories.add(resolve(inaccessibleAutosaves));
|
||||
const vanishing = join(directory, "Episode vanishing.prproj");
|
||||
writeFileSync(vanishing, "autosave");
|
||||
fsFaults.vanishedFiles.add(resolve(vanishing));
|
||||
|
||||
expect(discoverAdjacentRecoveryCandidates(project)).toEqual([]);
|
||||
});
|
||||
|
||||
it("bounds discovery to fifty candidates even when more matching autosaves exist", () => {
|
||||
const directory = temporaryDirectory();
|
||||
const project = join(directory, "Assembly.prproj");
|
||||
writeFileSync(project, "project");
|
||||
const autosaveDirectory = join(directory, "Adobe Premiere Pro Auto-Save");
|
||||
mkdirSync(autosaveDirectory);
|
||||
for (let index = 0; index < 51; index++) {
|
||||
const candidate = join(autosaveDirectory, `Assembly-${String(index).padStart(2, "0")}.prproj`);
|
||||
writeFileSync(candidate, String(index));
|
||||
utimesSync(candidate, new Date(1_000 + index), new Date(1_000 + index));
|
||||
}
|
||||
|
||||
const candidates = discoverAdjacentRecoveryCandidates(project);
|
||||
expect(candidates).toHaveLength(50);
|
||||
expect(candidates[0].modifiedMs).toBeGreaterThanOrEqual(candidates.at(-1)!.modifiedMs);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bridge telemetry filesystem edges", () => {
|
||||
it("remains useful when an individual pending file disappears", () => {
|
||||
const directory = temporaryDirectory();
|
||||
const pending = join(directory, "cmd_transient.jsx");
|
||||
writeFileSync(pending, "private script");
|
||||
fsFaults.vanishedFiles.add(resolve(pending));
|
||||
|
||||
expect(collectBridgeTelemetry({ tempDir: directory }, 50_000)).toMatchObject({
|
||||
directoryAccessible: true,
|
||||
pendingCommands: 1,
|
||||
pendingResponses: 0,
|
||||
busyOperations: 0,
|
||||
oldestPendingAgeMs: null,
|
||||
healthy: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an inaccessible bridge directory as unhealthy without exposing its details", async () => {
|
||||
const directory = temporaryDirectory();
|
||||
fsFaults.inaccessibleDirectories.add(resolve(directory));
|
||||
const tools = getRecoveryTools({ tempDir: directory });
|
||||
|
||||
const result = await tools.get_bridge_telemetry.handler();
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
directoryAccessible: false,
|
||||
pendingCommands: 0,
|
||||
pendingResponses: 0,
|
||||
busyOperations: 0,
|
||||
healthy: false,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain(directory);
|
||||
});
|
||||
|
||||
it("marks an old response unhealthy while ignoring unrelated bridge files", () => {
|
||||
const directory = temporaryDirectory();
|
||||
const response = join(directory, "res_old.json");
|
||||
writeFileSync(response, "{}");
|
||||
writeFileSync(join(directory, "not-a-queue-file.txt"), "private");
|
||||
utimesSync(response, new Date(1_000), new Date(1_000));
|
||||
|
||||
expect(collectBridgeTelemetry({ tempDir: directory }, 32_000)).toMatchObject({
|
||||
directoryAccessible: true,
|
||||
pendingCommands: 0,
|
||||
pendingResponses: 1,
|
||||
busyOperations: 0,
|
||||
oldestPendingAgeMs: 31_000,
|
||||
healthy: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("recovery inspection snapshots", () => {
|
||||
it.each([
|
||||
[null],
|
||||
[{}],
|
||||
[{ name: "Project", path: 17 }],
|
||||
[{ name: 17, path: "project.prproj" }],
|
||||
])("rejects an invalid Premiere snapshot: %j", async (data) => {
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: true, data });
|
||||
const result = await getRecoveryTools({ tempDir: temporaryDirectory() }).inspect_project_recovery.handler();
|
||||
expect(result).toEqual({ success: false, error: "Premiere returned an invalid project snapshot" });
|
||||
});
|
||||
|
||||
it("preserves bridge failures instead of attempting local recovery discovery", async () => {
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: false, error: "No project is open" });
|
||||
await expect(getRecoveryTools({ tempDir: temporaryDirectory() }).inspect_project_recovery.handler())
|
||||
.resolves.toEqual({ success: false, error: "No project is open" });
|
||||
});
|
||||
|
||||
it("explains the unsaved-project boundary and missing saved project boundary distinctly", async () => {
|
||||
const tools = getRecoveryTools({ tempDir: temporaryDirectory() });
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { name: "Untitled", path: "" } });
|
||||
await expect(tools.inspect_project_recovery.handler()).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
project: { name: "Untitled", path: null, hasSavedPath: false, fileExists: false },
|
||||
recovery: {
|
||||
candidateCount: 0,
|
||||
guidance: expect.stringContaining("Save the project"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const missingProject = join(temporaryDirectory(), "missing.prproj");
|
||||
mockedSendCommand.mockResolvedValueOnce({ success: true, data: { name: "Missing", path: missingProject } });
|
||||
await expect(tools.inspect_project_recovery.handler()).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
project: { name: "Missing", path: missingProject, hasSavedPath: true, fileExists: false },
|
||||
recovery: {
|
||||
candidateCount: 0,
|
||||
guidance: expect.stringContaining("No adjacent .prproj"),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../src/bridge/file-bridge.js")>();
|
||||
return { ...actual, sendCommand: vi.fn() };
|
||||
});
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import {
|
||||
collectBridgeTelemetry,
|
||||
discoverAdjacentRecoveryCandidates,
|
||||
getRecoveryTools,
|
||||
} from "../../src/tools/recovery.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
function temporaryDirectory() {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-recovery-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("recovery candidate discovery", () => {
|
||||
it("finds matching adjacent autosaves newest-first without modifying them", () => {
|
||||
const directory = temporaryDirectory();
|
||||
const project = join(directory, "Episode 01.prproj");
|
||||
writeFileSync(project, "project");
|
||||
const autosaveDirectory = join(directory, "Adobe Premiere Pro Auto-Save");
|
||||
mkdirSync(autosaveDirectory);
|
||||
const older = join(autosaveDirectory, "Episode 01-1.prproj");
|
||||
const newer = join(autosaveDirectory, "Episode 01-2.prproj");
|
||||
const unrelated = join(autosaveDirectory, "Other Project.prproj");
|
||||
writeFileSync(older, "old");
|
||||
writeFileSync(newer, "new");
|
||||
writeFileSync(unrelated, "unrelated");
|
||||
utimesSync(project, new Date(1_000), new Date(1_000));
|
||||
utimesSync(older, new Date(2_000), new Date(2_000));
|
||||
utimesSync(newer, new Date(3_000), new Date(3_000));
|
||||
|
||||
const candidates = discoverAdjacentRecoveryCandidates(project);
|
||||
expect(candidates.map((item) => item.fileName)).toEqual([
|
||||
"Episode 01-2.prproj",
|
||||
"Episode 01-1.prproj",
|
||||
]);
|
||||
expect(candidates.every((item) => item.newerThanProjectFile)).toBe(true);
|
||||
expect(readFileSync(newer, "utf8")).toBe("new");
|
||||
});
|
||||
|
||||
it("does not scan when no saved Premiere project path exists", () => {
|
||||
expect(discoverAdjacentRecoveryCandidates("")).toEqual([]);
|
||||
expect(discoverAdjacentRecoveryCandidates("/tmp/not-a-project.txt")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("privacy-preserving bridge telemetry", () => {
|
||||
it("does not report a quiet directory as healthy when no CEP heartbeat is present", () => {
|
||||
const directory = temporaryDirectory();
|
||||
expect(collectBridgeTelemetry({ tempDir: directory })).toMatchObject({
|
||||
directoryAccessible: true,
|
||||
heartbeat: { state: "unknown" },
|
||||
healthy: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns aggregate state without paths, filenames, or contents", () => {
|
||||
const directory = temporaryDirectory();
|
||||
writeFileSync(join(directory, "cmd_secret.jsx"), "private project script");
|
||||
writeFileSync(join(directory, "res_secret.json"), '{"project":"Private"}');
|
||||
writeFileSync(join(directory, "busy_secret.json"), "{}");
|
||||
writeFileSync(join(directory, "bridge-heartbeat.json"), JSON.stringify({
|
||||
protocolVersion: 1,
|
||||
state: "running",
|
||||
}));
|
||||
writeFileSync(join(directory, "helpers_ignored.jsx"), "helper");
|
||||
|
||||
const telemetry = collectBridgeTelemetry(
|
||||
{ tempDir: directory },
|
||||
Date.now() + 100,
|
||||
);
|
||||
expect(telemetry).toMatchObject({
|
||||
directoryAccessible: true,
|
||||
pendingCommands: 1,
|
||||
pendingResponses: 1,
|
||||
busyOperations: 1,
|
||||
heartbeat: { state: "running" },
|
||||
healthy: false,
|
||||
});
|
||||
const serialized = JSON.stringify(telemetry);
|
||||
expect(serialized).not.toContain(directory);
|
||||
expect(serialized).not.toContain("secret");
|
||||
expect(serialized).not.toContain("Private");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recovery tools", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("reports the unavailable dirty flag and never emits restore code", async () => {
|
||||
const directory = temporaryDirectory();
|
||||
const project = join(directory, "Documentary.prproj");
|
||||
writeFileSync(project, "project");
|
||||
mockedSendCommand.mockResolvedValue({
|
||||
success: true,
|
||||
data: { name: "Documentary", path: project },
|
||||
});
|
||||
const tools = getRecoveryTools({ tempDir: join(directory, "bridge") });
|
||||
const result = await tools.inspect_project_recovery.handler();
|
||||
expect(result.success).toBe(true);
|
||||
expect((result.data as any).unsavedChanges).toMatchObject({
|
||||
status: "not_exposed",
|
||||
dirty: null,
|
||||
});
|
||||
expect((result.data as any).recovery.automaticRestoreSupported).toBe(false);
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).not.toContain("openDocument");
|
||||
expect(script).not.toContain("openProject");
|
||||
expect(script).not.toContain(".save");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bridge panel accessibility", () => {
|
||||
it("uses semantic live regions, labels, keyboard focus, and forced-colors CSS", () => {
|
||||
const cepHtml = readFileSync(
|
||||
new URL("../../cep-plugin/index.html", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const cepCss = readFileSync(
|
||||
new URL("../../cep-plugin/styles.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const uxpHtml = readFileSync(
|
||||
new URL("../../uxp-plugin/index.html", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
expect(cepHtml).toContain('role="status"');
|
||||
expect(cepHtml).toContain('aria-label="Bridge activity log"');
|
||||
expect(cepHtml).toContain('tabindex="0"');
|
||||
expect(cepCss).toContain("@media (forced-colors: active)");
|
||||
expect(cepCss).toContain("focus-visible");
|
||||
expect(uxpHtml).toContain('role="status"');
|
||||
expect(uxpHtml).toContain('lang="en"');
|
||||
});
|
||||
});
|
||||
+871
@@ -0,0 +1,871 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { getHelpersSource } from "../../src/bridge/script-builder.js";
|
||||
import { BridgeOptions } from "../../src/bridge/file-bridge.js";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
sendRawCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
getTempDir: vi.fn().mockReturnValue("/tmp/test"),
|
||||
cleanupTempDir: vi.fn(),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getMarkerTools } from "../../src/tools/markers.js";
|
||||
import { getExportTools } from "../../src/tools/export.js";
|
||||
import { getUtilityTools } from "../../src/tools/utility.js";
|
||||
import { getTrackTargetingTools } from "../../src/tools/track-targeting.js";
|
||||
import { getEffectsTools } from "../../src/tools/effects.js";
|
||||
import { getClipboardTools } from "../../src/tools/clipboard.js";
|
||||
import { getTimelineTools } from "../../src/tools/timeline.js";
|
||||
import { getAdvancedTools } from "../../src/tools/advanced.js";
|
||||
import { getProjectTools } from "../../src/tools/project.js";
|
||||
import { getMediaTools } from "../../src/tools/media.js";
|
||||
import { getTextTools } from "../../src/tools/text.js";
|
||||
import { getKeyframeTools } from "../../src/tools/keyframes.js";
|
||||
import { getCaptionTools } from "../../src/tools/captions.js";
|
||||
import { getSequenceTools } from "../../src/tools/sequence.js";
|
||||
import { getPlayheadTools } from "../../src/tools/playhead.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const bridgeOptions: BridgeOptions = { tempDir: "/tmp/test-bridge", timeoutMs: 5000 };
|
||||
|
||||
/** Run a tool handler and return the ExtendScript it generated. */
|
||||
async function scriptFor(tool: { handler: (args: never) => Promise<unknown> }, args: unknown) {
|
||||
mockedSendCommand.mockClear();
|
||||
await tool.handler(args as never);
|
||||
expect(mockedSendCommand).toHaveBeenCalled();
|
||||
return mockedSendCommand.mock.calls[0][0] as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same, with comments removed. The helpers name the broken APIs in prose to explain
|
||||
* why they're avoided ("ProjectItem has no createProxy()"), so an assertion that a
|
||||
* method is never *called* has to look at code only.
|
||||
*/
|
||||
async function codeFor(tool: { handler: (args: never) => Promise<unknown> }, args: unknown) {
|
||||
const script = await scriptFor(tool, args);
|
||||
return script.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
async function executePixelAspectRatioScript(sequence: unknown, ratio = "1.4222") {
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
const script = await scriptFor(utility.set_sequence_pixel_aspect_ratio, { ratio });
|
||||
return JSON.parse(String(runInNewContext(`${getHelpersSource()}\n${script}`, {
|
||||
app: { project: { activeSequence: sequence } },
|
||||
})));
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("real-host social sequence regressions", () => {
|
||||
const sequence = getSequenceTools(bridgeOptions);
|
||||
const playhead = getPlayheadTools(bridgeOptions);
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
|
||||
it("calls Auto Reframe with its required five arguments and reports the derivative", async () => {
|
||||
const script = await scriptFor(sequence.auto_reframe_sequence, {
|
||||
sequence_id: "source-1",
|
||||
target_width: 1080,
|
||||
target_height: 1920,
|
||||
motion_preset: "faster",
|
||||
new_name: "TikTok Test",
|
||||
});
|
||||
expect(script).toContain('seq.autoReframeSequence(9, 16, "faster", newName, false)');
|
||||
expect(script).toContain("if (!reframed) return __error");
|
||||
expect(script).toContain("id: reframed.sequenceID");
|
||||
});
|
||||
|
||||
it("sets and reads sequence in/out points in seconds with verification", async () => {
|
||||
const setScript = await scriptFor(playhead.set_sequence_in_out_points, { in_seconds: 0, out_seconds: 60 });
|
||||
expect(setScript).toContain("seq.setInPoint(0)");
|
||||
expect(setScript).toContain("seq.setOutPoint(60)");
|
||||
expect(setScript).not.toContain("__secondsToTicks(60)");
|
||||
expect(setScript).toContain("Math.abs(observedOut - 60)");
|
||||
|
||||
const getScript = await scriptFor(playhead.get_sequence_in_out_points, {});
|
||||
expect(getScript).toContain("outSeconds: Number(seq.getOutPoint())");
|
||||
expect(getScript).not.toContain("__ticksToSeconds(seq.getOutPoint())");
|
||||
});
|
||||
|
||||
it("uses documented DOM work-area accessors instead of unavailable properties and method names", async () => {
|
||||
const workArea = await codeFor(playhead.get_work_area, {});
|
||||
expect(workArea).toContain("seq.getWorkAreaInPoint()");
|
||||
expect(workArea).toContain("seq.getWorkAreaOutPoint()");
|
||||
expect(workArea).not.toContain("seq.workInPoint");
|
||||
expect(workArea).not.toContain("seq.workOutPoint");
|
||||
|
||||
const enabled = await codeFor(sequence.is_work_area_enabled, {});
|
||||
expect(enabled).toContain("seq.isWorkAreaEnabled()");
|
||||
expect(enabled).not.toContain("seq.isWorkAreaBarEnabled()");
|
||||
});
|
||||
|
||||
it("matches a sequence through projectItem.nodeId and verifies both names", async () => {
|
||||
const script = await scriptFor(utility.rename_project_item, { item_id: "item-1", new_name: "Instagram Test" });
|
||||
expect(script).toContain("candidate.projectItem.nodeId === item.nodeId");
|
||||
expect(script).toContain('sequence.name = "Instagram Test"');
|
||||
expect(script).toContain("sequence && sequence.name !==");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/6
|
||||
describe("issue #6 — markers must use seconds, not ticks", () => {
|
||||
const markers = getMarkerTools(bridgeOptions);
|
||||
|
||||
it("add_marker passes seconds straight to createMarker", async () => {
|
||||
const script = await scriptFor(markers.add_marker, { time_seconds: 2.0 });
|
||||
|
||||
expect(script).toContain("createMarker(2)");
|
||||
// The old bug: __secondsToTicks(2) -> 508032000000 handed to createMarker(),
|
||||
// placing the marker ~508 billion seconds down the timeline.
|
||||
expect(script).not.toContain("__secondsToTicks(2).toString()");
|
||||
expect(script).not.toMatch(/createMarker\(parseFloat/);
|
||||
});
|
||||
|
||||
it("add_marker sets marker.end in seconds when given a duration", async () => {
|
||||
const script = await scriptFor(markers.add_marker, { time_seconds: 2.0, duration_seconds: 3.0 });
|
||||
|
||||
expect(script).toContain("marker.end = 5");
|
||||
expect(script).not.toMatch(/marker\.end = __secondsToTicks/);
|
||||
});
|
||||
|
||||
it("list_markers reads Time.seconds rather than re-converting ticks", async () => {
|
||||
const script = await scriptFor(markers.list_markers, {});
|
||||
|
||||
expect(script).toContain("startSeconds: marker.start.seconds");
|
||||
expect(script).toContain("endSeconds: marker.end.seconds");
|
||||
expect(script).not.toContain("__ticksToSeconds(marker.start.ticks)");
|
||||
});
|
||||
|
||||
it("delete_marker still compares ticks against ticks", async () => {
|
||||
// This path was always correct — both sides are ticks. Guard it so the #6
|
||||
// fix doesn't get over-applied here.
|
||||
const script = await scriptFor(markers.delete_marker, { time_seconds: 2.0 });
|
||||
|
||||
expect(script).toContain("__secondsToTicks(2)");
|
||||
expect(script).toContain("marker.start.ticks");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/7
|
||||
describe("issue #7 — no calls to nonexistent ExtendScript methods", () => {
|
||||
const exportTools = getExportTools(bridgeOptions);
|
||||
const trackTargeting = getTrackTargetingTools(bridgeOptions);
|
||||
|
||||
it("manage_proxies never calls ProjectItem.createProxy()", async () => {
|
||||
const code = await codeFor(exportTools.manage_proxies, {
|
||||
item_id: "clip1",
|
||||
action: "create",
|
||||
output_path: "/tmp/proxy.mov",
|
||||
});
|
||||
|
||||
// ProjectItem has no createProxy(); proxies must go through Media Encoder.
|
||||
expect(code).not.toContain("createProxy");
|
||||
expect(code).toContain("encodeProjectItem");
|
||||
});
|
||||
|
||||
it("manage_proxies 'create' refuses to report false success without an output path", async () => {
|
||||
const script = await scriptFor(exportTools.manage_proxies, { item_id: "clip1", action: "create" });
|
||||
|
||||
expect(script).toContain("output_path is required");
|
||||
expect(script).not.toContain("Proxy creation started");
|
||||
});
|
||||
|
||||
it("manage_proxies 'toggle' reports the state it actually set", async () => {
|
||||
const script = await scriptFor(exportTools.manage_proxies, { item_id: "clip1", action: "toggle" });
|
||||
|
||||
// The old code reported !isProxyEnabled() *after* flipping it — i.e. the inverse
|
||||
// of the truth, every single time.
|
||||
expect(script).toContain("proxiesEnabled: enabled");
|
||||
expect(script).not.toContain("proxiesEnabled: !app.project.isProxyEnabled()");
|
||||
});
|
||||
|
||||
it("get_encoder_presets never calls encoder.getFormatList()", async () => {
|
||||
const code = await codeFor(trackTargeting.get_encoder_presets, { format: "H.264" });
|
||||
|
||||
// EncoderManager has no getFormatList(); presets are found by scanning .epr files.
|
||||
expect(code).not.toContain("getFormatList");
|
||||
expect(code).toContain("__collectAllPresets()");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/9
|
||||
describe("issue #9 — frame export uses the QE DOM and verifies the file landed", () => {
|
||||
const exportTools = getExportTools(bridgeOptions);
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
|
||||
const frameTools: Array<[string, { handler: (args: never) => Promise<unknown> }, unknown]> = [
|
||||
["export_frame", exportTools.export_frame, { output_path: "/tmp/f.png" }],
|
||||
["capture_frame", exportTools.capture_frame, {}],
|
||||
["freeze_frame", utility.freeze_frame, { output_path: "/tmp/f.png" }],
|
||||
];
|
||||
|
||||
for (const [name, tool, args] of frameTools) {
|
||||
it(`${name} does not call exportFramePNG on the public DOM sequence`, async () => {
|
||||
const script = await scriptFor(tool, args);
|
||||
|
||||
// exportFramePNG exists only on the QE sequence. Calling it on
|
||||
// app.project.activeSequence throws "seq.exportFramePNG is not a function".
|
||||
expect(script).not.toMatch(/seq\.exportFramePNG/);
|
||||
expect(script).toContain("__exportStillFrame(");
|
||||
});
|
||||
|
||||
it(`${name} surfaces an error instead of claiming success when no file is written`, async () => {
|
||||
const script = await scriptFor(tool, args);
|
||||
|
||||
expect(script).toContain("if (!res.ok) return __error(");
|
||||
});
|
||||
}
|
||||
|
||||
it("sets and restores the AME one-frame range in seconds", () => {
|
||||
const helpers = getHelpersSource();
|
||||
|
||||
// Sequence.setInPoint/setOutPoint accept seconds. Passing ticks here made
|
||||
// the fallback target an enormous range and prevented a still from landing.
|
||||
expect(helpers).toContain("seq.setInPoint(__ticksToSeconds(startTicks))");
|
||||
expect(helpers).toContain("seq.setOutPoint(__ticksToSeconds(startTicks + frameTicks))");
|
||||
expect(helpers).toContain("seq.setInPoint(__ticksToSeconds(savedIn))");
|
||||
expect(helpers).toContain("seq.setOutPoint(__ticksToSeconds(savedOut))");
|
||||
expect(helpers).not.toContain("seq.setInPoint(String(startTicks))");
|
||||
});
|
||||
});
|
||||
|
||||
// Defects found while reviewing PR #3 (repair 6 broken tools on Premiere Pro 2026).
|
||||
describe("PR #3 follow-ups — color_correct and export_sequence", () => {
|
||||
const effects = getEffectsTools(bridgeOptions);
|
||||
const exportTools = getExportTools(bridgeOptions);
|
||||
|
||||
it("color_correct applies a value of 0 exactly once", async () => {
|
||||
const code = await codeFor(effects.color_correct, { node_id: "clip1", saturation: 0 });
|
||||
|
||||
// saturation: 0 is a valid full desaturate. Guarding on `!changes.saturation`
|
||||
// leaves the guard permanently open for 0, re-firing setValue on every later
|
||||
// Lumetri sub-section that repeats the "Saturation" display name.
|
||||
expect(code).toContain("!taken.saturation");
|
||||
expect(code).toContain("taken.saturation = true");
|
||||
expect(code).not.toContain("!changes.saturation");
|
||||
});
|
||||
|
||||
it("color_correct carries no dead helper", async () => {
|
||||
const code = await codeFor(effects.color_correct, { node_id: "clip1", exposure: 1 });
|
||||
expect(code).not.toContain("trySet");
|
||||
});
|
||||
|
||||
it("color_correct only emits setters for the controls it was given", async () => {
|
||||
const code = await codeFor(effects.color_correct, { node_id: "clip1", exposure: 1.5 });
|
||||
|
||||
expect(code).toContain('name === "Exposure"');
|
||||
expect(code).not.toContain('name === "Saturation"');
|
||||
});
|
||||
|
||||
it("export_sequence finds its default preset without hardcoding a version year", async () => {
|
||||
const code = await codeFor(exportTools.export_sequence, { output_path: "/tmp/out.mp4" });
|
||||
|
||||
expect(code).toContain("__findH264Preset()");
|
||||
// Hardcoded install paths rot the moment Adobe ships the next version.
|
||||
expect(code).not.toContain("Adobe Media Encoder 2025");
|
||||
expect(code).not.toContain("Adobe Media Encoder 2026");
|
||||
});
|
||||
});
|
||||
|
||||
describe("script-builder helpers used by the fixes are actually defined", () => {
|
||||
const exportTools = getExportTools(bridgeOptions);
|
||||
|
||||
it("defines every helper the generated scripts call", async () => {
|
||||
const script = getHelpersSource();
|
||||
|
||||
for (const helper of [
|
||||
"function __exportStillFrame(",
|
||||
"function __firstWrittenFile(",
|
||||
"function __findStillPreset(",
|
||||
"function __collectAllPresets(",
|
||||
"function __findProxyPreset(",
|
||||
"function __findH264Preset(",
|
||||
"function __adobeAppFolders(",
|
||||
"function __collectEprFiles(",
|
||||
]) {
|
||||
expect(script).toContain(helper);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/189
|
||||
describe("issue #189 — Premiere 26.3 capability boundaries and macOS presets", () => {
|
||||
const trackTargeting = getTrackTargetingTools(bridgeOptions);
|
||||
const timeline = getTimelineTools(bridgeOptions);
|
||||
const advanced = getAdvancedTools(bridgeOptions);
|
||||
const text = getTextTools(bridgeOptions);
|
||||
const keyframes = getKeyframeTools(bridgeOptions);
|
||||
const captions = getCaptionTools(bridgeOptions);
|
||||
|
||||
it("searches resources inside macOS application bundles and normalizes H.264 filters", async () => {
|
||||
const helpers = getHelpersSource();
|
||||
const code = await codeFor(trackTargeting.get_encoder_presets, { format: "H.264" });
|
||||
|
||||
expect(helpers).toContain("function __adobeApplicationResourceFolder(");
|
||||
expect(helpers).toContain('"/Contents/"');
|
||||
expect(helpers).toContain('"MediaIO/systempresets"');
|
||||
expect(code).toContain('__presetSearchText("H.264")');
|
||||
expect(code).toContain("__presetSearchText(p.name)");
|
||||
});
|
||||
|
||||
it("never calls unsupported speed setters", async () => {
|
||||
const variants = [
|
||||
timeline.speed_change.handler({ node_id: "clip-1", speed_percent: 65 }),
|
||||
advanced.set_clip_speed_qe.handler({ node_id: "clip-1", speed_percent: 65 }),
|
||||
timeline.set_clip_properties.handler({ node_id: "clip-1", speed: 0.65 }),
|
||||
];
|
||||
|
||||
await expect(Promise.all(variants)).resolves.toEqual([
|
||||
expect.objectContaining({ success: false, error: expect.stringContaining("No mutation was attempted") }),
|
||||
expect.objectContaining({ success: false, error: expect.stringContaining("No mutation was attempted") }),
|
||||
expect.objectContaining({ success: false, error: expect.stringContaining("No mutation was attempted") }),
|
||||
]);
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails raw-text caption creation before it can call an unsupported signature", async () => {
|
||||
const result = await text.add_text_overlay.handler({ text: "TREINO UPPER" });
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.stringContaining("No mutation was attempted"),
|
||||
}));
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("labels keyframe and caption results as storage or structure verification, not rendered output", async () => {
|
||||
const keyframeScript = await scriptFor(keyframes.add_keyframe, {
|
||||
node_id: "clip-1", effect_name: "Opacity", property_name: "Opacity", time_seconds: 0, value: 0,
|
||||
});
|
||||
expect(keyframeScript).toContain("getValueAtKey(time)");
|
||||
expect(keyframeScript).toContain("renderVerified: false");
|
||||
expect(keyframeScript).toContain("Premiere parameter readback only");
|
||||
|
||||
const captionScript = await scriptFor(captions.create_caption_track, { item_id: "captions.srt" });
|
||||
expect(captionScript).toContain("renderVerified: false");
|
||||
expect(captionScript).toContain("verify playback or exported frames");
|
||||
});
|
||||
|
||||
it("validates and structurally verifies timeline insertion instead of trusting insertClip", async () => {
|
||||
const invalid = await timeline.add_to_timeline.handler({ item_id: "clip-1", start_seconds: -1 });
|
||||
expect(invalid).toEqual(expect.objectContaining({ success: false }));
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
|
||||
const script = await scriptFor(timeline.add_to_timeline, {
|
||||
item_id: "clip-1", track_index: 0, audio_track_index: 0, start_seconds: 3.4,
|
||||
});
|
||||
expect(script).toContain("beforeVideoCount");
|
||||
expect(script).toContain("afterVideoCount > beforeVideoCount + 1");
|
||||
expect(script).toContain("residual frame fragment");
|
||||
expect(script).toContain("matchedItem");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/194
|
||||
describe("issue #194 — string-backed MOGRT effect properties", () => {
|
||||
const keyframes = getKeyframeTools(bridgeOptions);
|
||||
|
||||
it("accepts and safely serializes the JSON string exposed by MOGRT text properties", async () => {
|
||||
expect(keyframes.set_effect_property.parameters.properties.value).toMatchObject({
|
||||
type: ["number", "string"],
|
||||
});
|
||||
|
||||
const script = await scriptFor(keyframes.set_effect_property, {
|
||||
node_id: "clip-1",
|
||||
effect_name: "AE.ADBE Capsule",
|
||||
property_name: "Source Text",
|
||||
value: '{"textEditValue":"Hello \\"editor\\""}',
|
||||
});
|
||||
|
||||
expect(script).toContain('var requestedValue = "{\\"textEditValue\\":\\"Hello \\\\\\\"editor\\\\\\\"\\"}";');
|
||||
expect(script).toContain("prop.setValue(requestedValue, true)");
|
||||
expect(script).toContain("readbackVerified: readbackAvailable && readbackValue === requestedValue");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/196
|
||||
describe("issue #196 — empty Premiere 26.x QE effect catalogs", () => {
|
||||
const effects = getEffectsTools(bridgeOptions);
|
||||
|
||||
it("probes an exact QE effect name before treating an empty catalog as unavailable", async () => {
|
||||
const script = await scriptFor(effects.apply_effect, {
|
||||
node_id: "clip-1",
|
||||
effect_name: "Transform",
|
||||
});
|
||||
|
||||
expect(script).toContain("qe.project.getVideoEffectByName(effectName)");
|
||||
expect(script).toContain('var effectCatalog = __getQeEffectCatalog("video")');
|
||||
expect(script).toContain("Direct QE lookup for");
|
||||
expect(script).toContain('lookupSource: lookupSource');
|
||||
|
||||
const listScript = await scriptFor(effects.list_available_effects, {});
|
||||
expect(listScript).toContain("var commonNames = [");
|
||||
expect(listScript).toContain("qe.byName.partial");
|
||||
expect(listScript).toContain("Direct lookup did not resolve any bounded fallback effects.");
|
||||
|
||||
const helpers = getHelpersSource();
|
||||
expect(helpers).toContain("function __getQeEffectCatalog(kind)");
|
||||
expect(helpers).toContain("Premiere returned an empty legacy QE");
|
||||
expect(helpers).toContain("no effect was applied");
|
||||
expect(helpers).toContain("manage_clip_effects_uxp");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/129
|
||||
describe("issue #129 — CEP component removal is capability-gated", () => {
|
||||
const effects = getEffectsTools(bridgeOptions);
|
||||
const clipboard = getClipboardTools(bridgeOptions);
|
||||
|
||||
it.each([
|
||||
["index", { node_id: "clip1", effect_index: 3 }],
|
||||
["name", { node_id: "clip1", effect_name: "Amplify" }],
|
||||
])("remove_effect by %s guards an unavailable Component.remove()", async (_mode, args) => {
|
||||
const code = await codeFor(effects.remove_effect, args);
|
||||
|
||||
expect(code).toContain('typeof component.remove !== "function"');
|
||||
expect(code).toContain("No safe targeted QE fallback exists");
|
||||
expect(code).toContain("return __error(removal.error)");
|
||||
expect(code).not.toContain("qeClip.removeEffects()");
|
||||
});
|
||||
|
||||
it("preflights every matching component before remove_effect_by_name mutates any", async () => {
|
||||
const code = await codeFor(clipboard.remove_effect_by_name, {
|
||||
node_id: "clip1",
|
||||
effect_name: "Amplify",
|
||||
});
|
||||
|
||||
expect(code).toContain("var matches = []");
|
||||
expect(code).toContain("if (!canRemoveComponent(component))");
|
||||
expect(code).toContain("No matching components were removed");
|
||||
expect(code).not.toContain("qeClip.removeEffects()");
|
||||
expect(code.indexOf("if (!canRemoveComponent(component))"))
|
||||
.toBeLessThan(code.lastIndexOf("component.remove()"));
|
||||
});
|
||||
|
||||
it("documents the capability boundary in each targeted removal tool", () => {
|
||||
expect(effects.remove_effect.description).toContain("capability error");
|
||||
expect(clipboard.remove_effect_by_name.description).toContain("capability error");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/37
|
||||
describe("issue #37 — sequence frame rate uses ticks per frame", () => {
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
|
||||
it("converts fps to a Time duration and verifies the applied ticks", async () => {
|
||||
const script = await scriptFor(utility.set_sequence_frame_rate, { frame_rate: 30 });
|
||||
|
||||
expect(script).toContain("TICKS_PER_SECOND / requestedFps");
|
||||
expect(script).toContain("var frameDuration = new Time()");
|
||||
expect(script).toContain("frameDuration.ticks = requestedTicks.toString()");
|
||||
expect(script).toContain("settings.videoFrameRate = frameDuration");
|
||||
expect(script).toContain("Math.abs(appliedTicks - requestedTicks) > 1");
|
||||
expect(script).not.toContain("settings.videoFrameRate = 30");
|
||||
});
|
||||
|
||||
it("rejects invalid frame rates before sending a Premiere command", async () => {
|
||||
mockedSendCommand.mockClear();
|
||||
const result = await utility.set_sequence_frame_rate.handler({ frame_rate: 0 });
|
||||
|
||||
expect(result).toMatchObject({ success: false });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/335
|
||||
describe("issue #335 — pixel aspect ratio must fail closed on unsupported CEP hosts", () => {
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
|
||||
it("checks host support and verifies the readback before reporting success", async () => {
|
||||
const script = await codeFor(utility.set_sequence_pixel_aspect_ratio, { ratio: "1.0" });
|
||||
|
||||
expect(script).toContain("currentRatio = settings.videoPixelAspectRatio");
|
||||
expect(script).toContain('typeof currentRatio === "undefined"');
|
||||
expect(script).toContain("No sequence settings were changed");
|
||||
expect(script).toContain("settings.videoPixelAspectRatio = requestedRatio");
|
||||
expect(script).toContain("observed = seq.getSettings()");
|
||||
expect(script).toContain("observedRatio = String(observed.videoPixelAspectRatio)");
|
||||
expect(script.indexOf('typeof currentRatio === "undefined"'))
|
||||
.toBeLessThan(script.indexOf("settings.videoPixelAspectRatio = requestedRatio"));
|
||||
});
|
||||
|
||||
it("returns structured failures for unavailable and rejected host settings", async () => {
|
||||
const unavailableSetSettings = vi.fn();
|
||||
await expect(executePixelAspectRatioScript({
|
||||
name: "Unsupported setting",
|
||||
getSettings: () => ({}),
|
||||
setSettings: unavailableSetSettings,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("does not expose a writable"),
|
||||
});
|
||||
expect(unavailableSetSettings).not.toHaveBeenCalled();
|
||||
|
||||
const lockedSettings: Record<string, string> = {};
|
||||
Object.defineProperty(lockedSettings, "videoPixelAspectRatio", {
|
||||
get: () => "1.0",
|
||||
set: () => { throw new Error("locked"); },
|
||||
});
|
||||
const rejectedSetSettings = vi.fn();
|
||||
await expect(executePixelAspectRatioScript({
|
||||
name: "Read-only setting",
|
||||
getSettings: () => lockedSettings,
|
||||
setSettings: rejectedSetSettings,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("rejected the sequence pixel-aspect-ratio update"),
|
||||
});
|
||||
expect(rejectedSetSettings).not.toHaveBeenCalled();
|
||||
|
||||
await expect(executePixelAspectRatioScript({
|
||||
name: "Apply failure",
|
||||
getSettings: () => ({ videoPixelAspectRatio: "1.0" }),
|
||||
setSettings: () => { throw new Error("host refused"); },
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("could not apply the sequence pixel-aspect-ratio update"),
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a false host result and mismatched readback as unverified", async () => {
|
||||
await expect(executePixelAspectRatioScript({
|
||||
name: "Rejected return value",
|
||||
getSettings: () => ({ videoPixelAspectRatio: "1.0" }),
|
||||
setSettings: () => false,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("rejected the sequence pixel-aspect-ratio update"),
|
||||
});
|
||||
|
||||
const initialSettings = { videoPixelAspectRatio: "1.0" };
|
||||
await expect(executePixelAspectRatioScript({
|
||||
name: "Mismatched readback",
|
||||
getSettings: vi.fn().mockReturnValueOnce(initialSettings).mockReturnValueOnce({ videoPixelAspectRatio: "1.0" }),
|
||||
setSettings: () => undefined,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining("did not apply the requested sequence pixel aspect ratio"),
|
||||
});
|
||||
});
|
||||
|
||||
it("reports success only after an applied setting reads back exactly", async () => {
|
||||
const settings = { videoPixelAspectRatio: "1.0" };
|
||||
const result = await executePixelAspectRatioScript({
|
||||
name: "Verified sequence",
|
||||
getSettings: () => settings,
|
||||
setSettings: () => true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { ratio: "1.4222", sequence: "Verified sequence", verified: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-string aspect ratios before sending a Premiere command", async () => {
|
||||
mockedSendCommand.mockClear();
|
||||
const result = await utility.set_sequence_pixel_aspect_ratio.handler({ ratio: 1 as never });
|
||||
|
||||
expect(result).toMatchObject({ success: false });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/235
|
||||
describe("issue #235 — CEP tool calls use the host's documented argument types", () => {
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
const tracks = getTrackTargetingTools(bridgeOptions);
|
||||
const project = getProjectTools(bridgeOptions);
|
||||
|
||||
it("uses a string-backed pixel aspect ratio and verifies the settings readback", async () => {
|
||||
const script = await scriptFor(utility.set_sequence_pixel_aspect_ratio, { ratio: "1.0" });
|
||||
|
||||
expect(utility.set_sequence_pixel_aspect_ratio.parameters.properties.ratio).toMatchObject({ type: "string" });
|
||||
expect(script).toContain('var requestedRatio = "1.0"');
|
||||
expect(script).toContain("settings.videoPixelAspectRatio = requestedRatio");
|
||||
expect(script).toContain("seq.getSettings()");
|
||||
expect(script).toContain("Premiere did not apply the requested sequence pixel aspect ratio");
|
||||
});
|
||||
|
||||
it("clears sequence points with seconds derived from their tick values", async () => {
|
||||
const script = await scriptFor(tracks.clear_sequence_in_out, {});
|
||||
|
||||
expect(script).toContain("var zeroSeconds = __ticksToSeconds(seq.zeroPoint)");
|
||||
expect(script).toContain("var endSeconds = __ticksToSeconds(seq.end)");
|
||||
expect(script).toContain("seq.setInPoint(zeroSeconds)");
|
||||
expect(script).toContain("seq.setOutPoint(endSeconds)");
|
||||
expect(script).not.toContain("seq.zeroPoint.ticks");
|
||||
});
|
||||
|
||||
it("passes every positional bars-and-tone argument and captures the created item", async () => {
|
||||
const script = await scriptFor(project.create_bars_and_tone, {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
pixel_aspect_numerator: 1,
|
||||
pixel_aspect_denominator: 1,
|
||||
audio_sample_rate: 48000,
|
||||
name: "Bars",
|
||||
});
|
||||
|
||||
expect(script).toContain("app.project.newBarsAndTone(");
|
||||
expect(script).toContain("1920,");
|
||||
expect(script).toContain("1080,");
|
||||
expect(script).toContain("1,");
|
||||
expect(script).toContain("48000,");
|
||||
expect(script).toContain('"Bars"');
|
||||
expect(script).toContain("if (!item) return __error");
|
||||
});
|
||||
|
||||
it("writes the Anti-flicker numeric stream value and verifies it", async () => {
|
||||
const script = await scriptFor(tracks.set_anti_alias_quality, { node_id: "clip-1", enabled: false });
|
||||
|
||||
expect(script).toContain("var requestedValue = 0");
|
||||
expect(script).toContain("antiFlicker.setValue(requestedValue, 1)");
|
||||
expect(script).toContain("antiFlicker.getValue()");
|
||||
expect(script).not.toContain("Use Composition's Shutter Angle");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/236
|
||||
describe("issue #236 — CEP tools supply required positional arguments", () => {
|
||||
const advanced = getAdvancedTools(bridgeOptions);
|
||||
const text = getTextTools(bridgeOptions);
|
||||
|
||||
it("passes action, linked-audio, and sensitivity to scene detection", async () => {
|
||||
const script = await scriptFor(advanced.scene_edit_detection, {
|
||||
action: "CreateMarkers",
|
||||
apply_cuts_to_linked_audio: false,
|
||||
sensitivity: "MediumSensitivity",
|
||||
});
|
||||
|
||||
expect(script).toContain("seq.performSceneEditDetectionOnSelection(");
|
||||
expect(script).toContain('"CreateMarkers"');
|
||||
expect(script).toContain("false,");
|
||||
expect(script).toContain('"MediumSensitivity"');
|
||||
expect(script).toContain("Select at least one clip");
|
||||
});
|
||||
|
||||
it("passes the Creative Cloud Library name before the MOGRT name", async () => {
|
||||
const script = await scriptFor(text.import_mogrt_from_library, {
|
||||
library_name: "Brand Library",
|
||||
mogrt_name: "Lower Third",
|
||||
});
|
||||
|
||||
expect(text.import_mogrt_from_library.parameters.required).toEqual(["library_name", "mogrt_name"]);
|
||||
expect(script).toContain("seq.importMGTFromLibrary(");
|
||||
expect(script).toContain('libraryName,');
|
||||
expect(script).toContain('mogrtName,');
|
||||
expect(script).toContain("startTicks,");
|
||||
});
|
||||
|
||||
it("fails raw-text overlay creation before sending an unsupported bridge call", async () => {
|
||||
const result = await text.add_text_overlay.handler({ text: "Title" });
|
||||
|
||||
expect(result).toMatchObject({ success: false, error: expect.stringContaining("No mutation was attempted") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/237
|
||||
describe("issue #237 — reported mutations must be observable or fail", () => {
|
||||
const advanced = getAdvancedTools(bridgeOptions);
|
||||
const project = getProjectTools(bridgeOptions);
|
||||
const tracks = getTrackTargetingTools(bridgeOptions);
|
||||
const media = getMediaTools(bridgeOptions);
|
||||
const exports = getExportTools(bridgeOptions);
|
||||
|
||||
it("makes trim tools read back their claimed changes", async () => {
|
||||
const slide = await scriptFor(advanced.slide_edit, { node_id: "clip-1", offset_seconds: 1 });
|
||||
const slip = await scriptFor(advanced.slip_edit, { node_id: "clip-1", offset_seconds: 1 });
|
||||
const roll = await scriptFor(advanced.roll_edit, { node_id: "clip-1", offset_seconds: 1 });
|
||||
|
||||
expect(slide).toContain("The slide edit returned without an observable timeline change");
|
||||
expect(roll).toContain("The roll edit returned without an observable timeline change");
|
||||
expect(slip).toContain("The slip edit returned without an observable source in/out change");
|
||||
expect(slip).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("uses structural receipts for import and duplicate consolidation", async () => {
|
||||
const imports = await scriptFor(project.import_sequences, {
|
||||
project_path: "/tmp/source.prproj",
|
||||
sequence_ids: ["source-sequence-id"],
|
||||
});
|
||||
const duplicates = await scriptFor(project.consolidate_duplicates, {});
|
||||
|
||||
expect(imports).toContain("new File");
|
||||
expect(imports).toContain("beforeIds");
|
||||
expect(duplicates).toContain("var nodeId = String(item.nodeId || \"\")");
|
||||
expect(duplicates).toContain("pathMap[mediaPath].nodeIds[nodeId]");
|
||||
expect(imports).toContain("Premiere did not add any of the requested sequences");
|
||||
expect(duplicates).toContain("__duplicateMediaStats");
|
||||
expect(duplicates).toContain("duplicate media groups did not decrease");
|
||||
});
|
||||
|
||||
it("checks image imports, source-point writes, disable state, and offline state", async () => {
|
||||
const image = await scriptFor(tracks.import_image_sequence, { first_file_path: "/tmp/frame_001.png" });
|
||||
const points = await scriptFor(tracks.set_item_in_out, { item_id: "item-1", in_seconds: 1 });
|
||||
const enabled = await scriptFor(tracks.batch_enable_disable, { target: "selected", enabled: false });
|
||||
const offline = await scriptFor(media.set_offline, { item_id: "item-1", offline: false });
|
||||
|
||||
expect(image).toContain("sourceFile.exists");
|
||||
expect(image).toContain("Premiere accepted the image-sequence import but no project item was added");
|
||||
expect(points).toContain("item.getInPoint");
|
||||
expect(points).toContain("Premiere did not apply the requested project-item in point");
|
||||
expect(enabled).toContain("track.clips[c].disabled = disabled");
|
||||
expect(enabled).toContain("No clips matched target 'selected'");
|
||||
expect(offline).toContain("item.refreshMedia()");
|
||||
expect(offline).toContain("item.isOffline()");
|
||||
});
|
||||
|
||||
it("labels sequence close as a tab operation and verifies an OMF file", async () => {
|
||||
const close = await scriptFor(advanced.close_sequence, { sequence_id: "sequence-1" });
|
||||
const omf = await scriptFor(exports.export_omf, { output_path: "/tmp/export.omf" });
|
||||
|
||||
expect(close).toContain("timelineTabCloseRequested: true");
|
||||
expect(close).toContain("sequenceRetainedInProject");
|
||||
expect(omf).toContain("outputFile.exists");
|
||||
expect(omf).toContain("Premiere did not write the requested OMF file");
|
||||
expect(exports.export_omf.parameters.properties.include_pan).toMatchObject({ type: "boolean" });
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/238
|
||||
describe("issue #238 — AME uses canonical paths and documented encodeFile positions", () => {
|
||||
const exports = getExportTools(bridgeOptions);
|
||||
|
||||
it("captures AME job IDs and does not present queueing as a completed encode", async () => {
|
||||
const queued = await scriptFor(exports.add_to_render_queue, { output_path: "/tmp/render.mp4" });
|
||||
const projectItem = await scriptFor(exports.encode_project_item, {
|
||||
item_id: "item-1",
|
||||
output_path: "/tmp/render.mp4",
|
||||
preset_path: "/tmp/preset.epr",
|
||||
});
|
||||
|
||||
expect(queued).toContain("var outputFile = new File");
|
||||
expect(queued).toContain("var jobId = encoder.encodeSequence");
|
||||
expect(queued).toContain("Queue presence and output-file creation are not verified");
|
||||
expect(projectItem).toContain("outputFile.fsName");
|
||||
expect(projectItem).toContain("var jobId = app.encoder.encodeProjectItem");
|
||||
});
|
||||
|
||||
it("passes work area before removal and never passes undefined Time values", async () => {
|
||||
const wholeFile = await scriptFor(exports.encode_file, {
|
||||
input_path: "/tmp/source.mov",
|
||||
output_path: "/tmp/render.mp4",
|
||||
preset_path: "/tmp/preset.epr",
|
||||
});
|
||||
const range = await scriptFor(exports.encode_file, {
|
||||
input_path: "/tmp/source.mov",
|
||||
output_path: "/tmp/render.mp4",
|
||||
preset_path: "/tmp/preset.epr",
|
||||
in_seconds: 1,
|
||||
out_seconds: 2,
|
||||
});
|
||||
|
||||
expect(wholeFile).toContain("var srcIn = new Time()");
|
||||
expect(wholeFile).toContain("var workArea = 0");
|
||||
expect(range).toContain("var workArea = 1");
|
||||
expect(range).toContain("workArea,");
|
||||
expect(range).not.toContain("var srcIn = undefined");
|
||||
expect(range).toContain("var jobId = app.encoder.encodeFile");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/322
|
||||
describe("issue #322 — marker filtering retains the requested sequence collection", () => {
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
|
||||
it("resolves the requested sequence once and returns its identity with the marker set", async () => {
|
||||
const script = await scriptFor(utility.get_sequence_markers_by_type, {
|
||||
sequence_id: "target-sequence", marker_type: "Comment",
|
||||
});
|
||||
|
||||
expect(script).toContain('var seq = __findSequence("target-sequence")');
|
||||
expect(script).toContain("var selectedSequence = { id: String(seq.sequenceID)");
|
||||
expect(script).toContain("var markerCollection = seq.markers");
|
||||
expect(script).toContain("markerCollection.getFirstMarker()");
|
||||
expect(script).toContain("markerCollection.getNextMarker(m)");
|
||||
expect(script).toContain("sequence: selectedSequence");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/323
|
||||
describe("issue #323 — AME handoffs are unverified until a queue or file readback", () => {
|
||||
const exports = getExportTools(bridgeOptions);
|
||||
const project = getProjectTools(bridgeOptions);
|
||||
|
||||
it("does not present AME acceptance as a queued, started, or completed encode", async () => {
|
||||
const render = await scriptFor(exports.add_to_render_queue, { output_path: "/tmp/render.mp4" });
|
||||
const item = await scriptFor(exports.encode_project_item, {
|
||||
item_id: "item-1", output_path: "/tmp/render.mp4", preset_path: "/tmp/preset.epr",
|
||||
});
|
||||
const proxy = await scriptFor(exports.manage_proxies, {
|
||||
item_id: "item-1", action: "create", output_path: "/tmp/proxy.mov", preset_path: "/tmp/proxy.epr",
|
||||
});
|
||||
const file = await scriptFor(exports.encode_file, {
|
||||
input_path: "/tmp/source.mov", output_path: "/tmp/render.mp4", preset_path: "/tmp/preset.epr",
|
||||
});
|
||||
const batch = await scriptFor(project.start_batch_encode, {});
|
||||
|
||||
for (const script of [render, item, proxy, file]) {
|
||||
expect(script).toContain('if (!jobId || String(jobId) === "0") return __error');
|
||||
expect(script).toContain("accepted: true");
|
||||
expect(script).toContain('outcome: "committed_unverified"');
|
||||
expect(script).not.toContain("queued: true");
|
||||
}
|
||||
expect(batch).toContain("requested: true");
|
||||
expect(batch).toContain('outcome: "committed_unverified"');
|
||||
expect(batch).not.toContain("started: true");
|
||||
expect(batch).toContain("var startResult = app.encoder.startBatch()");
|
||||
expect(batch).toContain("if (startResult !== 1 && startResult !== true)");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/324
|
||||
describe("issue #324 — duplicate media requires distinct project-item node IDs", () => {
|
||||
const utility = getUtilityTools(bridgeOptions);
|
||||
|
||||
it("deduplicates each media path by stable node ID before forming a group", async () => {
|
||||
const script = await scriptFor(utility.get_duplicate_media, {});
|
||||
|
||||
expect(script).toContain("var nodeId = String(item.nodeId || \"\")");
|
||||
expect(script).toContain("pathMap[mp] = { items: [], nodeIds: {} }");
|
||||
expect(script).toContain("if (!pathMap[mp].nodeIds[nodeId])");
|
||||
expect(script).toContain("pathMap[path].items.length > 1");
|
||||
expect(script).not.toContain("pathMap[path].length > 1");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/326
|
||||
describe("issue #326 — sequence creation requires project-collection readback", () => {
|
||||
const sequence = getSequenceTools(bridgeOptions);
|
||||
|
||||
it("does not report a QE-active sequence as created unless it is discoverable", async () => {
|
||||
const script = await scriptFor(sequence.create_sequence, {
|
||||
name: "Verified Sequence", preset_path: "/tmp/sequence.sqpreset",
|
||||
});
|
||||
expect(script).toContain("var beforeSequenceIds = {}");
|
||||
expect(script).toContain("var sequenceId = String(seq.sequenceID)");
|
||||
expect(script).toContain("if (beforeSequenceIds[sequenceId])");
|
||||
expect(script).toContain("did not create a new sequence");
|
||||
expect(script).toContain("var created = __findSequence(sequenceId)");
|
||||
expect(script).toContain("no creation success is reported");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/leancoderkavy/premiere-pro-mcp/issues/327
|
||||
describe("issue #327 — legacy media replacement is fail-closed", () => {
|
||||
const clipboard = getClipboardTools(bridgeOptions);
|
||||
|
||||
it("never uses overwriteClip when duration and adjacent-track preservation cannot be verified", async () => {
|
||||
expect(clipboard.replace_clip_media.operationalCapability).toMatchObject({
|
||||
backend: "local", backends: ["local"], status: "unsupported", hostVerificationRequired: false,
|
||||
});
|
||||
const result = await clipboard.replace_clip_media.handler({ clip_node_id: "clip-1", new_item_id: "item-2" } as never);
|
||||
expect(result).toMatchObject({ success: false, error: expect.stringContaining("No mutation was attempted") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
existsSync: vi.fn(),
|
||||
readdirSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: mocks.existsSync,
|
||||
readdirSync: mocks.readdirSync,
|
||||
statSync: mocks.statSync,
|
||||
}));
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.PREMIERE_DEFAULT_SEQUENCE_PRESET;
|
||||
mocks.existsSync.mockReturnValue(true);
|
||||
mocks.statSync.mockReturnValue({ isDirectory: () => false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("default sequence preset discovery", () => {
|
||||
it("uses and caches an explicit existing preset", async () => {
|
||||
process.env.PREMIERE_DEFAULT_SEQUENCE_PRESET = "C:\\Presets\\Explicit.sqpreset";
|
||||
const { findDefaultSequencePreset } = await import("../../src/tools/sequence.js");
|
||||
expect(findDefaultSequencePreset()).toBe("C:\\Presets\\Explicit.sqpreset");
|
||||
mocks.existsSync.mockReturnValue(false);
|
||||
expect(findDefaultSequencePreset()).toBe("C:\\Presets\\Explicit.sqpreset");
|
||||
});
|
||||
|
||||
it("selects the preferred Windows preset while skipping unrelated installs", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
mocks.readdirSync.mockImplementation((directory) => {
|
||||
const path = String(directory);
|
||||
if (path === "C:\\Program Files\\Adobe") return ["Unrelated", "Adobe Premiere Pro 2026"] as never;
|
||||
if (path.endsWith("SequencePresets")) {
|
||||
return ["Other.sqpreset", "UHD (4K) 2160p 25 fps.sqpreset"] as never;
|
||||
}
|
||||
return [] as never;
|
||||
});
|
||||
const { findDefaultSequencePreset } = await import("../../src/tools/sequence.js");
|
||||
expect(findDefaultSequencePreset()).toMatch(/UHD \(4K\) 2160p 25 fps\.sqpreset$/);
|
||||
});
|
||||
|
||||
it("walks a macOS app bundle and falls back to any preset", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
||||
mocks.readdirSync.mockImplementation((directory) => {
|
||||
const path = String(directory);
|
||||
if (path === "/Applications") return ["Adobe Premiere Pro 2026"] as never;
|
||||
if (path.endsWith("Adobe Premiere Pro 2026")) return ["Adobe Premiere Pro.app", "README"] as never;
|
||||
if (path.endsWith("SequencePresets")) return ["Cinema.sqpreset"] as never;
|
||||
return [] as never;
|
||||
});
|
||||
const { findDefaultSequencePreset } = await import("../../src/tools/sequence.js");
|
||||
expect(findDefaultSequencePreset()).toMatch(/Cinema\.sqpreset$/);
|
||||
});
|
||||
|
||||
it("returns and caches null when installation discovery fails", async () => {
|
||||
mocks.readdirSync.mockImplementation(() => { throw new Error("access denied"); });
|
||||
const { findDefaultSequencePreset } = await import("../../src/tools/sequence.js");
|
||||
expect(findDefaultSequencePreset()).toBeNull();
|
||||
expect(findDefaultSequencePreset()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getExportTools } from "../../src/tools/export.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const tools = getExportTools({ tempDir: "/tmp/test", timeoutMs: 1000 });
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("export_sequence_review_frames", () => {
|
||||
it("reduces a six-frame review from six MCP/bridge calls to one bounded bridge call", async () => {
|
||||
await tools.export_sequence_review_frames.handler({ output_dir: "/tmp/review", frame_count: 6 });
|
||||
|
||||
expect(mockedSendCommand).toHaveBeenCalledTimes(1);
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("var requested = 6");
|
||||
expect(script).toContain("for (var i = 0; i < requested; i++)");
|
||||
expect(script).toContain("__exportStillFrame(");
|
||||
expect(script).toContain("frames.length === requested");
|
||||
});
|
||||
|
||||
it("fails invalid counts and ranges before contacting Premiere", async () => {
|
||||
await expect(tools.export_sequence_review_frames.handler({ output_dir: "/tmp/review", frame_count: 25 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("2 through 24") });
|
||||
await expect(tools.export_sequence_review_frames.handler({ output_dir: "/tmp/review", frame_count: 1 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("2 through 24") });
|
||||
await expect(tools.export_sequence_review_frames.handler({ output_dir: "/tmp/review", start_seconds: 8, end_seconds: 4 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("greater than") });
|
||||
await expect(tools.export_sequence_review_frames.handler({ output_dir: "/tmp/review", start_seconds: -1 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("non-negative") });
|
||||
await expect(tools.export_sequence_review_frames.handler({ output_dir: "", frame_count: 6 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("non-empty") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports partial export failures instead of claiming the whole review succeeded", async () => {
|
||||
await tools.export_sequence_review_frames.handler({ output_dir: "/tmp/review", frame_count: 4 });
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("failures.push");
|
||||
expect(script).toContain("complete: frames.length === requested");
|
||||
expect(script).toContain("Playback, audio, and editorial quality remain unverified");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { planDerivedSilenceRemoval } from "../../src/tools/silence-removal.js";
|
||||
|
||||
describe("derived silence-removal planner", () => {
|
||||
it.each([24000 / 1001, 30000 / 1001, 60000 / 1001])("snaps removal inward at %s fps", (frameRate) => {
|
||||
const plan = planDerivedSilenceRemoval({ durationSeconds: 10, frameRate, silenceRanges: [{ startSeconds: 1.001, endSeconds: 2.009 }] });
|
||||
expect(plan.removalRanges).toEqual([{ startFrame: Math.ceil(1.001 * frameRate - 1e-7), endFrame: Math.floor(2.009 * frameRate + 1e-7) }]);
|
||||
expect(plan.keptFrames + plan.removedFrames).toBe(plan.totalFrames);
|
||||
});
|
||||
|
||||
it("merges overlapping frame ranges and retains requested handles", () => {
|
||||
const plan = planDerivedSilenceRemoval({ durationSeconds: 6, frameRate: 30, keepHandleFrames: 2, silenceRanges: [
|
||||
{ startSeconds: 1, endSeconds: 3 }, { startSeconds: 2.8, endSeconds: 4 },
|
||||
] });
|
||||
expect(plan.removalRanges).toEqual([{ startFrame: 32, endFrame: 118 }]);
|
||||
expect(plan.keepRanges).toEqual([{ startFrame: 0, endFrame: 32 }, { startFrame: 118, endFrame: 180 }]);
|
||||
});
|
||||
|
||||
it("merges snapped overlapping silences before applying outer handles", () => {
|
||||
const plan = planDerivedSilenceRemoval({ durationSeconds: 10, frameRate: 30, keepHandleFrames: 10, silenceRanges: [
|
||||
{ startSeconds: 0, endSeconds: 5 }, { startSeconds: 4.5, endSeconds: 10 },
|
||||
] });
|
||||
expect(plan.removalRanges).toEqual([{ startFrame: 10, endFrame: 290 }]);
|
||||
expect(plan.keepRanges).toEqual([{ startFrame: 0, endFrame: 10 }, { startFrame: 290, endFrame: 300 }]);
|
||||
});
|
||||
|
||||
it("refuses invalid, truncated, and all-silence plans", () => {
|
||||
expect(() => planDerivedSilenceRemoval({ durationSeconds: 1, frameRate: 30, silenceRanges: [{ startSeconds: 0, endSeconds: 1 }] })).toThrow("entire source");
|
||||
expect(() => planDerivedSilenceRemoval({ durationSeconds: 10, frameRate: 30, maximumRemovals: 1, silenceRanges: [
|
||||
{ startSeconds: 1, endSeconds: 2 }, { startSeconds: 3, endSeconds: 4 },
|
||||
] })).toThrow("maximumRemovals");
|
||||
expect(() => planDerivedSilenceRemoval({ durationSeconds: 2, frameRate: 30, silenceRanges: [{ startSeconds: -1, endSeconds: 1 }] })).toThrow("outside");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockedExecFile, mockedExecFileAsync } = vi.hoisted(() => {
|
||||
const mockedExecFileAsync = vi.fn();
|
||||
const mockedExecFile = vi.fn();
|
||||
mockedExecFile[Symbol.for("nodejs.util.promisify.custom")] = mockedExecFileAsync;
|
||||
return { mockedExecFile, mockedExecFileAsync };
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({ execFile: mockedExecFile }));
|
||||
|
||||
import { getExportTools, parseSceneChangeOutput } from "../../src/tools/export.js";
|
||||
|
||||
describe("parseSceneChangeOutput", () => {
|
||||
it("pairs times with scores and keeps the strongest nearby event", () => {
|
||||
const output = [
|
||||
"n:1 pts:100 pts_time:1.00",
|
||||
"lavfi.scene_score=0.40",
|
||||
"n:2 pts:110 pts_time:1.10",
|
||||
"lavfi.scene_score=0.75",
|
||||
"n:3 pts:300 pts_time:3.00",
|
||||
"lavfi.scene_score=0.55",
|
||||
].join("\n");
|
||||
expect(parseSceneChangeOutput(output, 0.25)).toEqual([
|
||||
{ timeSeconds: 1.1, score: 0.75 },
|
||||
{ timeSeconds: 3, score: 0.55 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detect_source_scene_changes boundary", () => {
|
||||
const tool = getExportTools({ tempDir: "/tmp/test" }).detect_source_scene_changes;
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("rejects invalid thresholds and missing files", async () => {
|
||||
await expect(tool.handler({ media_path: "missing.mp4", threshold: 2 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tool.handler({ media_path: "missing.mp4", minimum_interval_seconds: -1 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tool.handler({ media_path: "missing.mp4", maximum_events: 0 })).resolves.toMatchObject({ success: false });
|
||||
await expect(tool.handler({ media_path: "missing.mp4" })).resolves.toMatchObject({ success: false, error: expect.stringContaining("not found") });
|
||||
});
|
||||
|
||||
it("returns bounded source-relative scene candidates from one local FFmpeg pass", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-scene-detect-"));
|
||||
const mediaPath = join(directory, "source.mp4");
|
||||
writeFileSync(mediaPath, "video-fixture");
|
||||
mockedExecFileAsync.mockResolvedValueOnce({
|
||||
stdout: "n:1 pts:100 pts_time:1.00\nlavfi.scene_score=0.40\nn:2 pts:300 pts_time:3.00\nlavfi.scene_score=0.55",
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
await expect(tool.handler({ media_path: mediaPath, threshold: 0.3, minimum_interval_seconds: 0.25, maximum_events: 1 }))
|
||||
.resolves.toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
mediaPath,
|
||||
totalDetected: 2,
|
||||
truncated: true,
|
||||
sceneChanges: [{ timeSeconds: 1, score: 0.4 }],
|
||||
},
|
||||
});
|
||||
expect(mockedExecFileAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps an FFmpeg scene-detection failure explicit", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "premiere-scene-error-"));
|
||||
const mediaPath = join(directory, "source.mp4");
|
||||
writeFileSync(mediaPath, "video-fixture");
|
||||
mockedExecFileAsync.mockRejectedValueOnce(Object.assign(new Error("decode failed"), { stderr: "bad video stream" }));
|
||||
|
||||
await expect(tool.handler({ media_path: mediaPath }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("bad video stream") });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
}));
|
||||
|
||||
import { sendCommand } from "../../src/bridge/file-bridge.js";
|
||||
import { getTimelineTools } from "../../src/tools/timeline.js";
|
||||
import { getTrackTargetingTools } from "../../src/tools/track-targeting.js";
|
||||
import { getAdvancedTools } from "../../src/tools/advanced.js";
|
||||
import { getTrackTools } from "../../src/tools/tracks.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const bridgeOptions = { tempDir: "/tmp/test", timeoutMs: 1000 };
|
||||
const timeline = getTimelineTools(bridgeOptions);
|
||||
const trackTargeting = getTrackTargetingTools(bridgeOptions);
|
||||
const advanced = getAdvancedTools(bridgeOptions);
|
||||
const tracks = getTrackTools(bridgeOptions);
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("trim_clip verification", () => {
|
||||
it("requires exactly one edit point instead of reporting a no-op or multi-write as success", async () => {
|
||||
const result = await timeline.trim_clip.handler({ node_id: "abc" });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("new_in_seconds");
|
||||
// The bridge must not be touched at all — there is nothing to apply.
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
|
||||
const multiple = await timeline.trim_clip.handler({
|
||||
node_id: "abc",
|
||||
new_in_seconds: 2,
|
||||
new_out_seconds: 8,
|
||||
});
|
||||
expect(multiple.success).toBe(false);
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("compares the read-back in point against the requested value", async () => {
|
||||
await timeline.trim_clip.handler({ node_id: "abc", new_in_seconds: 2 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("var actualIn = after.inPoint");
|
||||
expect(script).toContain("Math.abs(actualIn - 2) > tolerance");
|
||||
expect(script).toContain("did not apply a verified timeline trim");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("compares the read-back out point against the requested value", async () => {
|
||||
await timeline.trim_clip.handler({ node_id: "abc", new_out_seconds: 8 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("Math.abs(actualOut - 8) > tolerance");
|
||||
});
|
||||
|
||||
it("only writes the edge that was actually requested", async () => {
|
||||
await timeline.trim_clip.handler({ node_id: "abc", new_in_seconds: 2 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("clip.inPoint = __secondsToTicks(2).toString();");
|
||||
expect(script).not.toContain("clip.outPoint = __secondsToTicks(2).toString();");
|
||||
});
|
||||
|
||||
it("derives its tolerance from the sequence timebase so frame snapping is not a failure", async () => {
|
||||
await timeline.trim_clip.handler({ node_id: "abc", new_in_seconds: 2 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("seq.timebase");
|
||||
expect(script).toContain("TICKS_PER_SECOND / 24");
|
||||
expect(script).toContain("var tolerance = __ticksToSeconds(frameTicks)");
|
||||
});
|
||||
|
||||
it("refuses invalid numeric edit points before contacting Premiere", async () => {
|
||||
const result = await timeline.trim_clip.handler({
|
||||
node_id: "abc",
|
||||
new_out_seconds: Number.NaN,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("finite");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("verifies the visible timeline geometry as well as source metadata", async () => {
|
||||
await timeline.trim_clip.handler({ node_id: "abc", new_out_seconds: 8 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("var afterResult = __findClip(\"abc\")");
|
||||
expect(script).toContain("var expectedStart = before.start");
|
||||
expect(script).toContain("var expectedEnd = before.end + (actualOut - before.outPoint)");
|
||||
expect(script).toContain("visible timeline duration does not match the applied source range");
|
||||
expect(script).toContain("source metadata may have changed, but this is not reported as success");
|
||||
});
|
||||
|
||||
it("fails closed for retimed clips and unhandled out-of-range keyframes", async () => {
|
||||
await timeline.trim_clip.handler({ node_id: "abc", new_out_seconds: 8 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("does not support retimed or otherwise non-1x clips");
|
||||
expect(script).toContain("__findOutOfRangeKeyframes");
|
||||
expect(script).toContain("keyframe_policy: preserve");
|
||||
expect(script).toContain("keyframesOutsideVisibleRange");
|
||||
});
|
||||
|
||||
it("allows explicit keyframe preservation while reporting that it was not fully verified", async () => {
|
||||
await timeline.trim_clip.handler({
|
||||
node_id: "abc",
|
||||
new_out_seconds: 8,
|
||||
keyframe_policy: "preserve",
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('keyframePolicy: "preserve"');
|
||||
expect(script).toContain("keyframesVerified:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("split_clip verification", () => {
|
||||
it("refuses invalid local arguments before contacting Premiere", async () => {
|
||||
const invalidTime = await timeline.split_clip.handler({ time_seconds: -1 });
|
||||
expect(invalidTime.success).toBe(false);
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
|
||||
const invalidTrack = await timeline.split_clip.handler({
|
||||
time_seconds: 4,
|
||||
track_index: 1.5,
|
||||
});
|
||||
expect(invalidTrack.success).toBe(false);
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires a clip to span the requested cut before attempting QE razor", async () => {
|
||||
await timeline.split_clip.handler({ time_seconds: 4, track_type: "audio" });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("function __eligibleClips");
|
||||
expect(script).toContain("if (!eligibleBefore.length)");
|
||||
expect(script).toContain("no razor was attempted");
|
||||
});
|
||||
|
||||
it("verifies each left and right segment, not only a count increase", async () => {
|
||||
await timeline.split_clip.handler({ time_seconds: 4 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("var expectedClipCount = clipCountBefore + eligibleBefore.length");
|
||||
expect(script).toContain("function __hasSegment");
|
||||
expect(script).toContain("left segment");
|
||||
expect(script).toContain("right segment");
|
||||
expect(script).toContain('keyframeSemantics: "unverified"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("move_clip verification", () => {
|
||||
it("re-finds the clip after the move rather than trusting a stale reference", async () => {
|
||||
await timeline.move_clip.handler({ node_id: "abc", new_start_seconds: 5 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('var after = __findClip("abc")');
|
||||
expect(script).toContain("Math.abs(actualStart - 5) > tolerance");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("rejects an out-of-range track index instead of silently skipping the move", async () => {
|
||||
await timeline.move_clip.handler({
|
||||
node_id: "abc",
|
||||
new_start_seconds: 5,
|
||||
new_track_index: 99,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("is out of range");
|
||||
expect(script).toContain("targetTracks.numTracks");
|
||||
});
|
||||
|
||||
it("attempts the track change before writing the start time, so a failure leaves no partial edit", async () => {
|
||||
await timeline.move_clip.handler({
|
||||
node_id: "abc",
|
||||
new_start_seconds: 5,
|
||||
new_track_index: 1,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
const trackMoveAt = script.indexOf("qeClip.moveToTrack");
|
||||
const startWriteAt = script.indexOf("clip.start = __secondsToTicks");
|
||||
expect(trackMoveAt).toBeGreaterThan(-1);
|
||||
expect(startWriteAt).toBeGreaterThan(-1);
|
||||
expect(trackMoveAt).toBeLessThan(startWriteAt);
|
||||
});
|
||||
|
||||
it("matches the QE clip by start time because QE item indices include gaps", async () => {
|
||||
await timeline.move_clip.handler({
|
||||
node_id: "abc",
|
||||
new_start_seconds: 5,
|
||||
new_track_index: 1,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('String(cand.type) !== "Clip"');
|
||||
expect(script).not.toContain("getItemAt(result.clipIndex)");
|
||||
});
|
||||
|
||||
it("verifies the clip actually landed on the requested track", async () => {
|
||||
await timeline.move_clip.handler({
|
||||
node_id: "abc",
|
||||
new_start_seconds: 5,
|
||||
new_track_index: 1,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("after.trackIndex !== 1");
|
||||
});
|
||||
|
||||
it("does not emit any track-move code when no track change was requested", async () => {
|
||||
await timeline.move_clip.handler({ node_id: "abc", new_start_seconds: 5 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).not.toContain("moveToTrack");
|
||||
expect(script).not.toContain("after.trackIndex !==");
|
||||
});
|
||||
});
|
||||
|
||||
describe("razor_all_tracks verification", () => {
|
||||
it("counts tracks whose clip count actually changed, not razor attempts", async () => {
|
||||
await trackTargeting.razor_all_tracks.handler({ time_seconds: 7 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("domTrack.clips.numItems > before");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("treats only tracks with a clip spanning the cut point as eligible", async () => {
|
||||
await trackTargeting.razor_all_tracks.handler({ time_seconds: 7 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("function __spansPoint");
|
||||
expect(script).toContain("if (v > s && v < e) return true");
|
||||
expect(script).toContain("if (wasEligible) eligible++");
|
||||
});
|
||||
|
||||
it("errors when any eligible track did not split", async () => {
|
||||
await trackTargeting.razor_all_tracks.handler({ time_seconds: 7 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("eligible > 0 && razored < eligible");
|
||||
expect(script).toContain("only partially applied");
|
||||
expect(script).toContain("no-op on some Premiere Pro 26.x");
|
||||
});
|
||||
|
||||
it("surfaces per-track razor exceptions rather than swallowing them", async () => {
|
||||
await trackTargeting.razor_all_tracks.handler({ time_seconds: 7 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("failures.push");
|
||||
expect(script).toContain("failures: failures");
|
||||
});
|
||||
});
|
||||
|
||||
describe("move_clip_to_track verification", () => {
|
||||
it("matches the QE clip by start time instead of the DOM clip index", async () => {
|
||||
// getItemAt(result.clipIndex) returns an "Empty" gap on any track with a
|
||||
// leading gap, which is what made this fail with a misleading error.
|
||||
await advanced.move_clip_to_track.handler({
|
||||
node_id: "abc",
|
||||
target_track_index: 1,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).not.toContain("getItemAt(result.clipIndex)");
|
||||
expect(script).toContain('String(cand.type) !== "Clip"');
|
||||
expect(script).toContain("Math.abs(parseFloat(cand.start.ticks) - wantStart) < 1");
|
||||
});
|
||||
|
||||
it("rejects an out-of-range target track", async () => {
|
||||
await advanced.move_clip_to_track.handler({
|
||||
node_id: "abc",
|
||||
target_track_index: 99,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("is out of range");
|
||||
expect(script).toContain("targetTracks.numTracks");
|
||||
});
|
||||
|
||||
it("short-circuits when the clip is already on the requested track", async () => {
|
||||
await advanced.move_clip_to_track.handler({
|
||||
node_id: "abc",
|
||||
target_track_index: 1,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("alreadyOnTrack: true");
|
||||
});
|
||||
|
||||
it("confirms against the DOM rather than trusting the QE call", async () => {
|
||||
await advanced.move_clip_to_track.handler({
|
||||
node_id: "abc",
|
||||
target_track_index: 1,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('var after = __findClip("abc")');
|
||||
expect(script).toContain("after.trackIndex !== 1");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("explains the QE rejection instead of surfacing a raw parameter error", async () => {
|
||||
await advanced.move_clip_to_track.handler({
|
||||
node_id: "abc",
|
||||
target_track_index: 1,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("catch (moveErr)");
|
||||
expect(script).toContain("The clip was left untouched");
|
||||
expect(script).toContain("overwriteClip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("track creation verification", () => {
|
||||
it("rejects invalid add_track requests locally instead of generating unsafe host code", async () => {
|
||||
const result = await tracks.add_track.handler({ track_type: "audio", count: 0 });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("positive integer");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses a bounded QE fallback only after a public add_track failure with no mutation", async () => {
|
||||
await tracks.add_track.handler({ track_type: "audio", count: 2 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
|
||||
expect(script).toContain('typeof seq.insertAudioTrackAt !== "function"');
|
||||
expect(script).toContain("afterPublic !== expected && afterPublic !== before");
|
||||
expect(script).toContain("qeSeq.addTracks(0, 2, 0, 0)");
|
||||
expect(script).toContain("if (after !== expected)");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("does not report QE add_tracks success until exact video and audio count readback", async () => {
|
||||
await advanced.add_tracks.handler({
|
||||
video_tracks: 1,
|
||||
audio_tracks: 2,
|
||||
audio_mono_tracks: 1,
|
||||
audio_51_tracks: 1,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
|
||||
expect(script).toContain("var beforeVideo = seq.videoTracks.numTracks");
|
||||
expect(script).toContain("var expectedAudio = beforeAudio + 4");
|
||||
expect(script).toContain("typeof qeSeq.addTracks !== \"function\"");
|
||||
expect(script).toContain("afterVideo !== expectedVideo || afterAudio !== expectedAudio");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("rejects an empty add_tracks request locally rather than returning a successful no-op", async () => {
|
||||
const result = await advanced.add_tracks.handler({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("at least one");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("overwrite_clip verification", () => {
|
||||
it("rejects invalid indices locally before they can be interpolated into ExtendScript", async () => {
|
||||
const result = await advanced.overwrite_clip.handler({
|
||||
item_id: "source-1",
|
||||
audio_track_index: -1,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("audio_track_index");
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks both target tracks and host capability before calling overwriteClip", async () => {
|
||||
await advanced.overwrite_clip.handler({
|
||||
item_id: "source-1",
|
||||
track_index: 1,
|
||||
audio_track_index: 2,
|
||||
start_seconds: 5,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
const videoCheck = script.indexOf("Video track index 1 is out of range");
|
||||
const audioCheck = script.indexOf("Audio track index 2 is out of range");
|
||||
const call = script.indexOf("seq.overwriteClip(item");
|
||||
|
||||
expect(videoCheck).toBeGreaterThan(-1);
|
||||
expect(audioCheck).toBeGreaterThan(-1);
|
||||
expect(call).toBeGreaterThan(audioCheck);
|
||||
expect(script).toContain('typeof seq.overwriteClip !== "function"');
|
||||
});
|
||||
|
||||
it("proves the requested source landed on a target track and errors on no new placement", async () => {
|
||||
await advanced.overwrite_clip.handler({ item_id: "source-1", start_seconds: 5 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
|
||||
expect(script).toContain("clip.projectItem ? String(clip.projectItem.nodeId)");
|
||||
expect(script).toContain("Math.abs(actualStartTicks - wantedStartTicks) <= frameTicks");
|
||||
expect(script).toContain("if (!videoPlaced && !audioPlaced)");
|
||||
expect(script).toContain("produced no verifiable new placement");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
});
|
||||
+979
@@ -0,0 +1,979 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { getHelpersSource } from "../../src/bridge/script-builder.js";
|
||||
import { BridgeOptions } from "../../src/bridge/file-bridge.js";
|
||||
|
||||
// Mock sendCommand and sendRawCommand so tool handlers don't do real I/O
|
||||
vi.mock("../../src/bridge/file-bridge.js", () => ({
|
||||
sendCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
sendRawCommand: vi.fn().mockResolvedValue({ success: true, data: {} }),
|
||||
getTempDir: vi.fn().mockReturnValue("/tmp/test"),
|
||||
cleanupTempDir: vi.fn(),
|
||||
}));
|
||||
|
||||
import { sendCommand, sendRawCommand } from "../../src/bridge/file-bridge.js";
|
||||
|
||||
const mockedSendCommand = vi.mocked(sendCommand);
|
||||
const mockedSendRawCommand = vi.mocked(sendRawCommand);
|
||||
|
||||
const bridgeOptions: BridgeOptions = { tempDir: "/tmp/test-bridge", timeoutMs: 5000 };
|
||||
|
||||
// Import all tool modules
|
||||
import { getDiscoveryTools } from "../../src/tools/discovery.js";
|
||||
import { getProjectTools } from "../../src/tools/project.js";
|
||||
import { getMediaTools } from "../../src/tools/media.js";
|
||||
import { getSequenceTools } from "../../src/tools/sequence.js";
|
||||
import { getTimelineTools } from "../../src/tools/timeline.js";
|
||||
import { getEffectsTools } from "../../src/tools/effects.js";
|
||||
import { getTransitionsTools } from "../../src/tools/transitions.js";
|
||||
import { getAudioTools } from "../../src/tools/audio.js";
|
||||
import { getTextTools } from "../../src/tools/text.js";
|
||||
import { getMarkerTools } from "../../src/tools/markers.js";
|
||||
import { getTrackTools } from "../../src/tools/tracks.js";
|
||||
import { getPlayheadTools } from "../../src/tools/playhead.js";
|
||||
import { getMetadataTools } from "../../src/tools/metadata.js";
|
||||
import { getExportTools } from "../../src/tools/export.js";
|
||||
import { getMediaAnalysisTools } from "../../src/tools/media-analysis.js";
|
||||
import { getInterchangeAnalysisTools } from "../../src/tools/interchange-analysis.js";
|
||||
import { getAdvancedTools } from "../../src/tools/advanced.js";
|
||||
import { getKeyframeTools } from "../../src/tools/keyframes.js";
|
||||
import { getScriptingTools } from "../../src/tools/scripting.js";
|
||||
import { getInspectionTools } from "../../src/tools/inspection.js";
|
||||
import { getSelectionTools } from "../../src/tools/selection.js";
|
||||
import { getClipboardTools } from "../../src/tools/clipboard.js";
|
||||
import { getSourceMonitorTools } from "../../src/tools/source-monitor.js";
|
||||
import { getTrackTargetingTools } from "../../src/tools/track-targeting.js";
|
||||
import { getUtilityTools } from "../../src/tools/utility.js";
|
||||
import { getHealthTools } from "../../src/tools/health.js";
|
||||
import { getWorkspaceTools } from "../../src/tools/workspace.js";
|
||||
import { getCaptionTools } from "../../src/tools/captions.js";
|
||||
import { getPlaybackTools } from "../../src/tools/playback.js";
|
||||
import { getProjectManagerTools } from "../../src/tools/project-manager.js";
|
||||
import { getRecoveryTools } from "../../src/tools/recovery.js";
|
||||
import { getAvSettingsTools } from "../../src/tools/av-settings.js";
|
||||
import { getProjectContextTools } from "../../src/tools/project-context.js";
|
||||
import { getEditorialContextPackTools } from "../../src/tools/editorial-context-pack.js";
|
||||
import { getEditorialPlanTools } from "../../src/tools/editorial-plans.js";
|
||||
import { getProjectIntakeTools } from "../../src/tools/project-intake.js";
|
||||
import { getCompetitorGapTools } from "../../src/tools/competitor-gaps.js";
|
||||
import { getSpotWorkflowTools } from "../../src/tools/spot-workflows.js";
|
||||
import { getMogrtAuthoringTools } from "../../src/tools/mogrt-authoring.js";
|
||||
import { getMogrtStudioTools } from "../../src/tools/mogrt-studio.js";
|
||||
import type { Telemetry, TelemetryProperties } from "../../src/telemetry.js";
|
||||
|
||||
interface ToolDef {
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
handler: (args: any) => Promise<{ success: boolean; data?: unknown; error?: string }>;
|
||||
}
|
||||
|
||||
type ToolModule = Record<string, ToolDef>;
|
||||
|
||||
// All modules with their expected tool counts and names
|
||||
const ALL_MODULES: Array<{
|
||||
name: string;
|
||||
getter: (opts: BridgeOptions) => ToolModule;
|
||||
minTools: number;
|
||||
}> = [
|
||||
{ name: "discovery", getter: getDiscoveryTools, minTools: 8 },
|
||||
{ name: "project", getter: getProjectTools, minTools: 20 },
|
||||
{ name: "media", getter: getMediaTools, minTools: 12 },
|
||||
{ name: "sequence", getter: getSequenceTools, minTools: 8 },
|
||||
{ name: "timeline", getter: getTimelineTools, minTools: 8 },
|
||||
{ name: "effects", getter: getEffectsTools, minTools: 6 },
|
||||
{ name: "transitions", getter: getTransitionsTools, minTools: 3 },
|
||||
{ name: "audio", getter: getAudioTools, minTools: 2 },
|
||||
{ name: "text", getter: getTextTools, minTools: 2 },
|
||||
{ name: "markers", getter: getMarkerTools, minTools: 3 },
|
||||
{ name: "tracks", getter: getTrackTools, minTools: 3 },
|
||||
{ name: "playhead", getter: getPlayheadTools, minTools: 4 },
|
||||
{ name: "metadata", getter: getMetadataTools, minTools: 6 },
|
||||
{ name: "export", getter: getExportTools, minTools: 10 },
|
||||
{ name: "media-analysis", getter: getMediaAnalysisTools, minTools: 5 },
|
||||
{ name: "interchange-analysis", getter: getInterchangeAnalysisTools, minTools: 5 },
|
||||
{ name: "advanced", getter: getAdvancedTools, minTools: 20 },
|
||||
{ name: "keyframes", getter: getKeyframeTools, minTools: 5 },
|
||||
{ name: "scripting", getter: getScriptingTools, minTools: 3 },
|
||||
{ name: "inspection", getter: getInspectionTools, minTools: 5 },
|
||||
{ name: "selection", getter: getSelectionTools, minTools: 5 },
|
||||
{ name: "clipboard", getter: getClipboardTools, minTools: 4 },
|
||||
{ name: "source-monitor", getter: getSourceMonitorTools, minTools: 5 },
|
||||
{ name: "track-targeting", getter: getTrackTargetingTools, minTools: 20 },
|
||||
{ name: "utility", getter: getUtilityTools, minTools: 15 },
|
||||
{ name: "health", getter: getHealthTools, minTools: 3 },
|
||||
{ name: "workspace", getter: getWorkspaceTools, minTools: 2 },
|
||||
{ name: "captions", getter: getCaptionTools, minTools: 1 },
|
||||
{ name: "playback", getter: getPlaybackTools, minTools: 3 },
|
||||
{ name: "project-manager", getter: getProjectManagerTools, minTools: 1 },
|
||||
{ name: "recovery", getter: getRecoveryTools, minTools: 2 },
|
||||
{ name: "av-settings", getter: getAvSettingsTools, minTools: 4 },
|
||||
{ name: "project-context", getter: getProjectContextTools, minTools: 3 },
|
||||
{ name: "editorial-context-pack", getter: getEditorialContextPackTools, minTools: 1 },
|
||||
{ name: "editorial-plans", getter: () => getEditorialPlanTools(), minTools: 2 },
|
||||
{ name: "project-intake", getter: getProjectIntakeTools, minTools: 1 },
|
||||
{ name: "competitor-gaps", getter: getCompetitorGapTools, minTools: 8 },
|
||||
{ name: "spot-workflows", getter: getSpotWorkflowTools, minTools: 4 },
|
||||
{ name: "mogrt-authoring", getter: getMogrtAuthoringTools, minTools: 4 },
|
||||
{ name: "mogrt-studio", getter: getMogrtStudioTools, minTools: 11 },
|
||||
];
|
||||
|
||||
describe("Tool Module Structure", () => {
|
||||
for (const mod of ALL_MODULES) {
|
||||
describe(`${mod.name} module`, () => {
|
||||
let tools: ToolModule;
|
||||
|
||||
beforeEach(() => {
|
||||
tools = mod.getter(bridgeOptions);
|
||||
});
|
||||
|
||||
it(`exports at least ${mod.minTools} tools`, () => {
|
||||
expect(Object.keys(tools).length).toBeGreaterThanOrEqual(mod.minTools);
|
||||
});
|
||||
|
||||
it("each tool has a description string", () => {
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
expect(typeof tool.description, `${name} description`).toBe("string");
|
||||
expect(tool.description.length, `${name} description length`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("each tool has a parameters object", () => {
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
expect(typeof tool.parameters, `${name} parameters`).toBe("object");
|
||||
}
|
||||
});
|
||||
|
||||
it("each tool has an async handler function", () => {
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
expect(typeof tool.handler, `${name} handler`).toBe("function");
|
||||
}
|
||||
});
|
||||
|
||||
it("parameter properties all have type fields", () => {
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
const props = (tool.parameters as any).properties;
|
||||
if (props) {
|
||||
for (const [propName, prop] of Object.entries(props) as [string, any][]) {
|
||||
expect(prop.type, `${name}.${propName} type`).toBeDefined();
|
||||
const types = Array.isArray(prop.type) ? prop.type : [prop.type];
|
||||
expect(
|
||||
types.length > 0 && types.every((type) =>
|
||||
["string", "number", "integer", "boolean", "array", "object"].includes(type)
|
||||
),
|
||||
`${name}.${propName} has valid type "${types.join(",")}"`
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("parameter properties all have description fields", () => {
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
const props = (tool.parameters as any).properties;
|
||||
if (props) {
|
||||
for (const [propName, prop] of Object.entries(props) as [string, any][]) {
|
||||
expect(
|
||||
typeof prop.description,
|
||||
`${name}.${propName} should have description`
|
||||
).toBe("string");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("required fields reference existing properties", () => {
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
const params = tool.parameters as any;
|
||||
const required = params.required || [];
|
||||
const propNames = Object.keys(params.properties || {});
|
||||
for (const req of required) {
|
||||
expect(
|
||||
propNames.includes(req),
|
||||
`${name}: required field "${req}" should exist in properties`
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("Total Tool Count", () => {
|
||||
it("all modules together have 342 tools", () => {
|
||||
let total = 0;
|
||||
for (const mod of ALL_MODULES) {
|
||||
total += Object.keys(mod.getter(bridgeOptions)).length;
|
||||
}
|
||||
expect(total).toBe(342);
|
||||
});
|
||||
|
||||
it("there are 40 directly enumerated modules", () => {
|
||||
expect(ALL_MODULES.length).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tool Handler Behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedSendCommand.mockResolvedValue({ success: true, data: { mock: true } });
|
||||
});
|
||||
|
||||
describe("health.ping", () => {
|
||||
it("calls sendCommand with shortened timeout", async () => {
|
||||
const tools = getHealthTools(bridgeOptions);
|
||||
await (tools.ping.handler as any)({});
|
||||
|
||||
expect(mockedSendCommand).toHaveBeenCalledTimes(1);
|
||||
const callArgs = mockedSendCommand.mock.calls[0];
|
||||
// Script should contain app.version
|
||||
expect(callArgs[0]).toContain("app.version");
|
||||
// Should use a 5-second timeout override
|
||||
expect(callArgs[1]?.timeoutMs).toBe(5000);
|
||||
expect(callArgs[1]?.failFastOnUnreadyHeartbeat).toBe(true);
|
||||
});
|
||||
|
||||
it("generates script that checks connectivity", async () => {
|
||||
const tools = getHealthTools(bridgeOptions);
|
||||
await (tools.ping.handler as any)({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("app.project");
|
||||
expect(script).toContain("__result");
|
||||
expect(script).toContain("connected: true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("health.verify_premiere_connection", () => {
|
||||
it("uses a read-only boolean check and emits activation only after readiness is confirmed", async () => {
|
||||
const events: Array<{ event: string; properties?: TelemetryProperties }> = [];
|
||||
const telemetry: Telemetry = {
|
||||
enabled: true,
|
||||
capture: (event, properties) => events.push({ event, properties }),
|
||||
shutdown: async () => {},
|
||||
};
|
||||
mockedSendCommand.mockResolvedValue({
|
||||
success: true,
|
||||
data: { projectOpen: true, sequenceOpen: true },
|
||||
});
|
||||
|
||||
const tools = getHealthTools(bridgeOptions, undefined, undefined, { telemetry });
|
||||
const result = await (tools.verify_premiere_connection.handler as any)({});
|
||||
const script = String(mockedSendCommand.mock.calls[0][0]);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: { overall: "ready", safeCheck: { readOnly: true, mutatesProject: false } },
|
||||
});
|
||||
expect(script).toContain("projectOpen");
|
||||
expect(script).toContain("sequenceOpen");
|
||||
expect(mockedSendCommand.mock.calls[0][1]?.failFastOnUnreadyHeartbeat).toBe(true);
|
||||
expect(script).not.toContain("projectName");
|
||||
expect(script).not.toContain("project.path");
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
event: "premiere_mcp_activation_completed",
|
||||
properties: { activation_stage: "verified_connection", backend: "cep" },
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(events)).not.toMatch(/prompt|path|token|project|media|argument|result|profile/i);
|
||||
});
|
||||
|
||||
it("does not record activation when the read-only check is incomplete", async () => {
|
||||
const events: Array<{ event: string; properties?: TelemetryProperties }> = [];
|
||||
const telemetry: Telemetry = {
|
||||
enabled: true,
|
||||
capture: (event, properties) => events.push({ event, properties }),
|
||||
shutdown: async () => {},
|
||||
};
|
||||
mockedSendCommand.mockResolvedValue({
|
||||
success: true,
|
||||
data: { projectOpen: true, sequenceOpen: false },
|
||||
});
|
||||
|
||||
const tools = getHealthTools(bridgeOptions, undefined, undefined, { telemetry });
|
||||
const result = await (tools.verify_premiere_connection.handler as any)({});
|
||||
|
||||
expect(result).toMatchObject({ success: true, data: { overall: "needs_attention" } });
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not fall back from an unavailable UXP check to CEP", async () => {
|
||||
const tools = getHealthTools(bridgeOptions);
|
||||
const result = await (tools.verify_premiere_connection.handler as any)({ backend: "uxp" });
|
||||
|
||||
expect(result).toMatchObject({ success: true, data: { backend: "uxp", overall: "needs_attention" } });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("project.create_project", () => {
|
||||
it("rejects a directory path without invoking Premiere", async () => {
|
||||
const tools = getProjectTools(bridgeOptions);
|
||||
|
||||
const result = await (tools.create_project.handler as any)({
|
||||
path: "/tmp/film-test",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error:
|
||||
"create_project path must be a full .prproj file path; Premiere cannot create a project from a directory path.",
|
||||
});
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("verifies the active project path after creating a project", async () => {
|
||||
const tools = getProjectTools(bridgeOptions);
|
||||
|
||||
await (tools.create_project.handler as any)({
|
||||
path: "/tmp/Film Test.prproj",
|
||||
});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('var requestedPath = "/tmp/Film Test.prproj"');
|
||||
expect(script).toContain("var beforePath = app.project");
|
||||
expect(script).toContain("var actualPath = project ? String(project.path || \"\") : \"\"");
|
||||
expect(script).toContain("__normalizedProjectPath(actualPath) !== __normalizedProjectPath(requestedPath)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reported tool regressions", () => {
|
||||
beforeEach(() => mockedSendCommand.mockClear());
|
||||
|
||||
it("does not report duplicate consolidation as successful without a reduced duplicate scan", async () => {
|
||||
const tools = getProjectTools(bridgeOptions);
|
||||
await tools.consolidate_duplicates.handler({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("function __duplicateMediaStats()");
|
||||
expect(script).toContain("var before = __duplicateMediaStats()");
|
||||
expect(script).toContain("var after = __duplicateMediaStats()");
|
||||
expect(script).toContain("duplicate media groups did not decrease");
|
||||
expect(script).toContain("verified: true");
|
||||
});
|
||||
|
||||
it("fails reverse_clip locally with an actionable unsupported-capability error", async () => {
|
||||
const tools = getAdvancedTools(bridgeOptions);
|
||||
const result = await tools.reverse_clip.handler({ node_id: "clip-1" });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining("not exposed by Premiere's supported ExtendScript or UXP APIs"),
|
||||
});
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("merges XMP patches through AdobeXMPScript and reparses host readback", async () => {
|
||||
const tools = getMetadataTools(bridgeOptions);
|
||||
await tools.set_xmp_metadata.handler({
|
||||
item_id: "source-1",
|
||||
xmp_xml: '<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"/></x:xmpmeta>',
|
||||
});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('new ExternalObject("lib:AdobeXMPScript")');
|
||||
expect(script).toContain("var existingXmp = new XMPMeta(existingPacket)");
|
||||
expect(script).toContain("XMPUtils.appendProperties(patchXmp, existingXmp, true, true, false)");
|
||||
expect(script).toContain("var writtenPacket = String(item.getXMPMetadata() || \"\")");
|
||||
expect(script).toContain('verification: "readback_xmp_packet_reparsed"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("health.get_capabilities", () => {
|
||||
it("reports platform support without requiring Premiere to be running", async () => {
|
||||
const tools = getHealthTools(bridgeOptions);
|
||||
const result = await tools.get_capabilities.handler();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.backends.cep.platforms).toEqual(["macOS", "Windows"]);
|
||||
expect(result.data.premiere.hostVerificationRequired).toBe(true);
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes per-tool operational metadata from the supplied catalog", async () => {
|
||||
const tools = getHealthTools(
|
||||
bridgeOptions,
|
||||
undefined,
|
||||
() => ({
|
||||
get_capabilities: { description: "Report capabilities." },
|
||||
ripple_delete: { description: "Ripple delete. Uses QE DOM." },
|
||||
}),
|
||||
);
|
||||
const result = await tools.get_capabilities.handler();
|
||||
expect(result.data.tools.total).toBe(2);
|
||||
expect(result.data.tools.tools).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "ripple_delete",
|
||||
status: "experimental",
|
||||
backend: "CEP/ExtendScript + QE",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounded read tools", () => {
|
||||
it("returns a filtered, paged get_capabilities catalog without changing the capability summary", async () => {
|
||||
const tools = getHealthTools(
|
||||
bridgeOptions,
|
||||
undefined,
|
||||
() => ({
|
||||
ping: { description: "Ping." },
|
||||
get_capabilities: { description: "Capabilities." },
|
||||
ripple_delete: { description: "Ripple delete. Uses QE DOM." },
|
||||
}),
|
||||
);
|
||||
const result = await tools.get_capabilities.handler({ tool_names: ["ripple_delete", "missing"], tool_limit: 1 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.tools.tools).toEqual([expect.objectContaining({ name: "ripple_delete" })]);
|
||||
expect(result.data.tools.pagination).toEqual({
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
returned: 1,
|
||||
totalMatching: 1,
|
||||
hasMore: false,
|
||||
nextOffset: null,
|
||||
});
|
||||
expect(result.data.backends.cep.status).toBe("production");
|
||||
});
|
||||
|
||||
it("omits requested metadata XML payloads before invoking the CEP bridge", async () => {
|
||||
const tools = getMetadataTools(bridgeOptions);
|
||||
await tools.get_metadata.handler({
|
||||
item_id: "source-1",
|
||||
include_project_metadata: false,
|
||||
include_xmp_metadata: false,
|
||||
});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).not.toContain("item.getProjectMetadata()");
|
||||
expect(script).not.toContain("item.getXMPMetadata()");
|
||||
expect(script).toContain("metadata.nodeId = item.nodeId");
|
||||
});
|
||||
|
||||
it("generates bounded overview and bin read scripts", async () => {
|
||||
const tools = getInspectionTools(bridgeOptions);
|
||||
await tools.get_full_project_overview.handler({
|
||||
include_bin_tree: false,
|
||||
sequence_offset: 5,
|
||||
sequence_limit: 10,
|
||||
});
|
||||
const overview = mockedSendCommand.mock.calls[0][0];
|
||||
expect(overview).toContain("var binTree = null");
|
||||
expect(overview).toContain("var sequenceOffset = 5");
|
||||
expect(overview).toContain("var sequenceLimit = 10");
|
||||
expect(overview).toContain("sequencePagination");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await tools.get_bin_contents.handler({
|
||||
bin_id: "Footage",
|
||||
recursive: false,
|
||||
offset: 10,
|
||||
limit: 25,
|
||||
max_depth: 0,
|
||||
});
|
||||
const bin = mockedSendCommand.mock.calls[0][0];
|
||||
expect(bin).toContain("var allContents = walkItems(target, false, 0)");
|
||||
expect(bin).toContain("allContents.slice(offset, offset + limit)");
|
||||
expect(bin).toContain("totalDirectItems");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace tools", () => {
|
||||
it("get_workspaces generates correct script", async () => {
|
||||
const tools = getWorkspaceTools(bridgeOptions);
|
||||
await (tools.get_workspaces.handler as any)({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("app.getWorkspaces()");
|
||||
expect(script).toContain("__result");
|
||||
});
|
||||
|
||||
it("set_workspace escapes the workspace name", async () => {
|
||||
const tools = getWorkspaceTools(bridgeOptions);
|
||||
await (tools.set_workspace.handler as any)({ name: 'My "Custom" Workspace' });
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('My \\"Custom\\" Workspace');
|
||||
expect(script).toContain("app.setWorkspace");
|
||||
});
|
||||
});
|
||||
|
||||
describe("playback tools", () => {
|
||||
it("play_timeline uses QE DOM", async () => {
|
||||
const tools = getPlaybackTools(bridgeOptions);
|
||||
await (tools.play_timeline.handler as any)({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("app.enableQE()");
|
||||
expect(script).toContain("qe.startPlayback()");
|
||||
expect(script).toContain("playbackRequested: true");
|
||||
expect(script).toContain("playbackVerified: false");
|
||||
expect(script).toContain("poll get_playhead_position");
|
||||
});
|
||||
|
||||
it("play_timeline has no speed parameter (QE startPlayback ignores it)", async () => {
|
||||
const tools = getPlaybackTools(bridgeOptions);
|
||||
expect(tools.play_timeline.parameters).toEqual({});
|
||||
});
|
||||
|
||||
it("writes and verifies the public TrackItem disabled property", async () => {
|
||||
const tools = getTimelineTools(bridgeOptions);
|
||||
await (tools.enable_disable_clip.handler as any)({ node_id: "clip-1", enabled: false });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("result.clip.disabled = wantDisabled");
|
||||
expect(script).toContain('var verified = __findClip("clip-1")');
|
||||
expect(script).toContain("!!verified.clip.disabled !== wantDisabled");
|
||||
expect(script).not.toContain("setDisabled(");
|
||||
});
|
||||
|
||||
it("does not claim legacy source-monitor or stop-playback requests are verified", async () => {
|
||||
const tools = getPlaybackTools(bridgeOptions);
|
||||
await (tools.stop_playback.handler as any)({});
|
||||
let script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("stopRequested: true");
|
||||
expect(script).toContain("playbackVerified: false");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await (tools.play_source_monitor.handler as any)({ speed: 1 });
|
||||
script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("playbackRequested: true");
|
||||
expect(script).toContain("poll get_source_monitor_position");
|
||||
});
|
||||
|
||||
it("stop_playback uses QE DOM", async () => {
|
||||
const tools = getPlaybackTools(bridgeOptions);
|
||||
await (tools.stop_playback.handler as any)({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("qe.stopPlayback()");
|
||||
});
|
||||
|
||||
it("get_source_monitor_position reads ticks", async () => {
|
||||
const tools = getPlaybackTools(bridgeOptions);
|
||||
await (tools.get_source_monitor_position.handler as any)({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("app.sourceMonitor.getPosition()");
|
||||
expect(script).toContain("__ticksToSeconds");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verified Premiere mutations", () => {
|
||||
it("uses settings fields and readback for set_sequence_settings", async () => {
|
||||
const tools = getSequenceTools(bridgeOptions);
|
||||
await (tools.set_sequence_settings.handler as any)({ sequence_id: "seq-1", width: 1920, height: 1080 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("settings.videoFrameWidth = 1920");
|
||||
expect(script).toContain("settings.videoFrameHeight = 1080");
|
||||
expect(script).toContain("var applied = seq.getSettings()");
|
||||
expect(script).toContain("Premiere did not apply the requested frame width");
|
||||
expect(script).toContain("verified: true");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await (tools.set_sequence_settings.handler as any)({ width: 1920 });
|
||||
expect(mockedSendCommand.mock.calls[0][0]).toContain("settings.videoFrameWidth = 1920");
|
||||
expect(mockedSendCommand.mock.calls[0][0]).not.toContain("settings.videoFrameHeight =");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await (tools.set_sequence_settings.handler as any)({ height: 1080 });
|
||||
expect(mockedSendCommand.mock.calls[0][0]).toContain("settings.videoFrameHeight = 1080");
|
||||
expect(mockedSendCommand.mock.calls[0][0]).not.toContain("settings.videoFrameWidth =");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await expect((tools.set_sequence_settings.handler as any)({ width: 0 })).resolves.toMatchObject({ success: false });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
|
||||
await expect((tools.set_sequence_settings.handler as any)({})).resolves.toMatchObject({ success: false });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails unsupported legacy AAF and multiple-undo requests before mutation", async () => {
|
||||
const aaf = await (getExportTools(bridgeOptions).export_aaf.handler as any)({ output_path: "/tmp/turnover.aaf" });
|
||||
const undoTools = getTrackTargetingTools(bridgeOptions);
|
||||
const undo = await (undoTools.multiple_undo.handler as any)({ count: 2 });
|
||||
const invalidUndo = await (undoTools.multiple_undo.handler as any)({ count: 0 });
|
||||
expect(aaf).toMatchObject({ success: false, error: expect.stringContaining("No export was attempted") });
|
||||
expect(undo).toMatchObject({ success: false, error: expect.stringContaining("No mutation was attempted") });
|
||||
expect(invalidUndo).toMatchObject({ success: false, error: expect.stringContaining("integer from 1 through 100") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finds pan on Channel Volume layouts and verifies the readback", async () => {
|
||||
const tools = getTrackTargetingTools(bridgeOptions);
|
||||
await (tools.set_clip_pan.handler as any)({ node_id: "audio-clip-1", pan: -50 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('result.trackType !== "audio"');
|
||||
expect(script).toContain("component.properties");
|
||||
expect(script).toContain('property.displayName === "Balance"');
|
||||
expect(script).toContain("panProperty.getValue()");
|
||||
expect(script).toContain("Premiere did not apply the requested pan");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await expect((tools.set_clip_pan.handler as any)({ node_id: "audio-clip-1", pan: 101 })).resolves.toMatchObject({ success: false });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports the documented ## zero-padded rename placeholder", async () => {
|
||||
const tools = getTrackTargetingTools(bridgeOptions);
|
||||
await (tools.batch_rename_clips.handler as any)({ pattern: "QA_Batch_##", track_type: "video", track_index: 0 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('.split("##").join(paddedSequenceNumber)');
|
||||
expect(script).toContain('while (paddedSequenceNumber.length < 2)');
|
||||
expect(script).toContain('"0" + paddedSequenceNumber');
|
||||
});
|
||||
|
||||
it("converts audio dB from Premiere's +15 dB normalized level and verifies the readback", async () => {
|
||||
const tools = getAudioTools(bridgeOptions);
|
||||
await (tools.adjust_audio_levels.handler as any)({ node_id: "clip-1", level_db: -6 });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("requestedLevel");
|
||||
expect(script).toContain("levelProp.getValue()");
|
||||
expect(script).toContain("Premiere did not apply the requested audio level");
|
||||
expect(script).toContain("0.089125");
|
||||
expect(script).toContain("appliedDb");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await expect((tools.adjust_audio_levels.handler as any)({ node_id: "clip-1", level_db: 16 }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("+15 dB") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preflights and verifies every batch effect target instead of swallowing failures", async () => {
|
||||
const tools = getClipboardTools(bridgeOptions);
|
||||
await (tools.batch_apply_effect.handler as any)({ effect_name: "Gaussian Blur", target: "selected" });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("function findQeClip(target)");
|
||||
expect(script).toContain("function countEffectComponents(clip)");
|
||||
expect(script).toContain("component count did not increase");
|
||||
expect(script).toContain("Batch effect application was only partially verified");
|
||||
expect(script).toContain("selectedIncompatible");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await expect((tools.batch_apply_effect.handler as any)({ effect_name: "Gaussian Blur", target: "track" }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("track_type") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not report an empty QE audio-transition catalog as an available list", async () => {
|
||||
const tools = getTransitionsTools(bridgeOptions);
|
||||
await (tools.list_available_audio_transitions.handler as any)({});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("QE reported an empty audio-transition catalog");
|
||||
expect(script).toContain("no transition availability is claimed");
|
||||
expect(script).toContain('source: "qe.catalog"');
|
||||
});
|
||||
|
||||
it("uses Time objects and verifies audio keyframe values", async () => {
|
||||
const tools = getAudioTools(bridgeOptions);
|
||||
await (tools.add_audio_keyframes.handler as any)({
|
||||
node_id: "clip-1",
|
||||
keyframes: [{ time_seconds: 1.5, level_db: -12 }],
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("new Time()");
|
||||
expect(script).toContain("getValueAtTime(t)");
|
||||
expect(script).toContain("verificationErrors");
|
||||
});
|
||||
|
||||
it("verifies ripple delete and passes QE razor a sequence timecode", async () => {
|
||||
await (getAdvancedTools(bridgeOptions).ripple_delete.handler as any)({ node_id: "clip-1" });
|
||||
let script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("if (__findClip(deletedNodeId))");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await (getTimelineTools(bridgeOptions).split_clip.handler as any)({ time_seconds: 2 });
|
||||
script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("var expectedClipCount = clipCountBefore + eligibleBefore.length");
|
||||
expect(script).toContain("function __hasSegment");
|
||||
expect(script).toContain("var __razorTc");
|
||||
expect(script).toContain("track.razor(__razorTc)");
|
||||
expect(script).not.toContain("track.razor(cutTicks.toString())");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await (getTrackTargetingTools(bridgeOptions).razor_all_tracks.handler as any)({ time_seconds: 2 });
|
||||
script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("var __razorTc");
|
||||
expect(script).toContain("getVideoTrackAt(t).razor(__razorTc)");
|
||||
expect(script).toContain("getAudioTrackAt(t).razor(__razorTc)");
|
||||
});
|
||||
|
||||
it("targets the QE clip, preserves an unsupported speed boundary, and verifies track state", async () => {
|
||||
const tools = getTransitionsTools(bridgeOptions);
|
||||
await (tools.add_transition.handler as any)({
|
||||
transition_name: "Cross Dissolve",
|
||||
track_index: 0,
|
||||
cut_point_seconds: 2,
|
||||
});
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("var targetClip = incomingClip || outgoingClip");
|
||||
expect(script).toContain("__findQeClipByDomClip(qeTrack, targetClip)");
|
||||
expect(script).toContain('typeof qeClip.addTransition !== "function"');
|
||||
expect(script).toContain('qeClip.addTransition(transitionQE, targetHead, String(durationFrames), "0", 0.5, false, true)');
|
||||
expect(script).not.toContain("qeTrack.addTransition(");
|
||||
expect(script).toContain("transitionAtCut");
|
||||
expect(script).toContain("Math.abs(((transitionStart + transitionEnd) / 2) - cutTicks) <= frameTicks / 2");
|
||||
expect(script).toContain("DOM readback did not find it at the requested cut point");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await (tools.add_transition_to_clip.handler as any)({
|
||||
node_id: "clip-1", transition_name: "Cross Dissolve", position: "both",
|
||||
});
|
||||
const clipScript = mockedSendCommand.mock.calls[0][0];
|
||||
expect(clipScript).toContain('result.trackType !== "video"');
|
||||
expect(clipScript).toContain("__findQeClipByDomClip(qeTrack, result.clip)");
|
||||
expect(clipScript).toContain("var requestedCount = position === \"both\" ? 2 : 1");
|
||||
expect(clipScript).toContain('qeClip.addTransition(transitionQE, true, String(durationFrames), "0", 0.5, false, true)');
|
||||
expect(clipScript).toContain('qeClip.addTransition(transitionQE, false, String(durationFrames), "0", 0.5, false, true)');
|
||||
expect(clipScript).toContain("startVerified");
|
||||
expect(clipScript).toContain("endVerified");
|
||||
expect(clipScript).toContain("var verifiedMidpoint = (verifiedStart + verifiedEnd) / 2");
|
||||
expect(clipScript).toContain("the request was partially applied");
|
||||
|
||||
vi.clearAllMocks();
|
||||
await (tools.batch_add_transitions.handler as any)({ transition_name: "Cross Dissolve" });
|
||||
const batchScript = mockedSendCommand.mock.calls[0][0];
|
||||
expect(batchScript).toContain("__findQeClipByDomClip(qeTrack, incomingClip)");
|
||||
expect(batchScript).toContain('qeClip.addTransition(transitionQE, true, String(durationFrames), "0", 0.5, false, true)');
|
||||
expect(batchScript).toContain("verifiedCount !== requestedCount");
|
||||
expect(batchScript).toContain("Math.abs(((readStart + readEnd) / 2) - expectedCut) <= frameTicks / 2");
|
||||
expect(batchScript).toContain("DOM readback did not find a transition at cut");
|
||||
});
|
||||
});
|
||||
|
||||
describe("discovery tools", () => {
|
||||
it("get_project_info generates correct script", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.get_project_info.handler as any)({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("app.project");
|
||||
expect(script).toContain("project.name");
|
||||
expect(script).toContain("project.path");
|
||||
expect(script).toContain("numSequences");
|
||||
expect(script).toContain("__result");
|
||||
});
|
||||
|
||||
it("list_project_items handles optional bin_path", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
|
||||
// Without bin_path
|
||||
await (tools.list_project_items.handler as any)({});
|
||||
let script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("var binPath = null");
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
// With bin_path
|
||||
await (tools.list_project_items.handler as any)({ bin_path: "Footage/Raw" });
|
||||
script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("Footage/Raw");
|
||||
});
|
||||
|
||||
it("list_project_items escapes bin_path", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.list_project_items.handler as any)({ bin_path: 'My "Folder"' });
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('My \\"Folder\\"');
|
||||
});
|
||||
|
||||
it("get_clip_properties escapes node_id", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.get_clip_properties.handler as any)({ node_id: "abc-123" });
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("abc-123");
|
||||
expect(script).toContain("__findClip");
|
||||
});
|
||||
|
||||
it("get_clip_at_position generates correct track lookup", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.get_clip_at_position.handler as any)({
|
||||
time_seconds: 5.5,
|
||||
track_index: 1,
|
||||
track_type: "video",
|
||||
});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("seq.videoTracks");
|
||||
expect(script).toContain("5.5");
|
||||
});
|
||||
|
||||
it("get_clip_at_position uses audio tracks when specified", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.get_clip_at_position.handler as any)({
|
||||
time_seconds: 2.0,
|
||||
track_index: 0,
|
||||
track_type: "audio",
|
||||
});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("seq.audioTracks");
|
||||
});
|
||||
|
||||
it("list_sequence_tracks defaults to active sequence", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.list_sequence_tracks.handler as any)({});
|
||||
|
||||
// Helpers are no longer inlined — the whole script is user code.
|
||||
const userCode = mockedSendCommand.mock.calls[0][0];
|
||||
expect(userCode).toContain("app.project.activeSequence");
|
||||
// Should NOT call __findSequence in user code when no sequence_id is provided
|
||||
expect(userCode).not.toContain('__findSequence("');
|
||||
});
|
||||
|
||||
it("list_sequence_tracks uses __findSequence when id provided", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.list_sequence_tracks.handler as any)({ sequence_id: "seq-1" });
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("__findSequence");
|
||||
expect(script).toContain("seq-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("caption tools", () => {
|
||||
it("create_caption_track generates correct script", async () => {
|
||||
const tools = getCaptionTools(bridgeOptions);
|
||||
const toolNames = Object.keys(tools);
|
||||
expect(toolNames).toContain("create_caption_track");
|
||||
|
||||
await (tools.create_caption_track.handler as any)({ item_id: "my-srt-file" });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("__result");
|
||||
expect(script).toContain("my-srt-file");
|
||||
expect(script).toContain("createCaptionTrack");
|
||||
expect(script).toContain("__captionTrackCount");
|
||||
expect(script).toContain("no caption track appeared in host readback");
|
||||
expect(script).toContain("accepted: true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("project-manager tools", () => {
|
||||
it("consolidate_and_transfer verifies a copied project in a new destination", async () => {
|
||||
const tools = getProjectManagerTools(bridgeOptions);
|
||||
expect(tools.consolidate_and_transfer).toBeDefined();
|
||||
expect(typeof tools.consolidate_and_transfer.handler).toBe("function");
|
||||
await (tools.consolidate_and_transfer.handler as any)({ destination_path: "/tmp/transfer" });
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("destination_path must be a new, empty folder");
|
||||
expect(script).toContain('destination.getFiles("*.prproj")');
|
||||
expect(script).toContain("copiedProjectCount");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tool Handler Return Values", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("handlers return the result from sendCommand", async () => {
|
||||
const expected = { success: true, data: { version: "24.0" } };
|
||||
mockedSendCommand.mockResolvedValue(expected);
|
||||
|
||||
const tools = getHealthTools(bridgeOptions);
|
||||
const result = await (tools.ping.handler as any)({});
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it("handlers propagate sendCommand errors", async () => {
|
||||
mockedSendCommand.mockRejectedValue(new Error("Connection failed"));
|
||||
|
||||
const tools = getHealthTools(bridgeOptions);
|
||||
await expect((tools.ping.handler as any)({})).rejects.toThrow("Connection failed");
|
||||
});
|
||||
|
||||
it("handlers propagate failure results", async () => {
|
||||
const failResult = { success: false, error: "No active sequence" };
|
||||
mockedSendCommand.mockResolvedValue(failResult);
|
||||
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
const result = await (tools.get_active_sequence.handler as any)({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("No active sequence");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tool Naming Conventions", () => {
|
||||
it("all tool names use snake_case", () => {
|
||||
for (const mod of ALL_MODULES) {
|
||||
const tools = mod.getter(bridgeOptions);
|
||||
for (const name of Object.keys(tools)) {
|
||||
expect(name, `${mod.name}.${name} should be snake_case`).toMatch(
|
||||
/^[a-z][a-z0-9]*(_[a-z0-9]+)*$/
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("no duplicate tool names across modules", () => {
|
||||
const allNames: string[] = [];
|
||||
for (const mod of ALL_MODULES) {
|
||||
const tools = mod.getter(bridgeOptions);
|
||||
allNames.push(...Object.keys(tools));
|
||||
}
|
||||
const unique = new Set(allNames);
|
||||
expect(unique.size).toBe(allNames.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Script Generation Patterns", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedSendCommand.mockResolvedValue({ success: true, data: {} });
|
||||
});
|
||||
|
||||
it("all scripts contain __result or __error", async () => {
|
||||
for (const mod of ALL_MODULES) {
|
||||
const tools = mod.getter(bridgeOptions);
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
vi.clearAllMocks();
|
||||
try {
|
||||
// Call with minimal args — we only care about the generated script
|
||||
await tool.handler({});
|
||||
} catch {
|
||||
// Some handlers may throw if required args are missing, that's OK
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mockedSendCommand.mock.calls.length > 0) {
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
const hasResult = script.includes("__result") || script.includes("__error") || script.includes("__aeResult") || script.includes("__aeError");
|
||||
expect(hasResult, `${mod.name}.${name} script should use a host result helper`).toBe(true);
|
||||
}
|
||||
if (mockedSendRawCommand.mock.calls.length > 0) {
|
||||
// Raw commands (scripting module) may not follow the pattern
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("scripts are wrapped in IIFE with try/catch", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.get_project_info.handler as any)({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain("(function() {");
|
||||
expect(script).toContain("} catch(e) {");
|
||||
expect(script).toContain("})();");
|
||||
});
|
||||
|
||||
it("scripts do not inline helpers; helpers source defines them", async () => {
|
||||
const tools = getDiscoveryTools(bridgeOptions);
|
||||
await (tools.get_project_info.handler as any)({});
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
// Inlining ~14KB of helpers per command overflowed the ExtendScript stack
|
||||
// on long-lived engines; commands must stay lean.
|
||||
expect(script).not.toContain("function __jsonStringify(obj)");
|
||||
const helpers = getHelpersSource();
|
||||
expect(helpers).toContain("function __result(data)");
|
||||
expect(helpers).toContain("function __error(msg)");
|
||||
expect(helpers).toContain("TICKS_PER_SECOND");
|
||||
});
|
||||
});
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeTranscriptDeletionRanges,
|
||||
previewTranscriptEdit,
|
||||
transcriptRevision,
|
||||
} from "../../src/tools/transcript-edits.js";
|
||||
|
||||
describe("transcript edit range edge cases", () => {
|
||||
it.each([
|
||||
[null, "deletion 0 must be an object"],
|
||||
["not a range", "deletion 0 must be an object"],
|
||||
[{ start_seconds: -0.01, end_seconds: 1 }, "start_seconds must be a non-negative number"],
|
||||
[{ start_seconds: Number.NaN, end_seconds: 1 }, "start_seconds must be a non-negative number"],
|
||||
[{ start_seconds: 1, end_seconds: Number.POSITIVE_INFINITY }, "end_seconds must be greater than start_seconds"],
|
||||
[{ start_seconds: 1, end_seconds: 1 }, "end_seconds must be greater than start_seconds"],
|
||||
])("rejects invalid deletion input %j", (deletion, message) => {
|
||||
expect(() => normalizeTranscriptDeletionRanges([deletion])).toThrow(message);
|
||||
});
|
||||
|
||||
it("rounds normalized values and merges precisely adjacent ranges", () => {
|
||||
expect(normalizeTranscriptDeletionRanges([
|
||||
{ start_seconds: 2, end_seconds: 3.00000049 },
|
||||
{ start_seconds: 1.00000031, end_seconds: 2 },
|
||||
], 0.0000004)).toEqual([
|
||||
{ start_seconds: 1, end_seconds: 3.000001 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses end time as a stable secondary sort key before merging equal-start ranges", () => {
|
||||
expect(normalizeTranscriptDeletionRanges([
|
||||
{ start_seconds: 5.0000004, end_seconds: 8 },
|
||||
{ start_seconds: 5.0000003, end_seconds: 6 },
|
||||
])).toEqual([
|
||||
{ start_seconds: 5, end_seconds: 8 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transcript preview revision guard", () => {
|
||||
it("rejects empty transcript payloads before creating a revision", () => {
|
||||
expect(() => previewTranscriptEdit("", "", [{ start_seconds: 1, end_seconds: 2 }]))
|
||||
.toThrow("Premiere returned an empty transcript");
|
||||
expect(() => previewTranscriptEdit(null as unknown as string, "", [{ start_seconds: 1, end_seconds: 2 }]))
|
||||
.toThrow("Premiere returned an empty transcript");
|
||||
});
|
||||
|
||||
it("does not treat an empty requested revision as a valid transcript lock", () => {
|
||||
const json = '{"segments":[{"text":"approve"}]}';
|
||||
expect(transcriptRevision(json)).not.toBe("");
|
||||
expect(() => previewTranscriptEdit(json, "", [{ start_seconds: 1, end_seconds: 2 }]))
|
||||
.toThrow("Transcript revision does not match");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
import {
|
||||
normalizeTranscriptDeletionRanges,
|
||||
planTranscriptRoughCut,
|
||||
previewTranscriptEdit,
|
||||
transcriptRevision,
|
||||
} from "../../src/tools/transcript-edits.js";
|
||||
import { getUxpTools } from "../../src/tools/uxp.js";
|
||||
|
||||
describe("transcript edit planning", () => {
|
||||
it("creates stable transcript revisions", () => {
|
||||
expect(transcriptRevision('{"segments":[]}')).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
expect(transcriptRevision('{"segments":[]}')).toBe(transcriptRevision('{"segments":[]}'));
|
||||
expect(transcriptRevision('{"segments":[1]}')).not.toBe(transcriptRevision('{"segments":[]}'));
|
||||
});
|
||||
|
||||
it("sorts, adds handles, and merges overlapping deletion ranges", () => {
|
||||
expect(normalizeTranscriptDeletionRanges([
|
||||
{ start_seconds: 5, end_seconds: 7 },
|
||||
{ start_seconds: 1, end_seconds: 3 },
|
||||
{ start_seconds: 2.9, end_seconds: 5.1 },
|
||||
], 0.1)).toEqual([{ start_seconds: 0.9, end_seconds: 7.1 }]);
|
||||
});
|
||||
|
||||
it("rejects invalid or excessive deletion ranges", () => {
|
||||
expect(() => normalizeTranscriptDeletionRanges([])).toThrow("at least one");
|
||||
expect(() => normalizeTranscriptDeletionRanges([{ start_seconds: 2, end_seconds: 1 }])).toThrow("greater than");
|
||||
expect(() => normalizeTranscriptDeletionRanges([{ start_seconds: 0, end_seconds: 1 }], 11)).toThrow("between 0 and 10");
|
||||
expect(() => normalizeTranscriptDeletionRanges(Array.from({ length: 101 }, (_, i) => ({ start_seconds: i, end_seconds: i + 0.5 })))).toThrow("limited to 100");
|
||||
});
|
||||
|
||||
it("revision-locks previews and returns a deterministic confirmation token", () => {
|
||||
const json = '{"segments":[{"text":"remove this"}]}';
|
||||
const revision = transcriptRevision(json);
|
||||
const first = previewTranscriptEdit(json, revision, [
|
||||
{ start_seconds: 4, end_seconds: 5 },
|
||||
{ start_seconds: 1, end_seconds: 2.5 },
|
||||
]);
|
||||
const second = previewTranscriptEdit(json, revision, [
|
||||
{ start_seconds: 1, end_seconds: 2.5 },
|
||||
{ start_seconds: 4, end_seconds: 5 },
|
||||
]);
|
||||
expect(first).toMatchObject({ deletedSeconds: 2.5, applied: false });
|
||||
expect(first.confirmationToken).toBe(second.confirmationToken);
|
||||
expect(() => previewTranscriptEdit(json, transcriptRevision("different"), [{ start_seconds: 1, end_seconds: 2 }])).toThrow("does not match");
|
||||
});
|
||||
|
||||
it("maps transcript deletions to descending verified 1x timeline cut instructions", () => {
|
||||
const json = '{"segments":[]}';
|
||||
const preview = previewTranscriptEdit(json, transcriptRevision(json), [
|
||||
{ start_seconds: 2, end_seconds: 4 },
|
||||
{ start_seconds: 8, end_seconds: 12 },
|
||||
]);
|
||||
const plan = planTranscriptRoughCut(preview, [{
|
||||
placement_id: "clip-1",
|
||||
track_type: "video",
|
||||
track_index: 0,
|
||||
source_in_seconds: 0,
|
||||
source_out_seconds: 10,
|
||||
timeline_start_seconds: 20,
|
||||
timeline_end_seconds: 30,
|
||||
}]);
|
||||
expect(plan).toMatchObject({
|
||||
applied: false,
|
||||
requiresDuplicateSequence: true,
|
||||
requiresPostMutationRequery: true,
|
||||
ripple: true,
|
||||
unmappedDeletionRanges: [],
|
||||
});
|
||||
expect(plan.steps.map((step) => step.timeline_range)).toEqual([
|
||||
{ start_seconds: 28, end_seconds: 30 },
|
||||
{ start_seconds: 22, end_seconds: 24 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects retimed placements instead of guessing source-to-timeline mapping", () => {
|
||||
const json = '{"segments":[]}';
|
||||
const preview = previewTranscriptEdit(json, transcriptRevision(json), [{ start_seconds: 1, end_seconds: 2 }]);
|
||||
expect(() => planTranscriptRoughCut(preview, [{
|
||||
placement_id: "retimed",
|
||||
track_type: "video",
|
||||
track_index: 0,
|
||||
source_in_seconds: 0,
|
||||
source_out_seconds: 10,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 5,
|
||||
}])).toThrow("retimed");
|
||||
});
|
||||
|
||||
it("fails closed for malformed placements and reports unmapped non-ripple ranges", () => {
|
||||
const json = '{"segments":[]}';
|
||||
const preview = previewTranscriptEdit(json, transcriptRevision(json), [{ start_seconds: 20, end_seconds: 21 }]);
|
||||
const valid = {
|
||||
placement_id: "clip-1", track_type: "audio", track_index: 0,
|
||||
source_in_seconds: 0, source_out_seconds: 10,
|
||||
timeline_start_seconds: 0, timeline_end_seconds: 10,
|
||||
} as const;
|
||||
|
||||
expect(() => planTranscriptRoughCut(preview, [])).toThrow("at least one");
|
||||
expect(() => planTranscriptRoughCut(preview, Array(257).fill(valid))).toThrow("limited to 256");
|
||||
expect(() => planTranscriptRoughCut(preview, [null])).toThrow("must be an object");
|
||||
expect(() => planTranscriptRoughCut(preview, [{ ...valid, placement_id: "" }])).toThrow("1-512");
|
||||
expect(() => planTranscriptRoughCut(preview, [{ ...valid, track_type: "caption" }])).toThrow("video or audio");
|
||||
expect(() => planTranscriptRoughCut(preview, [{ ...valid, track_index: -1 }])).toThrow("non-negative integer");
|
||||
expect(() => planTranscriptRoughCut(preview, [{ ...valid, source_out_seconds: Number.NaN }])).toThrow("finite number");
|
||||
expect(() => planTranscriptRoughCut(preview, [{ ...valid, source_out_seconds: 0 }])).toThrow("increasing");
|
||||
|
||||
const plan = planTranscriptRoughCut(preview, [valid], false);
|
||||
expect(plan).toMatchObject({ ripple: false, steps: [], unmappedDeletionRanges: preview.deletionRanges });
|
||||
});
|
||||
});
|
||||
|
||||
describe("transcript UXP MCP tools", () => {
|
||||
it("exports native transcript JSON with a stable revision", async () => {
|
||||
const json = '{"segments":[]}';
|
||||
const request = vi.fn().mockResolvedValue({ projectGuid: "project-1", projectItemId: "clip-1", projectItemName: "Interview", json });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const result = await getUxpTools(bridge).get_clip_transcript_uxp.handler({ project_item_id: "clip-1" });
|
||||
expect(request).toHaveBeenCalledWith("transcript.export", { projectItemId: "clip-1" });
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: { result: { projectGuid: "project-1", projectItemId: "clip-1", json, transcriptRevision: transcriptRevision(json) } },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a guarded transcript replacement to the documented UXP command", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ committed: true, verified: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const result = await getUxpTools(bridge).import_transcript_uxp.handler({
|
||||
project_item_id: "clip-1",
|
||||
project_guid: "project-1",
|
||||
expected_transcript_revision: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
replacement_transcript_json: '{"segments":[]}',
|
||||
confirm_destructive: true,
|
||||
operation_id: "transcript-replace-1",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("transcript.import", {
|
||||
projectItemId: "clip-1",
|
||||
expectedProjectGuid: "project-1",
|
||||
expectedTranscriptRevision: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
json: '{"segments":[]}',
|
||||
confirmDestructive: true,
|
||||
operationId: "transcript-replace-1",
|
||||
});
|
||||
expect(result).toMatchObject({ success: true, data: { result: { committed: true, verified: true } } });
|
||||
});
|
||||
|
||||
it("fails closed before the bridge when destructive confirmation or replay identity is missing", async () => {
|
||||
const request = vi.fn();
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpTools(bridge).import_transcript_uxp;
|
||||
await expect(tool.handler({
|
||||
project_item_id: "clip-1", project_guid: "project-1", expected_transcript_revision: null,
|
||||
replacement_transcript_json: '{"segments":[]}', confirm_destructive: false, operation_id: "transcript-replace-2",
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("confirm_destructive") });
|
||||
await expect(tool.handler({
|
||||
project_item_id: "clip-1", project_guid: "project-1", expected_transcript_revision: null,
|
||||
replacement_transcript_json: '{"segments":[]}', confirm_destructive: true, operation_id: "",
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("operation_id") });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps bounded search arguments to the native transcript command", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ matches: [] });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
await getUxpTools(bridge).search_clip_transcript_uxp.handler({
|
||||
project_item_name: "Interview",
|
||||
query: "launch date",
|
||||
case_sensitive: true,
|
||||
max_results: 10,
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("transcript.search", {
|
||||
projectItemName: "Interview",
|
||||
query: "launch date",
|
||||
caseSensitive: true,
|
||||
maxResults: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("re-exports the transcript before producing a revision-locked preview", async () => {
|
||||
const json = '{"segments":[{"text":"cut this"}]}';
|
||||
const request = vi.fn().mockResolvedValue({ projectItemId: "clip-1", projectItemName: "Interview", json });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const result = await getUxpTools(bridge).preview_transcript_edit_uxp.handler({
|
||||
project_item_id: "clip-1",
|
||||
transcript_revision: transcriptRevision(json),
|
||||
deletions: [{ start_seconds: 2, end_seconds: 4 }],
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("transcript.export", { projectItemId: "clip-1" });
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: { result: { projectItemId: "clip-1", deletedSeconds: 2, applied: false } },
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a transcript rough-cut plan without mutating Premiere", async () => {
|
||||
const json = '{"segments":[{"text":"cut this"}]}';
|
||||
const request = vi.fn().mockResolvedValue({ projectItemId: "clip-1", projectItemName: "Interview", json });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const result = await getUxpTools(bridge).plan_transcript_rough_cut_uxp.handler({
|
||||
project_item_id: "clip-1",
|
||||
transcript_revision: transcriptRevision(json),
|
||||
deletions: [{ start_seconds: 2, end_seconds: 4 }],
|
||||
placements: [{
|
||||
placement_id: "timeline-clip-1",
|
||||
track_type: "video",
|
||||
track_index: 0,
|
||||
source_in_seconds: 0,
|
||||
source_out_seconds: 10,
|
||||
timeline_start_seconds: 20,
|
||||
timeline_end_seconds: 30,
|
||||
}],
|
||||
});
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(request).toHaveBeenCalledWith("transcript.export", { projectItemId: "clip-1" });
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
data: { result: { applied: false, requiresDuplicateSequence: true, steps: [{ timeline_range: { start_seconds: 22, end_seconds: 24 } }] } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
import { getUxpTools } from "../../src/tools/uxp.js";
|
||||
|
||||
const ADVANCED_WORKFLOW_TOOLS = [
|
||||
"inspect_project_selection_uxp",
|
||||
"inspect_project_tree_uxp",
|
||||
"manage_markers_uxp",
|
||||
"apply_beat_markers_uxp",
|
||||
"create_silence_cut_source_stringout_uxp",
|
||||
"organize_project_items_uxp",
|
||||
"manage_sequence_settings_uxp",
|
||||
"manage_sequence_display_format_uxp",
|
||||
"import_project_media_uxp",
|
||||
"automate_effect_parameters_uxp",
|
||||
"transform_track_item_uxp",
|
||||
"edit_timeline_uxp",
|
||||
"manage_sequences_uxp",
|
||||
"encode_media_uxp",
|
||||
] as const;
|
||||
|
||||
describe("advanced stable UXP workflow MCP catalog", () => {
|
||||
it("publishes fourteen advanced tools with closed, bounded schemas", () => {
|
||||
const bridge = { request: vi.fn(), getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge) as Record<string, { parameters: Record<string, unknown> }>;
|
||||
|
||||
expect(Object.keys(tools)).toEqual(expect.arrayContaining(ADVANCED_WORKFLOW_TOOLS));
|
||||
expect(ADVANCED_WORKFLOW_TOOLS).toHaveLength(14);
|
||||
for (const name of ADVANCED_WORKFLOW_TOOLS) {
|
||||
const parameters = tools[name].parameters;
|
||||
expect(parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
});
|
||||
if (name !== "inspect_project_tree_uxp") {
|
||||
expect(parameters).toMatchObject({
|
||||
required: expect.arrayContaining([name === "apply_beat_markers_uxp" ? "beat_times_seconds" : name === "create_silence_cut_source_stringout_uxp" ? "source_project_item_id" : "action"]),
|
||||
});
|
||||
}
|
||||
}
|
||||
expect(tools.inspect_project_selection_uxp.parameters).toMatchObject({
|
||||
properties: { action: { enum: ["views", "selection"] }, view_id: { maxLength: 128 } },
|
||||
});
|
||||
expect(tools.inspect_project_tree_uxp.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
properties: { max_items: { minimum: 1, maximum: 512 }, max_depth: { minimum: 0, maximum: 16 } },
|
||||
});
|
||||
expect(tools.manage_markers_uxp.parameters).toMatchObject({
|
||||
properties: {
|
||||
action: { enum: ["inspect", "add", "update", "remove", "remove_many"] },
|
||||
marker_snapshots: {
|
||||
minItems: 1, maxItems: 128,
|
||||
items: { required: ["marker_guid", "expected_name", "expected_start_seconds", "expected_duration_seconds"] },
|
||||
},
|
||||
confirm_destructive: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
expect(tools.manage_sequence_display_format_uxp.parameters).toMatchObject({
|
||||
properties: {
|
||||
action: { enum: ["inspect", "update"] },
|
||||
expected_sequence_guid: { maxLength: 128 },
|
||||
expected_display_formats: { required: ["audio_display_format", "video_display_format"] },
|
||||
updates: { additionalProperties: false },
|
||||
},
|
||||
});
|
||||
expect(tools.import_project_media_uxp.parameters).toMatchObject({
|
||||
required: ["action", "confirm_non_undoable"],
|
||||
properties: { paths: { maxItems: 100 }, confirm_non_undoable: { type: "boolean" } },
|
||||
});
|
||||
expect(tools.automate_effect_parameters_uxp.parameters).toMatchObject({
|
||||
properties: {
|
||||
action: { enum: ["inspect", "inspect_point_value", "inspect_point_displacement", "set_point_value", "inspect_color_value", "set_color_value", "inspect_keyframe", "set_value", "add_keyframe", "remove_keyframe", "remove_keyframe_range", "set_interpolation", "inspect_time_varying", "set_time_varying"] },
|
||||
point: { required: ["x", "y"], additionalProperties: false },
|
||||
expected_point_snapshot: { required: ["project_id", "sequence_id", "media_type", "track_index", "clip_index", "component_index", "component_id", "param_index", "param_name", "time_varying", "point"], additionalProperties: false },
|
||||
color: { required: ["red", "green", "blue", "alpha"], additionalProperties: false },
|
||||
expected_color_snapshot: { required: ["project_id", "sequence_id", "media_type", "track_index", "clip_index", "component_index", "component_id", "param_index", "param_name", "time_varying", "color"], additionalProperties: false },
|
||||
expected_sequence_id: { maxLength: 128 }, expected_time_varying: { type: "boolean" },
|
||||
expected_keyframe_times_seconds: { maxItems: 256, uniqueItems: true },
|
||||
keyframe_direction: { enum: ["at", "next", "previous", "nearest"] },
|
||||
time_varying: { type: "boolean" }, confirm_disable_time_varying: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
expect(tools.encode_media_uxp.parameters).toMatchObject({
|
||||
properties: {
|
||||
action: { enum: ["preflight", "jobs", "wait", "sequence", "project_item", "file"] },
|
||||
job_id: { pattern: expect.any(String) },
|
||||
timeout_ms: { maximum: 60000 },
|
||||
output_file: { maxLength: 4096 },
|
||||
confirm_external_write: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps guarded parameter keyframe and animation-mode reads to their exact UXP commands", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
const target = {
|
||||
media_type: "video", track_index: 0, clip_index: 1, component_index: 2, param_index: 3,
|
||||
expected_component_id: "ADBE Opacity", expected_param_name: "Opacity",
|
||||
};
|
||||
|
||||
await tools.automate_effect_parameters_uxp.handler({ action: "inspect_keyframe", ...target, time_seconds: 4, keyframe_direction: "next" });
|
||||
await tools.automate_effect_parameters_uxp.handler({ action: "inspect_keyframe", ...target, time_seconds: 4, end_seconds: 6, keyframe_direction: "nearest" });
|
||||
await tools.automate_effect_parameters_uxp.handler({ action: "inspect_time_varying", ...target, time_seconds: 4 });
|
||||
await tools.automate_effect_parameters_uxp.handler({
|
||||
action: "set_time_varying", ...target, expected_sequence_id: "sequence-1", expected_time_varying: true,
|
||||
expected_keyframe_times_seconds: [1, 2], time_varying: false, confirm_disable_time_varying: true,
|
||||
operation_id: "parameter-animation-op",
|
||||
});
|
||||
|
||||
expect(request.mock.calls).toEqual([
|
||||
["parameters.keyframe.inspect", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Opacity", expectedParamName: "Opacity", timeSeconds: 4, direction: "next",
|
||||
}],
|
||||
["parameters.keyframe.inspect", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Opacity", expectedParamName: "Opacity", timeSeconds: 4, direction: "nearest", endSeconds: 6,
|
||||
}],
|
||||
["parameters.timeVarying.inspect", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Opacity", expectedParamName: "Opacity",
|
||||
}],
|
||||
["parameters.timeVarying.set", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedSequenceId: "sequence-1", expectedComponentId: "ADBE Opacity", expectedParamName: "Opacity",
|
||||
expectedTimeVarying: true, expectedKeyframeTimesSeconds: [1, 2], timeVarying: false,
|
||||
confirmDisableTimeVarying: true, operationId: "parameter-animation-op",
|
||||
}],
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps guarded static PointF inspection and update actions to the exact UXP commands", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
const target = {
|
||||
media_type: "video", track_index: 0, clip_index: 1, component_index: 2, param_index: 3,
|
||||
expected_component_id: "ADBE Motion", expected_param_name: "Position",
|
||||
};
|
||||
const expectedPointSnapshot = {
|
||||
project_id: "project-1", sequence_id: "sequence-1", media_type: "video", track_index: 0, clip_index: 1,
|
||||
component_index: 2, component_id: "ADBE Motion", param_index: 3, param_name: "Position", time_varying: false,
|
||||
point: { x: 960, y: 540 },
|
||||
};
|
||||
|
||||
await tools.automate_effect_parameters_uxp.handler({ action: "inspect_point_value", ...target });
|
||||
await tools.automate_effect_parameters_uxp.handler({
|
||||
action: "set_point_value", ...target, expected_point_snapshot: expectedPointSnapshot,
|
||||
point: { x: 1100, y: 600 }, confirm_set_point: true, operation_id: "point-tool-op",
|
||||
});
|
||||
|
||||
expect(request.mock.calls).toEqual([
|
||||
["parameters.point.inspect", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Motion", expectedParamName: "Position",
|
||||
}],
|
||||
["parameters.point.set", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Motion", expectedParamName: "Position", point: { x: 1100, y: 600 },
|
||||
expectedSnapshot: {
|
||||
projectId: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0, clipIndex: 1,
|
||||
componentIndex: 2, componentId: "ADBE Motion", paramIndex: 3, paramName: "Position", timeVarying: false,
|
||||
point: { x: 960, y: 540 },
|
||||
},
|
||||
confirmSetPoint: true, operationId: "point-tool-op",
|
||||
}],
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps bounded native PointF displacement inspection to its exact UXP command", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ straightLineDistance: 5 });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
await tools.automate_effect_parameters_uxp.handler({
|
||||
action: "inspect_point_displacement", media_type: "video", track_index: 0, clip_index: 1,
|
||||
component_index: 2, param_index: 3, expected_component_id: "ADBE Motion", expected_param_name: "Position",
|
||||
time_seconds: 2, end_seconds: 5,
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("parameters.point.displacement.inspect", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Motion", expectedParamName: "Position", startSeconds: 2, endSeconds: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps guarded static Color inspection and update actions to the exact UXP commands", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
const target = {
|
||||
media_type: "video", track_index: 0, clip_index: 1, component_index: 2, param_index: 3,
|
||||
expected_component_id: "ADBE Lumetri", expected_param_name: "Tint",
|
||||
};
|
||||
const expectedColorSnapshot = {
|
||||
project_id: "project-1", sequence_id: "sequence-1", media_type: "video", track_index: 0, clip_index: 1,
|
||||
component_index: 2, component_id: "ADBE Lumetri", param_index: 3, param_name: "Tint", time_varying: false,
|
||||
color: { red: 0.1, green: 0.2, blue: 0.3, alpha: 1 },
|
||||
};
|
||||
|
||||
await tools.automate_effect_parameters_uxp.handler({ action: "inspect_color_value", ...target });
|
||||
await tools.automate_effect_parameters_uxp.handler({
|
||||
action: "set_color_value", ...target, expected_color_snapshot: expectedColorSnapshot,
|
||||
color: { red: 0.9, green: 0.8, blue: 0.7, alpha: 0.6 }, confirm_set_color: true, operation_id: "color-tool-op",
|
||||
});
|
||||
|
||||
expect(request.mock.calls).toEqual([
|
||||
["parameters.color.inspect", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Lumetri", expectedParamName: "Tint",
|
||||
}],
|
||||
["parameters.color.set", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Lumetri", expectedParamName: "Tint",
|
||||
color: { red: 0.9, green: 0.8, blue: 0.7, alpha: 0.6 },
|
||||
expectedSnapshot: {
|
||||
projectId: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0, clipIndex: 1,
|
||||
componentIndex: 2, componentId: "ADBE Lumetri", paramIndex: 3, paramName: "Tint", timeVarying: false,
|
||||
color: { red: 0.1, green: 0.2, blue: 0.3, alpha: 1 },
|
||||
},
|
||||
confirmSetColor: true, operationId: "color-tool-op",
|
||||
}],
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps every consolidated tool to its exact camel-case UXP command contract", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
|
||||
await tools.inspect_project_selection_uxp.handler({ action: "selection", view_id: "view-1" });
|
||||
await tools.inspect_project_tree_uxp.handler({ max_items: 12, max_depth: 3 });
|
||||
await tools.manage_markers_uxp.handler({
|
||||
action: "update", owner_type: "project_item", project_item_id: "clip-1",
|
||||
marker_guid: "marker-1", expected_name: "Old", name: "New", color_index: 3,
|
||||
operation_id: "marker-op",
|
||||
});
|
||||
await tools.manage_markers_uxp.handler({
|
||||
action: "remove_many", owner_type: "sequence", sequence_id: "sequence-1", marker_guid: "single-marker", expected_name: "Ignored", confirm_destructive: true,
|
||||
marker_snapshots: [{ marker_guid: "marker-1", expected_name: "Old", expected_start_seconds: 1, expected_duration_seconds: 0 }],
|
||||
operation_id: "marker-batch-op",
|
||||
});
|
||||
await tools.apply_beat_markers_uxp.handler({
|
||||
beat_times_seconds: [0.5, 1, 1.5], sequence_id: "sequence-1", offset_seconds: 2,
|
||||
name_prefix: "Downbeat", comments: "Detected beat grid", operation_id: "beat-grid-op",
|
||||
});
|
||||
await tools.create_silence_cut_source_stringout_uxp.handler({
|
||||
source_project_item_id: "clip-1", sequence_name: "Interview Tight", duration_seconds: 10,
|
||||
frame_rate: 30, silence_ranges: [{ start_seconds: 2, end_seconds: 4 }],
|
||||
keep_handle_frames: 0, confirm_non_undoable: true, operation_id: "silence-op",
|
||||
});
|
||||
await tools.organize_project_items_uxp.handler({
|
||||
action: "create_smart_bin", parent_bin_id: "bin-1", name: "Selects",
|
||||
search_query: "rating:5", operation_id: "bin-op",
|
||||
});
|
||||
await tools.manage_sequence_settings_uxp.handler({
|
||||
action: "update", sequence_id: "sequence-1",
|
||||
updates: { maximum_bit_depth: true, video_frame_rate: 24, video_width: 1920 },
|
||||
operation_id: "settings-op",
|
||||
});
|
||||
await tools.manage_sequence_display_format_uxp.handler({
|
||||
action: "update", sequence_id: "sequence-1", expected_sequence_guid: "sequence-1",
|
||||
expected_display_formats: { audio_display_format: 1, video_display_format: 20 },
|
||||
updates: { audio_display_format: 2, video_display_format: 26 }, operation_id: "display-format-op",
|
||||
});
|
||||
await tools.import_project_media_uxp.handler({
|
||||
action: "ae_comps", aep_path: "D:/Approved/graphics.aep",
|
||||
comp_names: ["Lower Third"], target_bin_id: "bin-1",
|
||||
confirm_non_undoable: true, operation_id: "import-op",
|
||||
});
|
||||
await tools.automate_effect_parameters_uxp.handler({
|
||||
action: "add_keyframe", media_type: "video", track_index: 0, clip_index: 1,
|
||||
component_index: 2, param_index: 3, expected_component_id: "ADBE Opacity",
|
||||
expected_param_name: "Opacity", value: 75, time_seconds: 2.5,
|
||||
operation_id: "keyframe-op",
|
||||
});
|
||||
await tools.transform_track_item_uxp.handler({
|
||||
action: "update", media_type: "audio", track_index: 1, clip_index: 2,
|
||||
expected_start_seconds: 10, expected_end_seconds: 20, move_by_seconds: 1.5,
|
||||
disabled: false, operation_id: "track-op",
|
||||
});
|
||||
await tools.edit_timeline_uxp.handler({
|
||||
action: "insert_mogrt_path", file_path: "D:/Approved/title.mogrt",
|
||||
time_seconds: 4, video_track_index: 2, audio_track_index: 0,
|
||||
confirm_non_undoable: true, operation_id: "timeline-op",
|
||||
});
|
||||
await tools.manage_sequences_uxp.handler({
|
||||
action: "create_from_media", name: "Assembly", project_item_ids: ["clip-1", "clip-2"],
|
||||
target_bin_id: "bin-1", confirm_non_undoable: true, operation_id: "sequence-op",
|
||||
});
|
||||
await tools.encode_media_uxp.handler({
|
||||
action: "file", file_path: "D:/Approved/input.mov", output_file: "D:/Approved/output.mp4",
|
||||
preset_file: "D:/Approved/h264.epr", in_seconds: 1, out_seconds: 8, work_area: 0,
|
||||
remove_upon_completion: true, start_queue_immediately: false,
|
||||
confirm_external_write: true, operation_id: "encode-op",
|
||||
});
|
||||
await tools.encode_media_uxp.handler({
|
||||
action: "wait", job_id: "encode-op", timeout_ms: 5000,
|
||||
});
|
||||
await tools.encode_media_uxp.handler({ action: "jobs", limit: 3 });
|
||||
await tools.encode_media_uxp.handler({ action: "wait", job_id: "encode-op" });
|
||||
|
||||
expect(request.mock.calls).toEqual([
|
||||
["projectSelection.inspect", { viewId: "view-1" }],
|
||||
["projectTree.inspect", { maxItems: 12, maxDepth: 3 }],
|
||||
["markers.update", {
|
||||
ownerType: "projectItem", projectItemId: "clip-1", markerGuid: "marker-1",
|
||||
expectedName: "Old", name: "New", colorIndex: 3, operationId: "marker-op",
|
||||
}],
|
||||
["markers.removeMany", {
|
||||
ownerType: "sequence", sequenceId: "sequence-1",
|
||||
markerSnapshots: [{ markerGuid: "marker-1", expectedName: "Old", expectedStartSeconds: 1, expectedDurationSeconds: 0 }],
|
||||
confirmDestructive: true, operationId: "marker-batch-op",
|
||||
}],
|
||||
["markers.addBeatGrid", {
|
||||
beatTimesSeconds: [0.5, 1, 1.5], sequenceId: "sequence-1", offsetSeconds: 2,
|
||||
namePrefix: "Downbeat", comments: "Detected beat grid", operationId: "beat-grid-op",
|
||||
}],
|
||||
["silence.deriveSequence", {
|
||||
sourceProjectItemId: "clip-1", name: "Interview Tight",
|
||||
keepRanges: [
|
||||
{ startFrame: 0, endFrame: 60, startSeconds: 0, endSeconds: 2 },
|
||||
{ startFrame: 120, endFrame: 300, startSeconds: 4, endSeconds: 10 },
|
||||
],
|
||||
confirmNonUndoable: true, operationId: "silence-op",
|
||||
}],
|
||||
["bins.createSmart", {
|
||||
parentBinId: "bin-1", name: "Selects", searchQuery: "rating:5", operationId: "bin-op",
|
||||
}],
|
||||
["sequenceSettings.update", {
|
||||
sequenceId: "sequence-1",
|
||||
updates: { maximumBitDepth: true, videoFrameRate: 24, videoWidth: 1920 },
|
||||
operationId: "settings-op",
|
||||
}],
|
||||
["sequence.displayFormat.update", {
|
||||
sequenceId: "sequence-1", expectedSequenceGuid: "sequence-1",
|
||||
expectedDisplayFormats: { audioDisplayFormat: 1, videoDisplayFormat: 20 },
|
||||
updates: { audioDisplayFormat: 2, videoDisplayFormat: 26 }, operationId: "display-format-op",
|
||||
}],
|
||||
["project.import", {
|
||||
mode: "aeComps", aepPath: "D:/Approved/graphics.aep", compNames: ["Lower Third"],
|
||||
targetBinId: "bin-1", confirmNonUndoable: true, operationId: "import-op",
|
||||
}],
|
||||
["parameters.keyframeAdd", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 1, componentIndex: 2, paramIndex: 3,
|
||||
expectedComponentId: "ADBE Opacity", expectedParamName: "Opacity", timeSeconds: 2.5,
|
||||
value: 75, operationId: "keyframe-op",
|
||||
}],
|
||||
["trackItem.update", {
|
||||
mediaType: "audio", trackIndex: 1, clipIndex: 2, expectedStartSeconds: 10,
|
||||
expectedEndSeconds: 20, moveBySeconds: 1.5, disabled: false, operationId: "track-op",
|
||||
}],
|
||||
["timeline.mogrtPath", {
|
||||
timeSeconds: 4, videoTrackIndex: 2, audioTrackIndex: 0,
|
||||
filePath: "D:/Approved/title.mogrt", confirmNonUndoable: true,
|
||||
operationId: "timeline-op",
|
||||
}],
|
||||
["sequences.createFromMedia", {
|
||||
name: "Assembly", projectItemIds: ["clip-1", "clip-2"], targetBinId: "bin-1",
|
||||
confirmNonUndoable: true, operationId: "sequence-op",
|
||||
}],
|
||||
["encoder.file", {
|
||||
filePath: "D:/Approved/input.mov", outputFile: "D:/Approved/output.mp4",
|
||||
presetFile: "D:/Approved/h264.epr", inSeconds: 1, outSeconds: 8, workArea: 0,
|
||||
removeUponCompletion: true, startQueueImmediately: false,
|
||||
confirmExternalWrite: true, operationId: "encode-op",
|
||||
}],
|
||||
["encoder.wait", { jobId: "encode-op", timeoutMs: 5000 }, { minimumTimeoutMs: 10000 }],
|
||||
["encoder.jobs", { limit: 3 }],
|
||||
["encoder.wait", { jobId: "encode-op" }, { minimumTimeoutMs: 5000 }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unknown public actions without contacting Premiere", async () => {
|
||||
const request = vi.fn();
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
const results = await Promise.all([
|
||||
tools.inspect_project_selection_uxp.handler({ action: "destroy_everything" }),
|
||||
tools.manage_markers_uxp.handler({ action: "destroy_everything" }),
|
||||
tools.organize_project_items_uxp.handler({ action: "destroy_everything" }),
|
||||
tools.transform_track_item_uxp.handler({ action: "destroy_everything" }),
|
||||
]);
|
||||
|
||||
expect(results).toEqual(Array.from({ length: 4 }, () => ({
|
||||
success: false,
|
||||
error: "Unsupported workflow action: destroy_everything",
|
||||
})));
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unreviewed or duplicate marker batch removal before contacting Premiere", async () => {
|
||||
const request = vi.fn();
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
const snapshot = { marker_guid: "marker-1", expected_name: "Beat", expected_start_seconds: 0, expected_duration_seconds: 0 };
|
||||
|
||||
await expect(tools.manage_markers_uxp.handler({ action: "remove_many", marker_snapshots: [snapshot] })).resolves.toMatchObject({
|
||||
success: false, error: expect.stringContaining("confirm_destructive=true"),
|
||||
});
|
||||
await expect(tools.manage_markers_uxp.handler({
|
||||
action: "remove_many", confirm_destructive: true, marker_snapshots: [snapshot, snapshot],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("duplicate marker_guid") });
|
||||
await expect(tools.manage_markers_uxp.handler({
|
||||
action: "remove_many", confirm_destructive: true, marker_snapshots: [null],
|
||||
})).resolves.toMatchObject({ success: false, error: expect.stringContaining("marker_snapshots[0] must be an object") });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes Error and non-Error bridge rejections", async () => {
|
||||
const request = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("Premiere unavailable"))
|
||||
.mockRejectedValueOnce("transport closed");
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
|
||||
await expect(tools.inspect_project_selection_uxp.handler({ action: "views" })).resolves.toEqual({
|
||||
success: false,
|
||||
error: "Premiere unavailable",
|
||||
});
|
||||
await expect(tools.manage_sequence_settings_uxp.handler({ action: "get" })).resolves.toEqual({
|
||||
success: false,
|
||||
error: "transport closed",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing actions in mapped dispatchers before bridge access", async () => {
|
||||
const request = vi.fn();
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge);
|
||||
|
||||
const results = await Promise.all([
|
||||
tools.import_project_media_uxp.handler({ confirm_non_undoable: true }),
|
||||
tools.automate_effect_parameters_uxp.handler({}),
|
||||
tools.edit_timeline_uxp.handler({}),
|
||||
tools.manage_sequences_uxp.handler({}),
|
||||
tools.encode_media_uxp.handler({}),
|
||||
]);
|
||||
|
||||
expect(results).toEqual(Array.from({ length: 5 }, () => ({
|
||||
success: false,
|
||||
error: "Unsupported workflow action: undefined",
|
||||
})));
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpCloneWorkflowTools } from "../../src/tools/uxp-clone-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
const source = { project_item_id: "source-1", start_seconds: 0, end_seconds: 10, in_seconds: 20, out_seconds: 30, duration_seconds: 10, speed: 1, reversed: false };
|
||||
|
||||
describe("public guarded append-only track-item duplicate MCP tool", () => {
|
||||
it("uses a closed complete source snapshot schema and translates snake_case apply arguments", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const tool = getUxpCloneWorkflowTools({ request } as unknown as UxpWebSocketBridge).duplicate_track_item_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
additionalProperties: false, required: ["action", "media_type", "track_index", "clip_index"],
|
||||
properties: { action: { enum: ["inspect", "apply"] }, expected_snapshot: { additionalProperties: false, required: ["project_guid", "sequence_id", "media_type", "track_index", "clip_index", "track_item_count", "source"] } },
|
||||
});
|
||||
await tool.handler({
|
||||
action: "apply", media_type: "video", track_index: 0, clip_index: 0, confirm_duplicate: true, operation_id: "clone-tool-1",
|
||||
expected_snapshot: { project_guid: "project-1", sequence_id: "sequence-1", media_type: "video", track_index: 0, clip_index: 0, track_item_count: 1, source },
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("trackItem.clone", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 0, confirmDuplicate: true, operationId: "clone-tool-1",
|
||||
expectedSnapshot: {
|
||||
projectGuid: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0, clipIndex: 0, trackItemCount: 1,
|
||||
source: { projectItemId: "source-1", startSeconds: 0, endSeconds: 10, inSeconds: 20, outSeconds: 30, durationSeconds: 10, speed: 1, reversed: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not silently drop unknown fields from a reviewed source snapshot", async () => {
|
||||
const request = vi.fn();
|
||||
const tool = getUxpCloneWorkflowTools({ request } as unknown as UxpWebSocketBridge).duplicate_track_item_uxp;
|
||||
await expect(tool.handler({
|
||||
action: "apply", media_type: "video", track_index: 0, clip_index: 0,
|
||||
expected_snapshot: { project_guid: "project-1", sequence_id: "sequence-1", media_type: "video", track_index: 0, clip_index: 0, track_item_count: 1, source: { ...source, unexpected: true } },
|
||||
})).rejects.toThrow("expected_snapshot.source has an unknown field: unexpected");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpDialogueWorkflowTools, validateDerivedDialoguePlan } from "../../src/tools/uxp-dialogue-workflows.js";
|
||||
|
||||
const json = "{\"segments\":[]}", revision = "sha256:cf84482b9efdd9291a36643471c6e09c79a69623f87a7b61265b660e54e69eaf";
|
||||
describe("UXP dialogue workflow tools", () => {
|
||||
it("previews then applies the exact revision-bound plan", async () => {
|
||||
const request = vi.fn(async (command: string) => command === "transcript.export" ? { json, projectGuid: "project" } : { outcome: "committed_unverified" });
|
||||
const tools = getUxpDialogueWorkflowTools({ request } as any);
|
||||
const preview = await tools.preview_derived_dialogue_sequence_uxp.handler({ project_guid: "project", mode: "talking_head", sequence_name: "Reviewed", segments: [{ id: "s1", source_project_item_id: "clip", transcript_revision: revision, source_start_seconds: 0, source_end_seconds: 1 }] });
|
||||
expect(preview.success).toBe(true);
|
||||
const data = preview.data as any;
|
||||
const applied = await tools.apply_derived_dialogue_sequence_uxp.handler({ plan: data.plan, confirmation_token: data.confirmation_token, operation_id: "op-1" });
|
||||
expect(applied.success).toBe(true);
|
||||
expect(request).toHaveBeenLastCalledWith("dialogue.deriveSequence", { plan: data.plan, operationId: "op-1" });
|
||||
});
|
||||
it("validates podcast plans and rejects unsafe variants", () => {
|
||||
const segment = { id: "s", source_project_item_id: "camera", transcript_revision: revision, source_start_seconds: 1, source_end_seconds: 2, master_audio_start_seconds: 3, master_audio_end_seconds: 4 };
|
||||
const plan = { schema_version: 1, project_guid: "project", mode: "podcast", sequence_name: "Podcast", master_audio_project_item_id: "audio", segments: [segment], output_duration_seconds: 1, original_sources_unchanged: true, render_verified: false };
|
||||
expect(validateDerivedDialoguePlan(plan).mode).toBe("podcast");
|
||||
const invalid = [
|
||||
null, { ...plan, extra: true }, { ...plan, schema_version: 2 }, { ...plan, mode: "other" }, { ...plan, segments: [] },
|
||||
{ ...plan, segments: [null] }, { ...plan, segments: [{ ...segment, extra: true }] }, { ...plan, segments: [segment, segment] },
|
||||
{ ...plan, segments: [{ ...segment, source_end_seconds: 1 }] }, { ...plan, segments: [{ ...segment, transcript_revision: "bad" }] },
|
||||
{ ...plan, segments: [{ ...segment, master_audio_end_seconds: 5 }] }, { ...plan, master_audio_project_item_id: undefined },
|
||||
{ ...plan, output_duration_seconds: 2 }, { ...plan, original_sources_unchanged: false },
|
||||
{ ...plan, target_bin_id: "" }, { ...plan, segments: [{ ...segment, speaker_label: "" }] },
|
||||
{ ...plan, mode: "talking_head", segments: [{ ...segment, master_audio_start_seconds: undefined, master_audio_end_seconds: undefined }] },
|
||||
];
|
||||
for (const value of invalid) expect(() => validateDerivedDialoguePlan(value)).toThrow();
|
||||
expect(() => validateDerivedDialoguePlan({ ...plan, mode: "talking_head", master_audio_project_item_id: undefined, segments: [{ ...segment, master_audio_start_seconds: undefined, master_audio_end_seconds: undefined }, { ...segment, id: "two", source_project_item_id: "other", master_audio_start_seconds: undefined, master_audio_end_seconds: undefined }] })).toThrow(/same source/);
|
||||
});
|
||||
it("fails closed for stale or unavailable transcripts and bad confirmations", async () => {
|
||||
const base = { project_guid: "project", mode: "talking_head", sequence_name: "Reviewed", segments: [{ id: "s1", source_project_item_id: "clip", transcript_revision: revision, source_start_seconds: 0, source_end_seconds: 1 }] };
|
||||
for (const response of [{ json, projectGuid: "other" }, { projectGuid: "project" }, { json: "changed", projectGuid: "project" }]) {
|
||||
const tools = getUxpDialogueWorkflowTools({ request: vi.fn().mockResolvedValue(response) } as any);
|
||||
expect((await tools.preview_derived_dialogue_sequence_uxp.handler(base)).success).toBe(false);
|
||||
}
|
||||
const tools = getUxpDialogueWorkflowTools({ request: vi.fn().mockResolvedValue({ json, projectGuid: "project" }) } as any);
|
||||
const preview = await tools.preview_derived_dialogue_sequence_uxp.handler(base) as any;
|
||||
expect((await tools.apply_derived_dialogue_sequence_uxp.handler({ plan: preview.data.plan, confirmation_token: `sha256:${"0".repeat(64)}`, operation_id: "op" })).success).toBe(false);
|
||||
expect((await tools.apply_derived_dialogue_sequence_uxp.handler({ plan: preview.data.plan, confirmation_token: preview.data.confirmation_token, operation_id: "bad space" })).success).toBe(false);
|
||||
});
|
||||
it("normalizes non-Error bridge failures", async () => {
|
||||
const tools = getUxpDialogueWorkflowTools({ request: vi.fn().mockRejectedValue("offline") } as any);
|
||||
const result = await tools.preview_derived_dialogue_sequence_uxp.handler({ project_guid: "project", mode: "talking_head", sequence_name: "Reviewed", segments: [{ id: "s1", source_project_item_id: "clip", transcript_revision: revision, source_start_seconds: 0, source_end_seconds: 1 }] });
|
||||
expect(result).toEqual({ success: false, error: "offline" });
|
||||
});
|
||||
});
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpEffectParameterCatalogWorkflowTools } from "../../src/tools/uxp-effect-parameter-catalog-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
describe("public documented UXP effect-parameter catalog tool", () => {
|
||||
it("publishes a closed bounded schema and maps only parameter-catalog coordinates", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ parameterCount: 1 });
|
||||
const tool = getUxpEffectParameterCatalogWorkflowTools({ request } as unknown as UxpWebSocketBridge)
|
||||
.inspect_effect_parameter_catalog_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false,
|
||||
required: ["media_type", "track_index", "clip_index", "component_index"],
|
||||
properties: {
|
||||
media_type: { type: "string", enum: ["video", "audio"] },
|
||||
track_index: { type: "integer", minimum: 0, maximum: 511 },
|
||||
clip_index: { type: "integer", minimum: 0, maximum: 511 },
|
||||
component_index: { type: "integer", minimum: 0, maximum: 511 },
|
||||
expected_sequence_guid: { minLength: 1, maxLength: 512 },
|
||||
expected_component_id: { minLength: 1, maxLength: 512 },
|
||||
},
|
||||
});
|
||||
expect(tool.description).toContain("never raw parameter values");
|
||||
expect(tool.operationalCapability).toMatchObject({
|
||||
backend: "UXP", minimumPremiereVersion: "25.6", hostVerificationRequired: true,
|
||||
});
|
||||
|
||||
await tool.handler({
|
||||
media_type: "audio", track_index: 3, clip_index: 2, component_index: 1,
|
||||
expected_sequence_guid: "sequence-1", expected_component_id: "ADBE Volume",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("parameters.catalog.inspect", {
|
||||
mediaType: "audio", trackIndex: 3, clipIndex: 2, componentIndex: 1,
|
||||
expectedSequenceGuid: "sequence-1", expectedComponentId: "ADBE Volume",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getUxpTools } from "../../src/tools/uxp.js";
|
||||
import { getUxpWorkflowTools } from "../../src/tools/uxp-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
type Schema = { type?: string; enum?: unknown[]; properties?: Record<string, Schema>; required?: string[]; items?: Schema };
|
||||
type Tool = { parameters: Schema; handler: (args: Record<string, unknown>) => Promise<unknown> };
|
||||
|
||||
const values: Record<string, unknown> = {
|
||||
name: "Coverage Name", query: "coverage", preset_path: "/tmp/preset.sqpreset",
|
||||
confirm_non_undoable: true, confirm_destructive: true, operation_id: "coverage-operation",
|
||||
output_file_path: "/tmp/output.otio", project_item_id: "item-1", track_index: 0,
|
||||
start_seconds: 1, end_seconds: 2, transcript_revision: `sha256:${"a".repeat(64)}`,
|
||||
project_guid: "project-1", expected_transcript_revision: `sha256:${"a".repeat(64)}`,
|
||||
replacement_transcript_json: '{"segments":[]}',
|
||||
sequence_name: "Coverage Stringout", duration_seconds: 10, frame_rate: 30,
|
||||
silence_ranges: [{ start_seconds: 2, end_seconds: 4 }],
|
||||
placements: [{
|
||||
placement_id: "coverage-placement", track_type: "video", track_index: 0,
|
||||
source_in_seconds: 0, source_out_seconds: 10,
|
||||
timeline_start_seconds: 20, timeline_end_seconds: 30,
|
||||
}],
|
||||
};
|
||||
|
||||
function valueFor(schema: Schema, field: string, all: boolean): unknown {
|
||||
if (field in values) return values[field];
|
||||
if (schema.enum?.length) return all ? schema.enum.at(-1) : schema.enum[0];
|
||||
if (schema.type === "boolean") return all;
|
||||
if (schema.type === "number" || schema.type === "integer") return 1;
|
||||
if (schema.type === "array") return [valueFor(schema.items ?? { type: "string" }, field, all)];
|
||||
if (schema.type === "object") return argsFor(schema, all);
|
||||
return `coverage-${field}`;
|
||||
}
|
||||
|
||||
function argsFor(schema: Schema, all: boolean) {
|
||||
const result: Record<string, unknown> = {};
|
||||
const required = new Set(schema.required ?? []);
|
||||
for (const [field, property] of Object.entries(schema.properties ?? {})) {
|
||||
if (all || required.has(field)) result[field] = valueFor(property, field, all);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("UXP tool handler coverage", () => {
|
||||
const request = vi.fn();
|
||||
const getState = vi.fn(() => ({ connected: true, authenticated: true }));
|
||||
const bridge = { request, getState } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpTools(bridge) as Record<string, Tool>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
request.mockResolvedValue({
|
||||
json: JSON.stringify({ words: [], segments: [] }),
|
||||
projectItemId: "item-1",
|
||||
projectItemName: "Coverage",
|
||||
});
|
||||
});
|
||||
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
it.each([["required", false], ["all", true]])(`${name} handles %s arguments`, async (_label, all) => {
|
||||
const args = argsFor(tool.parameters, all);
|
||||
if (name === "manage_timeline_selection_uxp" && all) args.action = "replace";
|
||||
const result = await tool.handler(args);
|
||||
expect(result).toBeDefined();
|
||||
if (name === "get_uxp_capabilities") expect(getState).toHaveBeenCalled();
|
||||
else if (!["preview_derived_dialogue_sequence_uxp", "apply_derived_dialogue_sequence_uxp"].includes(name)) expect(request).toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
it("normalizes Error and non-Error bridge rejections", async () => {
|
||||
request.mockRejectedValueOnce(new Error("offline"));
|
||||
await expect(tools.get_uxp_state.handler({})).resolves.toEqual({ success: false, error: "offline" });
|
||||
request.mockRejectedValueOnce("disconnected");
|
||||
await expect(tools.get_uxp_state.handler({})).resolves.toEqual({ success: false, error: "disconnected" });
|
||||
});
|
||||
|
||||
it("rejects an empty exported transcript", async () => {
|
||||
request.mockResolvedValueOnce({ json: "" });
|
||||
await expect(tools.get_clip_transcript_uxp.handler({ project_item_id: "item-1" }))
|
||||
.resolves.toEqual({ success: false, error: "Premiere returned an empty transcript" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("UXP workflow handler branch coverage", () => {
|
||||
const request = vi.fn();
|
||||
const bridge = { request } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpWorkflowTools(bridge) as Record<string, Tool>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
request.mockResolvedValue({ covered: true });
|
||||
});
|
||||
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
it.each([["required", false], ["all", true]])(`${name} handles %s arguments`, async (_label, all) => {
|
||||
await expect(tool.handler(argsFor(tool.parameters, all))).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
for (const [field, property] of Object.entries(tool.parameters.properties ?? {})) {
|
||||
for (const enumValue of property.enum ?? []) {
|
||||
it(`${name} handles ${field}=${String(enumValue)}`, async () => {
|
||||
const args = argsFor(tool.parameters, true);
|
||||
args[field] = enumValue;
|
||||
await expect(tool.handler(args)).resolves.toBeDefined();
|
||||
});
|
||||
}
|
||||
if (property.type === "boolean") {
|
||||
it(`${name} handles ${field}=false`, async () => {
|
||||
const args = argsFor(tool.parameters, true);
|
||||
args[field] = false;
|
||||
await expect(tool.handler(args)).resolves.toBeDefined();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (tool.parameters.properties?.action?.enum) {
|
||||
it(`${name} rejects an unsupported action`, async () => {
|
||||
const args = argsFor(tool.parameters, true);
|
||||
args.action = "unsupported-action";
|
||||
await expect(tool.handler(args)).resolves.toMatchObject({ success: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("normalizes workflow bridge failures", async () => {
|
||||
request.mockRejectedValueOnce(new Error("workflow offline"));
|
||||
await expect(tools.manage_clip_effects_uxp.handler({ action: "catalog" }))
|
||||
.resolves.toEqual({ success: false, error: "workflow offline" });
|
||||
request.mockRejectedValueOnce("disconnected");
|
||||
await expect(tools.manage_clip_effects_uxp.handler({ action: "catalog" }))
|
||||
.resolves.toEqual({ success: false, error: "disconnected" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
import { getUxpNextWorkflowTools } from "../../src/tools/uxp-next-workflows.js";
|
||||
|
||||
describe("next-wave UXP MCP tools", () => {
|
||||
it("publishes a closed and bounded event receipt schema", () => {
|
||||
const bridge = { request: vi.fn(), getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).inspect_premiere_events_uxp;
|
||||
expect(tool.description).toContain("timeline.snap.*");
|
||||
expect(tool.description).toContain("operation.clip.extend.reached");
|
||||
expect(tool.description).toContain("raw event payloads are never returned");
|
||||
expect(tool.parameters).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["list", "wait"] },
|
||||
after_revision: { maximum: Number.MAX_SAFE_INTEGER },
|
||||
categories: { maxItems: 32 },
|
||||
event_names: { maxItems: 32 },
|
||||
limit: { maximum: 256 },
|
||||
timeout_ms: { maximum: 60000 },
|
||||
},
|
||||
});
|
||||
const readiness = getUxpNextWorkflowTools(bridge).wait_for_host_readiness_uxp;
|
||||
expect(readiness.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["snapshot", "analysis", "operation"] },
|
||||
operation_type: { enum: ["import", "export", "effect_drop", "generative_extend"] },
|
||||
after_revision: { maximum: Number.MAX_SAFE_INTEGER },
|
||||
timeout_ms: { maximum: 60000 },
|
||||
poll_max_ms: { maximum: 5000 },
|
||||
},
|
||||
});
|
||||
const projects = getUxpNextWorkflowTools(bridge).manage_project_sessions_uxp;
|
||||
expect(projects.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["list", "validate", "create", "open", "save", "save_as", "branch_copies", "close"] },
|
||||
paths: { maxItems: 16, uniqueItems: true },
|
||||
confirm_external_write: { type: "boolean" },
|
||||
confirm_discard_unsaved: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
const growing = getUxpNextWorkflowTools(bridge).manage_growing_media_uxp;
|
||||
expect(growing.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["status", "pause", "resume"] },
|
||||
lease_ms: { minimum: 1000, maximum: 600000 },
|
||||
confirm_pause: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
const checkpoints = getUxpNextWorkflowTools(bridge).manage_workflow_checkpoints_uxp;
|
||||
expect(checkpoints.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action", "name"],
|
||||
properties: {
|
||||
action: { enum: ["has", "get", "set", "clear"] },
|
||||
owner: { enum: ["project", "sequence"] },
|
||||
value_type: { enum: ["string", "int", "float", "bool"] },
|
||||
persistence: { enum: ["session", "persistent"] },
|
||||
},
|
||||
});
|
||||
const media = getUxpNextWorkflowTools(bridge).maintain_media_health_uxp;
|
||||
expect(media.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["inspect", "refresh", "set_offline", "find_by_media_path"] },
|
||||
project_item_ids: { maxItems: 64, uniqueItems: true },
|
||||
confirm_set_offline: { type: "boolean" },
|
||||
include_paths: { type: "boolean" },
|
||||
include_media_timing: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
const sourceMediaTiming = getUxpNextWorkflowTools(bridge).manage_source_media_timing_uxp;
|
||||
expect(sourceMediaTiming.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action", "project_item_id"],
|
||||
properties: {
|
||||
action: { enum: ["inspect", "set_start"] },
|
||||
project_item_id: { maxLength: 512 },
|
||||
expected_timing: {
|
||||
additionalProperties: false,
|
||||
required: ["start_seconds", "duration_seconds"],
|
||||
},
|
||||
start_seconds: { maximum: 86400000 },
|
||||
confirm_set_start: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
const sourceMediaOverrides = getUxpNextWorkflowTools(bridge).manage_source_media_overrides_uxp;
|
||||
expect(sourceMediaOverrides.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action", "project_item_id"],
|
||||
properties: {
|
||||
action: { enum: ["inspect", "update"] },
|
||||
project_item_id: { maxLength: 512 },
|
||||
expected_overrides: {
|
||||
additionalProperties: false,
|
||||
required: ["project_guid", "frame_rate", "pixel_aspect_ratio"],
|
||||
},
|
||||
frame_rate: { minimum: 1, maximum: 240 },
|
||||
pixel_aspect_ratio: {
|
||||
additionalProperties: false,
|
||||
required: ["numerator", "denominator"],
|
||||
},
|
||||
confirm_media_interpretation: { type: "boolean" },
|
||||
operation_id: { pattern: "^[A-Za-z0-9._:-]{1,128}$" },
|
||||
},
|
||||
});
|
||||
const tracks = getUxpNextWorkflowTools(bridge).manage_track_state_uxp;
|
||||
expect(tracks.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action"],
|
||||
properties: {
|
||||
action: { enum: ["inspect", "set_mute"] },
|
||||
media_type: { enum: ["all", "video", "audio", "caption"] },
|
||||
track_indices: { maxItems: 64, uniqueItems: true },
|
||||
muted: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
const source = getUxpNextWorkflowTools(bridge).manage_source_clip_uxp;
|
||||
expect(source.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
required: ["action", "items"],
|
||||
properties: {
|
||||
action: { enum: ["inspect", "update"] },
|
||||
items: {
|
||||
maxItems: 64,
|
||||
items: {
|
||||
additionalProperties: false,
|
||||
required: ["project_item_id"],
|
||||
properties: {
|
||||
media_type: { enum: ["video", "audio"] },
|
||||
scale_to_frame: { enum: [true] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps snake-case event queries to the exact bridge commands", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ events: [] });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).inspect_premiere_events_uxp;
|
||||
await tool.handler({
|
||||
action: "wait", after_revision: 7, categories: ["encoder"],
|
||||
event_names: ["encoder.complete"], limit: 4, timeout_ms: 5000,
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("events.wait", {
|
||||
afterRevision: 7,
|
||||
categories: ["encoder"],
|
||||
eventNames: ["encoder.complete"],
|
||||
limit: 4,
|
||||
timeoutMs: 5000,
|
||||
}, { minimumTimeoutMs: 10000 });
|
||||
|
||||
const readiness = getUxpNextWorkflowTools(bridge).wait_for_host_readiness_uxp;
|
||||
await readiness.handler({
|
||||
action: "analysis", sequence_id: "sequence-1", expected_sequence_id: "sequence-1",
|
||||
timeout_ms: 10000, poll_min_ms: 100, poll_max_ms: 1000,
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("readiness.analysis.wait", {
|
||||
sequenceId: "sequence-1", expectedSequenceId: "sequence-1",
|
||||
timeoutMs: 10000, pollMinMs: 100, pollMaxMs: 1000,
|
||||
}, { minimumTimeoutMs: 15000 });
|
||||
|
||||
await readiness.handler({
|
||||
action: "operation", operation_type: "effect_drop", after_revision: 9, timeout_ms: 5000,
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("readiness.operation.wait", {
|
||||
operationType: "effectDrop", afterRevision: 9, timeoutMs: 5000,
|
||||
}, { minimumTimeoutMs: 10000 });
|
||||
|
||||
const projects = getUxpNextWorkflowTools(bridge).manage_project_sessions_uxp;
|
||||
await projects.handler({
|
||||
action: "branch_copies", project_id: "project-1", expected_path: "C:/work/source.prproj",
|
||||
paths: ["C:/work/a.prproj", "C:/work/b.prproj"], confirm_external_write: true,
|
||||
confirm_overwrite: false, operation_id: "branches-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.branchCopies", {
|
||||
projectId: "project-1", expectedPath: "C:/work/source.prproj", operationId: "branches-1",
|
||||
paths: ["C:/work/a.prproj", "C:/work/b.prproj"],
|
||||
confirmExternalWrite: true, confirmOverwrite: false,
|
||||
});
|
||||
|
||||
const growing = getUxpNextWorkflowTools(bridge).manage_growing_media_uxp;
|
||||
await growing.handler({
|
||||
action: "pause", project_id: "project-1", expected_path: "C:/work/source.prproj",
|
||||
lease_ms: 30000, confirm_pause: true, operation_id: "pause-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("growing.pause", {
|
||||
projectId: "project-1", expectedPath: "C:/work/source.prproj",
|
||||
leaseMs: 30000, confirmPause: true, operationId: "pause-1",
|
||||
});
|
||||
|
||||
const checkpoints = getUxpNextWorkflowTools(bridge).manage_workflow_checkpoints_uxp;
|
||||
await checkpoints.handler({
|
||||
action: "set", owner: "sequence", sequence_id: "sequence-1", expected_owner_id: "sequence-1",
|
||||
name: "render.pass", value_type: "int", value: 3, persistence: "persistent", operation_id: "checkpoint-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("checkpoint.set", {
|
||||
owner: "sequence", sequenceId: "sequence-1", expectedOwnerId: "sequence-1",
|
||||
name: "render.pass", valueType: "int", value: 3, persistence: "persistent", operationId: "checkpoint-1",
|
||||
});
|
||||
|
||||
const media = getUxpNextWorkflowTools(bridge).maintain_media_health_uxp;
|
||||
await media.handler({
|
||||
action: "set_offline", project_item_ids: ["clip-1", "clip-2"],
|
||||
expected_offline: false, confirm_set_offline: true, operation_id: "offline-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("media.health.setOffline", {
|
||||
projectItemIds: ["clip-1", "clip-2"], expectedOffline: false,
|
||||
confirmSetOffline: true, operationId: "offline-1",
|
||||
});
|
||||
|
||||
const sourceMediaTiming = getUxpNextWorkflowTools(bridge).manage_source_media_timing_uxp;
|
||||
await sourceMediaTiming.handler({ action: "set_start", project_item_id: "clip-1", start_seconds: 12,
|
||||
expected_timing: { start_seconds: 10, duration_seconds: 60 }, confirm_set_start: true, operation_id: "source-time-1" });
|
||||
expect(request).toHaveBeenLastCalledWith("source.mediaTiming.setStart", {
|
||||
projectItemId: "clip-1", expectedTiming: { startSeconds: 10, durationSeconds: 60 },
|
||||
startSeconds: 12, confirmSetStart: true, operationId: "source-time-1",
|
||||
});
|
||||
|
||||
const sourceMediaOverrides = getUxpNextWorkflowTools(bridge).manage_source_media_overrides_uxp;
|
||||
await sourceMediaOverrides.handler({
|
||||
action: "update", project_item_id: "clip-1",
|
||||
expected_overrides: { project_guid: "project-1", frame_rate: 23.976, pixel_aspect_ratio: 1 },
|
||||
frame_rate: 25, pixel_aspect_ratio: { numerator: 4, denominator: 3 },
|
||||
confirm_media_interpretation: true, operation_id: "source-override-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("source.mediaOverrides.update", {
|
||||
projectItemId: "clip-1",
|
||||
expectedOverrides: { projectGuid: "project-1", frameRate: 23.976, pixelAspectRatio: 1 },
|
||||
frameRate: 25, pixelAspectRatio: { numerator: 4, denominator: 3 },
|
||||
confirmMediaInterpretation: true, operationId: "source-override-1",
|
||||
});
|
||||
|
||||
const tracks = getUxpNextWorkflowTools(bridge).manage_track_state_uxp;
|
||||
await tracks.handler({
|
||||
action: "set_mute", sequence_id: "sequence-1", expected_sequence_id: "sequence-1",
|
||||
media_type: "caption", track_indices: [0], muted: true, expected_muted: false, operation_id: "mute-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("track.state.set", {
|
||||
sequenceId: "sequence-1", expectedSequenceId: "sequence-1",
|
||||
mediaType: "caption", trackIndices: [0], muted: true, expectedMuted: false, operationId: "mute-1",
|
||||
});
|
||||
|
||||
const source = getUxpNextWorkflowTools(bridge).manage_source_clip_uxp;
|
||||
await source.handler({
|
||||
action: "update",
|
||||
items: [{
|
||||
project_item_id: "clip-1", media_type: "video", expected_in_seconds: 1,
|
||||
in_seconds: 2, out_seconds: 8, scale_to_frame: true,
|
||||
}],
|
||||
operation_id: "source-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("source.clip.update", {
|
||||
items: [{
|
||||
projectItemId: "clip-1", mediaType: "video", expectedInSeconds: 1,
|
||||
inSeconds: 2, outSeconds: 8, scaleToFrame: true,
|
||||
}],
|
||||
operationId: "source-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("covers bounded event and readiness fallbacks without hiding bridge errors", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tools = getUxpNextWorkflowTools(bridge);
|
||||
|
||||
await tools.inspect_premiere_events_uxp.handler({ action: "list" });
|
||||
expect(request).toHaveBeenLastCalledWith("events.list", {});
|
||||
await tools.inspect_premiere_events_uxp.handler({
|
||||
action: "list",
|
||||
after_revision: 0,
|
||||
categories: [],
|
||||
event_names: [],
|
||||
limit: 1,
|
||||
timeout_ms: 10,
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("events.list", {
|
||||
afterRevision: 0,
|
||||
categories: [],
|
||||
eventNames: [],
|
||||
limit: 1,
|
||||
});
|
||||
await expect(tools.inspect_premiere_events_uxp.handler({ action: "unsupported" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported event action: unsupported" });
|
||||
|
||||
request.mockRejectedValueOnce(new Error("bridge unavailable"));
|
||||
await expect(tools.inspect_premiere_events_uxp.handler({ action: "wait" }))
|
||||
.resolves.toEqual({ success: false, error: "bridge unavailable" });
|
||||
request.mockRejectedValueOnce("plain failure");
|
||||
await expect(tools.inspect_premiere_events_uxp.handler({ action: "wait", timeout_ms: 0 }))
|
||||
.resolves.toEqual({ success: false, error: "plain failure" });
|
||||
|
||||
await tools.wait_for_host_readiness_uxp.handler({ action: "snapshot" });
|
||||
expect(request).toHaveBeenLastCalledWith("readiness.snapshot", {});
|
||||
await tools.wait_for_host_readiness_uxp.handler({ action: "snapshot", sequence_id: "sequence-1" });
|
||||
expect(request).toHaveBeenLastCalledWith("readiness.snapshot", { sequenceId: "sequence-1" });
|
||||
await tools.wait_for_host_readiness_uxp.handler({ action: "analysis" });
|
||||
expect(request).toHaveBeenLastCalledWith(
|
||||
"readiness.analysis.wait", {}, { minimumTimeoutMs: 35000 },
|
||||
);
|
||||
await tools.wait_for_host_readiness_uxp.handler({ action: "operation" });
|
||||
expect(request).toHaveBeenLastCalledWith(
|
||||
"readiness.operation.wait", {}, { minimumTimeoutMs: 35000 },
|
||||
);
|
||||
await expect(tools.wait_for_host_readiness_uxp.handler({ action: "unsupported" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported readiness action: unsupported" });
|
||||
});
|
||||
|
||||
it("covers every project-session route and its optional argument mappings", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).manage_project_sessions_uxp;
|
||||
|
||||
await tool.handler({ action: "list" });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.list", {});
|
||||
await tool.handler({ action: "list", include_paths: false });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.list", { includePaths: false });
|
||||
await tool.handler({ action: "validate", path: "C:/work/source.prproj" });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.validate", { path: "C:/work/source.prproj" });
|
||||
|
||||
await tool.handler({ action: "create", path: "C:/work/new.prproj" });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.create", { path: "C:/work/new.prproj" });
|
||||
await tool.handler({
|
||||
action: "create", path: "C:/work/new.prproj", confirm_external_write: true,
|
||||
confirm_overwrite: false, operation_id: "create-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.create", {
|
||||
path: "C:/work/new.prproj", confirmExternalWrite: true,
|
||||
confirmOverwrite: false, operationId: "create-1",
|
||||
});
|
||||
|
||||
await tool.handler({ action: "open", path: "C:/work/open.prproj" });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.open", { path: "C:/work/open.prproj" });
|
||||
await tool.handler({
|
||||
action: "open", path: "C:/work/open.prproj", show_dialogs: false,
|
||||
add_to_mru: true, operation_id: "open-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.open", {
|
||||
path: "C:/work/open.prproj", showDialogs: false, addToMru: true, operationId: "open-1",
|
||||
});
|
||||
|
||||
await tool.handler({ action: "save" });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.save", {});
|
||||
await tool.handler({ action: "save_as", path: "C:/work/copy.prproj" });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.saveAs", { path: "C:/work/copy.prproj" });
|
||||
await tool.handler({
|
||||
action: "save_as", project_id: "project-1", expected_path: "C:/work/source.prproj",
|
||||
path: "C:/work/copy.prproj", confirm_external_write: true,
|
||||
confirm_overwrite: true, operation_id: "save-as-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.saveAs", {
|
||||
projectId: "project-1", expectedPath: "C:/work/source.prproj", operationId: "save-as-1",
|
||||
path: "C:/work/copy.prproj", confirmExternalWrite: true, confirmOverwrite: true,
|
||||
});
|
||||
|
||||
await tool.handler({ action: "branch_copies", paths: ["C:/work/branch.prproj"] });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.branchCopies", {
|
||||
paths: ["C:/work/branch.prproj"],
|
||||
});
|
||||
await tool.handler({ action: "close" });
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.close", {});
|
||||
await tool.handler({
|
||||
action: "close", project_id: "project-1", expected_path: "C:/work/source.prproj",
|
||||
save_before_close: false, confirm_close: true, confirm_discard_unsaved: true,
|
||||
operation_id: "close-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("project.sessions.close", {
|
||||
projectId: "project-1", expectedPath: "C:/work/source.prproj", operationId: "close-1",
|
||||
saveBeforeClose: false, confirmClose: true, confirmDiscardUnsaved: true,
|
||||
});
|
||||
await expect(tool.handler({ action: "unsupported" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported project-session action: unsupported" });
|
||||
});
|
||||
|
||||
it("covers growing-media status, lease defaults, resume targeting, and rejection", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).manage_growing_media_uxp;
|
||||
|
||||
await tool.handler({ action: "status" });
|
||||
expect(request).toHaveBeenLastCalledWith("growing.status", {});
|
||||
await tool.handler({ action: "pause" });
|
||||
expect(request).toHaveBeenLastCalledWith("growing.pause", {});
|
||||
await tool.handler({ action: "resume" });
|
||||
expect(request).toHaveBeenLastCalledWith("growing.resume", {});
|
||||
await tool.handler({ action: "resume", project_id: "project-1", operation_id: "resume-1" });
|
||||
expect(request).toHaveBeenLastCalledWith("growing.resume", {
|
||||
projectId: "project-1", operationId: "resume-1",
|
||||
});
|
||||
await expect(tool.handler({ action: "unsupported" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported growing-media action: unsupported" });
|
||||
});
|
||||
|
||||
it("covers checkpoint reads, typed writes, clears, and optional owner targeting", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).manage_workflow_checkpoints_uxp;
|
||||
|
||||
await tool.handler({ action: "has", name: "render.pass" });
|
||||
expect(request).toHaveBeenLastCalledWith("checkpoint.has", { name: "render.pass" });
|
||||
await tool.handler({ action: "get", name: "render.pass" });
|
||||
expect(request).toHaveBeenLastCalledWith("checkpoint.get", { name: "render.pass" });
|
||||
await tool.handler({
|
||||
action: "get", owner: "project", sequence_id: "sequence-1",
|
||||
expected_owner_id: "project-1", name: "render.pass", value_type: "int",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("checkpoint.get", {
|
||||
owner: "project", sequenceId: "sequence-1", expectedOwnerId: "project-1",
|
||||
name: "render.pass", valueType: "int",
|
||||
});
|
||||
|
||||
await tool.handler({ action: "set", name: "render.pass" });
|
||||
expect(request).toHaveBeenLastCalledWith("checkpoint.set", { name: "render.pass" });
|
||||
await tool.handler({ action: "clear", name: "render.pass" });
|
||||
expect(request).toHaveBeenLastCalledWith("checkpoint.clear", { name: "render.pass" });
|
||||
await tool.handler({ action: "clear", name: "render.pass", operation_id: "clear-1" });
|
||||
expect(request).toHaveBeenLastCalledWith("checkpoint.clear", {
|
||||
name: "render.pass", operationId: "clear-1",
|
||||
});
|
||||
await expect(tool.handler({ action: "unsupported", name: "render.pass" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported checkpoint action: unsupported" });
|
||||
});
|
||||
|
||||
it("covers every media-health route with redacted defaults and explicit options", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).maintain_media_health_uxp;
|
||||
|
||||
await tool.handler({ action: "inspect" });
|
||||
expect(request).toHaveBeenLastCalledWith("media.health.inspect", {});
|
||||
await tool.handler({ action: "inspect", project_item_ids: ["clip-1"], include_paths: false, include_media_timing: true });
|
||||
expect(request).toHaveBeenLastCalledWith("media.health.inspect", {
|
||||
projectItemIds: ["clip-1"], includePaths: false, includeMediaTiming: true,
|
||||
});
|
||||
await tool.handler({ action: "refresh" });
|
||||
expect(request).toHaveBeenLastCalledWith("media.health.refresh", {});
|
||||
await tool.handler({
|
||||
action: "refresh", project_item_ids: ["clip-1"], expected_offline: true,
|
||||
operation_id: "refresh-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("media.health.refresh", {
|
||||
projectItemIds: ["clip-1"], expectedOffline: true, operationId: "refresh-1",
|
||||
});
|
||||
await tool.handler({ action: "set_offline" });
|
||||
expect(request).toHaveBeenLastCalledWith("media.health.setOffline", {});
|
||||
await tool.handler({ action: "find_by_media_path" });
|
||||
expect(request).toHaveBeenLastCalledWith("media.health.findByPath", {});
|
||||
await tool.handler({
|
||||
action: "find_by_media_path", project_item_id: "clip-1", match_path: "C:/media/a.mov",
|
||||
ignore_subclips: true, include_paths: true,
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("media.health.findByPath", {
|
||||
projectItemId: "clip-1", matchPath: "C:/media/a.mov",
|
||||
ignoreSubclips: true, includePaths: true,
|
||||
});
|
||||
await expect(tool.handler({ action: "unsupported" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported media-health action: unsupported" });
|
||||
});
|
||||
|
||||
it("covers source-media timing inspection, guarded start mapping, and rejection", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).manage_source_media_timing_uxp;
|
||||
|
||||
await tool.handler({ action: "inspect", project_item_id: "clip-1" });
|
||||
expect(request).toHaveBeenLastCalledWith("source.mediaTiming.inspect", { projectItemId: "clip-1" });
|
||||
await tool.handler({ action: "set_start", project_item_id: "clip-1" });
|
||||
expect(request).toHaveBeenLastCalledWith("source.mediaTiming.setStart", { projectItemId: "clip-1" });
|
||||
await tool.handler({
|
||||
action: "set_start", project_item_id: "clip-1", start_seconds: 12,
|
||||
expected_timing: { start_seconds: 10, duration_seconds: 60 }, confirm_set_start: false,
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("source.mediaTiming.setStart", {
|
||||
projectItemId: "clip-1", expectedTiming: { startSeconds: 10, durationSeconds: 60 },
|
||||
startSeconds: 12, confirmSetStart: false,
|
||||
});
|
||||
await expect(tool.handler({ action: "unsupported", project_item_id: "clip-1" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported source-media timing action: unsupported" });
|
||||
});
|
||||
|
||||
it("covers source-media override inspection, exact snapshot mapping, and rejection", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).manage_source_media_overrides_uxp;
|
||||
|
||||
await tool.handler({ action: "inspect", project_item_id: "clip-1" });
|
||||
expect(request).toHaveBeenLastCalledWith("source.mediaOverrides.inspect", { projectItemId: "clip-1" });
|
||||
await tool.handler({ action: "update", project_item_id: "clip-1" });
|
||||
expect(request).toHaveBeenLastCalledWith("source.mediaOverrides.update", { projectItemId: "clip-1" });
|
||||
await tool.handler({
|
||||
action: "update", project_item_id: "clip-1",
|
||||
expected_overrides: { project_guid: "project-1", frame_rate: 23.976, pixel_aspect_ratio: 1 },
|
||||
pixel_aspect_ratio: { numerator: 4, denominator: 3 }, confirm_media_interpretation: false,
|
||||
operation_id: "source-override-1",
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("source.mediaOverrides.update", {
|
||||
projectItemId: "clip-1",
|
||||
expectedOverrides: { projectGuid: "project-1", frameRate: 23.976, pixelAspectRatio: 1 },
|
||||
pixelAspectRatio: { numerator: 4, denominator: 3 },
|
||||
confirmMediaInterpretation: false, operationId: "source-override-1",
|
||||
});
|
||||
await expect(tool.handler({ action: "unsupported", project_item_id: "clip-1" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported source-media override action: unsupported" });
|
||||
});
|
||||
|
||||
it("covers track-state inspection, mute defaults, and unsupported actions", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).manage_track_state_uxp;
|
||||
|
||||
await tool.handler({ action: "inspect" });
|
||||
expect(request).toHaveBeenLastCalledWith("track.state.inspect", {});
|
||||
await tool.handler({
|
||||
action: "inspect", sequence_id: "sequence-1", expected_sequence_id: "sequence-1",
|
||||
media_type: "all", track_indices: [0, 1],
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("track.state.inspect", {
|
||||
sequenceId: "sequence-1", expectedSequenceId: "sequence-1",
|
||||
mediaType: "all", trackIndices: [0, 1],
|
||||
});
|
||||
await tool.handler({ action: "set_mute" });
|
||||
expect(request).toHaveBeenLastCalledWith("track.state.set", {});
|
||||
await expect(tool.handler({ action: "unsupported" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported track-state action: unsupported" });
|
||||
});
|
||||
|
||||
it("covers source-clip inspection, complete mappings, absent items, and rejection", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true });
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
const tool = getUxpNextWorkflowTools(bridge).manage_source_clip_uxp;
|
||||
|
||||
await tool.handler({ action: "inspect" });
|
||||
expect(request).toHaveBeenLastCalledWith("source.clip.inspect", { items: undefined });
|
||||
await tool.handler({ action: "inspect", items: [{}] });
|
||||
expect(request).toHaveBeenLastCalledWith("source.clip.inspect", { items: [{}] });
|
||||
await tool.handler({
|
||||
action: "inspect",
|
||||
items: [{
|
||||
project_item_id: "clip-1", media_type: "audio",
|
||||
expected_in_seconds: 1, expected_out_seconds: 10,
|
||||
in_seconds: 2, out_seconds: 9, clear_in_out: false, scale_to_frame: true,
|
||||
}],
|
||||
});
|
||||
expect(request).toHaveBeenLastCalledWith("source.clip.inspect", {
|
||||
items: [{
|
||||
projectItemId: "clip-1", mediaType: "audio",
|
||||
expectedInSeconds: 1, expectedOutSeconds: 10,
|
||||
inSeconds: 2, outSeconds: 9, clearInOut: false, scaleToFrame: true,
|
||||
}],
|
||||
});
|
||||
await tool.handler({ action: "update" });
|
||||
expect(request).toHaveBeenLastCalledWith("source.clip.update", { items: undefined });
|
||||
await expect(tool.handler({ action: "unsupported" }))
|
||||
.resolves.toEqual({ success: false, error: "Unsupported source-clip action: unsupported" });
|
||||
});
|
||||
});
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpObjectMaskAuditWorkflowTools } from "../../src/tools/uxp-object-mask-audit-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
describe("public documented UXP object-mask audit tool", () => {
|
||||
it("publishes a bounded read-only schema and maps only explicit audit selectors", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ projectGuid: "project-1" });
|
||||
const tool = getUxpObjectMaskAuditWorkflowTools({ request } as unknown as UxpWebSocketBridge).audit_object_masks_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
type: "object", additionalProperties: false,
|
||||
properties: {
|
||||
expected_project_guid: { minLength: 1, maxLength: 512 },
|
||||
sequence_ids: { minItems: 1, maxItems: 64, uniqueItems: true, items: { minLength: 1, maxLength: 512 } },
|
||||
},
|
||||
});
|
||||
expect(tool.description).toContain("not mask count");
|
||||
expect(tool.operationalCapability).toMatchObject({ backend: "UXP", minimumPremiereVersion: "26.3", hostVerificationRequired: true });
|
||||
|
||||
await tool.handler({ expected_project_guid: "project-1", sequence_ids: ["sequence-b", "sequence-a"] });
|
||||
await tool.handler({});
|
||||
expect(request).toHaveBeenNthCalledWith(1, "objectMask.audit", {
|
||||
expectedProjectGuid: "project-1", sequenceIds: ["sequence-b", "sequence-a"],
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "objectMask.audit", {});
|
||||
});
|
||||
});
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
import { getUxpTools } from "../../src/tools/uxp.js";
|
||||
|
||||
function createTools(result: unknown = { accepted: true }) {
|
||||
const request = vi.fn().mockResolvedValue(result);
|
||||
const bridge = { request, getState: vi.fn() } as unknown as UxpWebSocketBridge;
|
||||
return { request, tools: getUxpTools(bridge) };
|
||||
}
|
||||
|
||||
describe("UXP tool optional argument mappings", () => {
|
||||
it("keeps an explicitly empty AAF options object distinct from omitted options", async () => {
|
||||
const { request, tools } = createTools({ path: "/exports/mix.aaf" });
|
||||
|
||||
await expect(tools.export_aaf_uxp.handler({
|
||||
output_file_path: "/exports/mix.aaf",
|
||||
options: {},
|
||||
})).resolves.toEqual({
|
||||
success: true,
|
||||
data: { backend: "uxp", result: { path: "/exports/mix.aaf" } },
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("interchange.aaf.export", {
|
||||
outputFilePath: "/exports/mix.aaf",
|
||||
options: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps explicitly false AAF toggles and the optional operation id", async () => {
|
||||
const { request, tools } = createTools();
|
||||
|
||||
await tools.export_aaf_uxp.handler({
|
||||
output_file_path: "/exports/final.aaf",
|
||||
operation_id: "aaf-optional-1",
|
||||
options: {
|
||||
mixdown_video: false,
|
||||
explode_to_mono: false,
|
||||
sample_rate: 48_000,
|
||||
bits_per_sample: 24,
|
||||
embed_audio: false,
|
||||
audio_file_format: "wav",
|
||||
trim_sources: false,
|
||||
handle_frames: 0,
|
||||
video_mixdown_preset_path: "/presets/none.epr",
|
||||
render_audio_effects: false,
|
||||
interleave_without_effects: false,
|
||||
preserve_parent_folder: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenCalledWith("interchange.aaf.export", {
|
||||
outputFilePath: "/exports/final.aaf",
|
||||
operationId: "aaf-optional-1",
|
||||
options: {
|
||||
mixdownVideo: false,
|
||||
explodeToMono: false,
|
||||
sampleRate: 48_000,
|
||||
bitsPerSample: 24,
|
||||
embedAudio: false,
|
||||
audioFileFormat: "wav",
|
||||
trimSources: false,
|
||||
handleFrames: 0,
|
||||
videoMixdownPresetPath: "/presets/none.epr",
|
||||
renderAudioEffects: false,
|
||||
interleaveWithoutEffects: false,
|
||||
preserveParentFolder: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes a non-Error transcript export failure while mapping name selection", async () => {
|
||||
const { request, tools } = createTools();
|
||||
request.mockRejectedValueOnce("host disconnected");
|
||||
|
||||
await expect(tools.get_clip_transcript_uxp.handler({
|
||||
project_item_name: "Interview A",
|
||||
})).resolves.toEqual({ success: false, error: "host disconnected" });
|
||||
expect(request).toHaveBeenCalledWith("transcript.export", {
|
||||
projectItemName: "Interview A",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an invalid transcript before generating a preview token", async () => {
|
||||
const { request, tools } = createTools({ projectItemId: "clip-9" });
|
||||
|
||||
await expect(tools.preview_transcript_edit_uxp.handler({
|
||||
project_item_id: "clip-9",
|
||||
transcript_revision: `sha256:${"a".repeat(64)}`,
|
||||
deletions: [{ start_seconds: 1, end_seconds: 2 }],
|
||||
})).resolves.toEqual({
|
||||
success: false,
|
||||
error: "Premiere returned an empty transcript",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("transcript.export", { projectItemId: "clip-9" });
|
||||
});
|
||||
|
||||
it("normalizes a non-Error transcript preview export failure", async () => {
|
||||
const { request, tools } = createTools();
|
||||
request.mockRejectedValueOnce(null);
|
||||
|
||||
await expect(tools.preview_transcript_edit_uxp.handler({
|
||||
transcript_revision: `sha256:${"b".repeat(64)}`,
|
||||
deletions: [{ start_seconds: 1, end_seconds: 2 }],
|
||||
})).resolves.toEqual({ success: false, error: "null" });
|
||||
});
|
||||
|
||||
it("maps the explicit sequence marker scope to the wire protocol spelling", async () => {
|
||||
const { request, tools } = createTools();
|
||||
|
||||
await tools.list_markers_uxp.handler({ scope: "sequence" });
|
||||
expect(request).toHaveBeenCalledWith("marker.list", { scope: "sequence" });
|
||||
});
|
||||
|
||||
it("maps an explicit marker web-link read opt-in to the protocol", async () => {
|
||||
const { request, tools } = createTools();
|
||||
|
||||
await tools.list_markers_uxp.handler({ scope: "sequence", include_web_links: true });
|
||||
expect(request).toHaveBeenCalledWith("marker.list", { scope: "sequence", includeWebLinks: true });
|
||||
});
|
||||
|
||||
it("maps an explicit marker raw-color read opt-in to the protocol", async () => {
|
||||
const { request, tools } = createTools();
|
||||
|
||||
await tools.list_markers_uxp.handler({ scope: "sequence", include_color_values: true });
|
||||
expect(request).toHaveBeenCalledWith("marker.list", { scope: "sequence", includeColorValues: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpRippleDeleteWorkflowTools } from "../../src/tools/uxp-ripple-delete-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
const target = { project_item_id: "target-1", start_seconds: 0, end_seconds: 10, in_seconds: 20, out_seconds: 30, duration_seconds: 10, speed: 1, reversed: false };
|
||||
const following = { project_item_id: "following-1", start_seconds: 10, end_seconds: 20, in_seconds: 40, out_seconds: 50, duration_seconds: 10, speed: 1, reversed: false };
|
||||
|
||||
describe("public guarded contiguous track-item ripple-delete MCP tool", () => {
|
||||
it("uses closed target and successor snapshot schemas and translates snake_case apply arguments", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const tool = getUxpRippleDeleteWorkflowTools({ request } as unknown as UxpWebSocketBridge).ripple_delete_track_item_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
additionalProperties: false, required: ["action", "media_type", "track_index", "clip_index"],
|
||||
properties: { action: { enum: ["inspect", "apply"] }, expected_snapshot: { additionalProperties: false, required: ["project_guid", "sequence_id", "media_type", "track_index", "clip_index", "track_item_count", "target", "following"] } },
|
||||
});
|
||||
await tool.handler({
|
||||
action: "apply", media_type: "video", track_index: 0, clip_index: 0, confirm_ripple_delete: true, operation_id: "ripple-tool-1",
|
||||
expected_snapshot: { project_guid: "project-1", sequence_id: "sequence-1", media_type: "video", track_index: 0, clip_index: 0, track_item_count: 2, target, following },
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("trackItem.rippleDelete", {
|
||||
mediaType: "video", trackIndex: 0, clipIndex: 0, confirmRippleDelete: true, operationId: "ripple-tool-1",
|
||||
expectedSnapshot: {
|
||||
projectGuid: "project-1", sequenceId: "sequence-1", mediaType: "video", trackIndex: 0, clipIndex: 0, trackItemCount: 2,
|
||||
target: { projectItemId: "target-1", startSeconds: 0, endSeconds: 10, inSeconds: 20, outSeconds: 30, durationSeconds: 10, speed: 1, reversed: false },
|
||||
following: { projectItemId: "following-1", startSeconds: 10, endSeconds: 20, inSeconds: 40, outSeconds: 50, durationSeconds: 10, speed: 1, reversed: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not silently drop unknown fields from a reviewed successor snapshot", async () => {
|
||||
const request = vi.fn();
|
||||
const tool = getUxpRippleDeleteWorkflowTools({ request } as unknown as UxpWebSocketBridge).ripple_delete_track_item_uxp;
|
||||
await expect(tool.handler({
|
||||
action: "apply", media_type: "video", track_index: 0, clip_index: 0,
|
||||
expected_snapshot: { project_guid: "project-1", sequence_id: "sequence-1", media_type: "video", track_index: 0, clip_index: 0, track_item_count: 2, target, following: { ...following, unexpected: true } },
|
||||
})).rejects.toThrow("expected_snapshot.following has an unknown field: unexpected");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getUxpSequencePreviewFrameWorkflowTools } from "../../src/tools/uxp-sequence-preview-frame-workflows.js";
|
||||
import type { UxpWebSocketBridge } from "../../src/bridge/uxp-websocket-bridge.js";
|
||||
|
||||
const snapshot = {
|
||||
project_guid: "project-1", sequence_id: "sequence-1", preview_width: 640, preview_height: 360,
|
||||
};
|
||||
|
||||
describe("public guarded sequence preview-frame MCP tool", () => {
|
||||
it("uses a closed complete snapshot and translates the update arguments", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ outcome: "verified" });
|
||||
const tool = getUxpSequencePreviewFrameWorkflowTools({ request } as unknown as UxpWebSocketBridge).manage_sequence_preview_frame_uxp;
|
||||
expect(tool.parameters).toMatchObject({
|
||||
additionalProperties: false, required: ["action", "sequence_id"],
|
||||
properties: { action: { enum: ["inspect", "update"] }, expected_snapshot: { additionalProperties: false, required: Object.keys(snapshot) } },
|
||||
});
|
||||
await tool.handler({
|
||||
action: "update", sequence_id: "sequence-1", preview_width: 1920, preview_height: 1080,
|
||||
expected_snapshot: snapshot, confirm_set_preview_frame: true, operation_id: "preview-frame-tool-1",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("sequence.previewFrame.update", {
|
||||
sequenceId: "sequence-1", previewWidth: 1920, previewHeight: 1080, confirmSetPreviewFrame: true, operationId: "preview-frame-tool-1",
|
||||
expectedSnapshot: { projectGuid: "project-1", sequenceId: "sequence-1", previewWidth: 640, previewHeight: 360 },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not silently drop unknown reviewed fields", async () => {
|
||||
const request = vi.fn();
|
||||
const tool = getUxpSequencePreviewFrameWorkflowTools({ request } as unknown as UxpWebSocketBridge).manage_sequence_preview_frame_uxp;
|
||||
await expect(tool.handler({
|
||||
action: "update", sequence_id: "sequence-1", preview_width: 1920, preview_height: 1080,
|
||||
expected_snapshot: { ...snapshot, unexpected: true }, confirm_set_preview_frame: true, operation_id: "preview-frame-invalid",
|
||||
})).rejects.toThrow("expected_snapshot has an unknown field: unexpected");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user