@hyperframes/studio 0.8.75 → 0.8.77
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player-BSDTJ9EF.js +464 -0
- package/dist/assets/{index-BlUYw1MB.js → index-2GgHjQq-.js} +163 -163
- package/dist/assets/{index-0w6iIlwZ.css → index-BWJqxeaH.css} +1 -1
- package/dist/assets/{index-BiFKcNjs.js → index-CsFCRdMo.js} +1 -1
- package/dist/assets/{index-HjexPsny.js → index-DcxWQ7Za.js} +1 -1
- package/dist/{chunk-7BP32G62.js → chunk-5VANA2TG.js} +12 -1
- package/dist/chunk-5VANA2TG.js.map +1 -0
- package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-IGPLTI5E.js} +4 -2
- package/dist/index.d.ts +34 -69
- package/dist/index.html +2 -2
- package/dist/index.js +5287 -5277
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanels.types.ts +0 -2
- package/src/components/editor/DomEditOverlay.test.ts +5 -0
- package/src/components/editor/LayersPanel.tsx +3 -14
- package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingElement.ts +13 -0
- package/src/components/editor/domEditingLayers.test.ts +27 -0
- package/src/components/editor/domEditingLayers.ts +9 -0
- package/src/components/editor/gsapLivePreview.test.ts +63 -0
- package/src/components/editor/gsapLivePreview.ts +2 -14
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
- package/src/components/editor/overlayFrameLoop.test.ts +16 -0
- package/src/components/editor/overlayFrameLoop.ts +11 -0
- package/src/components/editor/propertyPanelHelpers.ts +4 -6
- package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
- package/src/components/nle/NLEPreview.test.ts +202 -22
- package/src/components/nle/NLEPreview.tsx +65 -46
- package/src/components/nle/PreviewPoster.tsx +99 -0
- package/src/components/nle/PreviewZoomOverlay.tsx +111 -0
- package/src/components/nle/previewZoom.test.ts +42 -0
- package/src/components/nle/previewZoom.ts +33 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
- package/src/components/panels/VariablesPanel.tsx +0 -2
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
- package/src/components/sidebar/CompositionsTab.test.ts +4 -6
- package/src/components/sidebar/CompositionsTab.tsx +28 -21
- package/src/components/studioColorGradingScope.ts +0 -3
- package/src/hooks/domEditDeleteMembers.ts +0 -2
- package/src/hooks/gsapScriptCommitTypes.ts +0 -2
- package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
- package/src/hooks/keyframeCacheAstLoad.ts +2 -1
- package/src/hooks/timelineAudioGroupCreate.ts +0 -1
- package/src/hooks/timelineEditingHelpers.ts +0 -3
- package/src/hooks/timelineTimingSync.ts +0 -1
- package/src/hooks/timelineTrackVisibility.ts +0 -1
- package/src/hooks/useBlockHandlers.ts +0 -2
- package/src/hooks/useClipboard.test.ts +69 -10
- package/src/hooks/useClipboard.ts +28 -19
- package/src/hooks/useDomEditCommits.test.tsx +0 -1
- package/src/hooks/useDomEditCommits.ts +0 -4
- package/src/hooks/useDomEditWiring.ts +1 -3
- package/src/hooks/useEditHistoryActions.test.tsx +31 -14
- package/src/hooks/useEditHistoryActions.ts +12 -7
- package/src/hooks/useEditorSave.ts +0 -3
- package/src/hooks/useElementLifecycleOps.ts +0 -1
- package/src/hooks/useEnableKeyframes.test.ts +9 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useGestureRecording.test.tsx +1 -1
- package/src/hooks/useGestureRecording.ts +6 -10
- package/src/hooks/useGroupCommits.ts +0 -1
- package/src/hooks/useGsapScriptCommits.ts +0 -1
- package/src/hooks/useGsapTweenCache.element.test.tsx +39 -0
- package/src/hooks/useGsapTweenCache.ts +5 -13
- package/src/hooks/useLintModal.test.tsx +34 -1
- package/src/hooks/useLintModal.ts +32 -30
- package/src/hooks/usePersistentEditHistory.test.ts +221 -315
- package/src/hooks/usePersistentEditHistory.ts +155 -342
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
- package/src/hooks/usePreviewPersistence.ts +0 -2
- package/src/hooks/useProjectCompositionVariables.ts +1 -3
- package/src/hooks/useRazorSplit.history.test.tsx +46 -67
- package/src/hooks/useRazorSplit.test.ts +17 -47
- package/src/hooks/useRenderClipContent.test.ts +29 -0
- package/src/hooks/useRenderClipContent.ts +6 -3
- package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
- package/src/hooks/useSdkSession.ts +8 -2
- package/src/hooks/useSlideshowPersist.ts +0 -2
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useTimelineAssetDropOps.ts +0 -1
- package/src/hooks/useTimelineDeleteOps.ts +0 -1
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/hooks/useTimelineEditingTypes.ts +0 -2
- package/src/hostMount.test.tsx +53 -22
- package/src/main.tsx +3 -0
- package/src/player/components/CompositionThumbnail.tsx +14 -0
- package/src/player/components/Player.test.ts +49 -0
- package/src/player/components/Player.tsx +21 -7
- package/src/player/components/timelineClipDragCommit.test.ts +7 -39
- package/src/player/hooks/timelineDomBinding.test.ts +50 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
- package/src/player/hooks/timelineSyncHydration.ts +3 -3
- package/src/player/hooks/useShadowPreviewReload.ts +1 -0
- package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
- package/src/player/hooks/useTimelinePlayer.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/automationStoreSync.test.ts +24 -0
- package/src/player/lib/automationStoreSync.ts +3 -9
- package/src/player/lib/playbackScrub.ts +1 -1
- package/src/player/lib/timelineDOM.test.ts +28 -0
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineElementHelpers.ts +78 -9
- package/src/player/lib/timelineIframeHelpers.test.ts +49 -5
- package/src/player/lib/timelineIframeHelpers.ts +13 -12
- package/src/player/store/playerStore.test.ts +35 -1
- package/src/player/store/playerStore.ts +29 -0
- package/src/player/store/readinessSlice.ts +34 -6
- package/src/utils/blockInstaller.ts +0 -3
- package/src/utils/previewPrefetch.test.ts +22 -0
- package/src/utils/previewPrefetch.ts +14 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/razorSplitTransaction.ts +1 -1
- package/src/utils/sdkEditTransaction.ts +0 -3
- package/src/utils/studioFileHistory.test.ts +0 -9
- package/src/utils/studioFileHistory.ts +1 -5
- package/src/utils/studioUiPreferences.test.ts +9 -3
- package/src/utils/studioUiPreferences.ts +0 -20
- package/src/utils/timelineCompositionInsert.ts +0 -1
- package/dist/assets/hyperframes-player-WbBVbWuO.js +0 -464
- package/dist/chunk-7BP32G62.js.map +0 -1
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
- package/src/utils/editHistory.test.ts +0 -534
- package/src/utils/editHistory.ts +0 -236
- package/src/utils/editHistoryStorage.test.ts +0 -37
- package/src/utils/editHistoryStorage.ts +0 -99
- /package/dist/{domEditingLayers-UKMVYXT6.js.map → domEditingLayers-IGPLTI5E.js.map} +0 -0
|
@@ -1,337 +1,243 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
5
8
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from "
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
onChange: () => {},
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
await first.recordEdit({
|
|
25
|
-
label: "Move layer",
|
|
26
|
-
kind: "manual",
|
|
27
|
-
files: { "index.html": { before: "a", after: "b" } },
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
const second = await createPersistentEditHistoryController({
|
|
31
|
-
projectId: "project-1",
|
|
32
|
-
storage,
|
|
33
|
-
now: () => 200,
|
|
34
|
-
onChange: () => {},
|
|
35
|
-
});
|
|
9
|
+
createStudioApi,
|
|
10
|
+
fileContentVersion,
|
|
11
|
+
identifyFileWrite,
|
|
12
|
+
openProjectHistory,
|
|
13
|
+
type StudioApiAdapter,
|
|
14
|
+
} from "@hyperframes/studio-server";
|
|
15
|
+
import { consumeStudioWriteToken } from "../utils/studioFileVersion";
|
|
16
|
+
import { usePersistentEditHistory } from "./usePersistentEditHistory";
|
|
17
|
+
|
|
18
|
+
const cleanup: Array<() => unknown> = [];
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
for (const step of cleanup.splice(0).reverse()) await step();
|
|
22
|
+
vi.unstubAllGlobals();
|
|
23
|
+
});
|
|
36
24
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
25
|
+
function tempDir(prefix: string): string {
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
27
|
+
cleanup.push(() => rmSync(dir, { recursive: true, force: true }));
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The hook over the real history routes and engine, on a project whose index.html reads "A". */
|
|
32
|
+
async function studio({ withHistory = true } = {}) {
|
|
33
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
34
|
+
const dir = tempDir("hf-studio-history-");
|
|
35
|
+
writeFileSync(join(dir, "index.html"), "A");
|
|
36
|
+
const history = await openProjectHistory({
|
|
37
|
+
projectDir: dir,
|
|
38
|
+
historyRoot: tempDir("hf-studio-history-root-"),
|
|
40
39
|
});
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
40
|
+
cleanup.push(() => history.close());
|
|
41
|
+
const api = createStudioApi({
|
|
42
|
+
listProjects: () => [],
|
|
43
|
+
resolveProject: (id: string) => (id === "demo" ? { id, dir } : null),
|
|
44
|
+
...(withHistory && { history: () => history }),
|
|
45
|
+
} as unknown as StudioApiAdapter);
|
|
46
|
+
vi.stubGlobal("fetch", (url: string, init?: RequestInit) =>
|
|
47
|
+
api.request(url.replace(/^\/api/, ""), init),
|
|
48
|
+
);
|
|
49
|
+
let hook!: ReturnType<typeof usePersistentEditHistory>;
|
|
50
|
+
function Harness() {
|
|
51
|
+
hook = usePersistentEditHistory({ projectId: "demo" });
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const root = createRoot(document.createElement("div"));
|
|
55
|
+
await act(async () => root.render(createElement(Harness)));
|
|
56
|
+
cleanup.push(() => act(() => root.unmount()));
|
|
57
|
+
const file = () => readFileSync(join(dir, "index.html"), "utf8");
|
|
58
|
+
const save = (content: string) => writeFileSync(join(dir, "index.html"), content);
|
|
59
|
+
const readFile = async (path: string) => readFileSync(join(dir, path), "utf8");
|
|
60
|
+
return { dir, hook: () => hook, file, save, readFile };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
it("an edit Studio saved is undone and redone by the project's history, with the preview's before and after", async () => {
|
|
64
|
+
const { hook, file, save, readFile } = await studio();
|
|
65
|
+
save("B");
|
|
66
|
+
await act(() =>
|
|
67
|
+
hook().recordEdit({
|
|
68
|
+
label: "Moved Title",
|
|
69
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
|
|
73
|
+
|
|
74
|
+
const undone = await act(() => hook().undo({ readFile }));
|
|
75
|
+
expect(undone).toEqual({
|
|
76
|
+
ok: true,
|
|
77
|
+
label: "Undid: Moved Title",
|
|
78
|
+
paths: ["index.html"],
|
|
79
|
+
files: { "index.html": { previous: "B", restored: "A" } },
|
|
72
80
|
});
|
|
81
|
+
expect(file()).toBe("A");
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
async set() {
|
|
80
|
-
throw new Error("IndexedDB unavailable");
|
|
81
|
-
},
|
|
82
|
-
async delete() {},
|
|
83
|
-
};
|
|
84
|
-
const controller = await createPersistentEditHistoryController({
|
|
85
|
-
projectId: "project-1",
|
|
86
|
-
storage,
|
|
87
|
-
now: () => 100,
|
|
88
|
-
onChange: () => {},
|
|
89
|
-
});
|
|
83
|
+
await vi.waitFor(() => expect(hook().canRedo).toBe(true));
|
|
84
|
+
const redone = await act(() => hook().redo({ readFile }));
|
|
85
|
+
expect(redone).toMatchObject({ ok: true, label: "Redid: Moved Title" });
|
|
86
|
+
expect(file()).toBe("B");
|
|
87
|
+
});
|
|
90
88
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
89
|
+
it("a drag's edits under one key undo as one step, even before the drag goes idle", async () => {
|
|
90
|
+
const { hook, file, save, readFile } = await studio();
|
|
91
|
+
const writes: Array<[before: string, after: string]> = [
|
|
92
|
+
["A", "B"],
|
|
93
|
+
["B", "C"],
|
|
94
|
+
];
|
|
95
|
+
for (const [before, after] of writes) {
|
|
96
|
+
save(after);
|
|
97
|
+
await act(() =>
|
|
98
|
+
hook().recordEdit({
|
|
99
|
+
label: "Dragged Title",
|
|
100
|
+
coalesceKey: "drag",
|
|
101
|
+
coalesceMs: 60_000,
|
|
102
|
+
files: { "index.html": { before, after } },
|
|
96
103
|
}),
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
// The held drag is not in the history's list yet: only its own paths say what to read before the step.
|
|
107
|
+
expect(hook().undoLabel).toBeUndefined();
|
|
108
|
+
const undone = await act(() => hook().undo({ readFile }));
|
|
109
|
+
expect(undone).toMatchObject({
|
|
110
|
+
ok: true,
|
|
111
|
+
label: "Undid: Dragged Title",
|
|
112
|
+
files: { "index.html": { previous: "C", restored: "A" } },
|
|
100
113
|
});
|
|
114
|
+
expect(file()).toBe("A");
|
|
115
|
+
});
|
|
101
116
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
kind: "manual",
|
|
117
|
-
files: { "index.html": { before: "a", after: "b" } },
|
|
118
|
-
}),
|
|
119
|
-
store.recordEdit({
|
|
120
|
-
label: "Resize layer",
|
|
121
|
-
kind: "manual",
|
|
122
|
-
files: { "index.html": { before: "b", after: "c" } },
|
|
123
|
-
}),
|
|
124
|
-
]);
|
|
125
|
-
|
|
126
|
-
expect(store.snapshot().state.undo.map((entry) => entry.label)).toEqual([
|
|
127
|
-
"Move layer",
|
|
128
|
-
"Resize layer",
|
|
129
|
-
]);
|
|
130
|
-
});
|
|
117
|
+
it("an agent's edit made seconds before Studio's stays the agent's: Cmd+Z undoes only Studio's", async () => {
|
|
118
|
+
const { hook, file, save, readFile } = await studio();
|
|
119
|
+
save("B");
|
|
120
|
+
await act(() => hook().recordEdit({ label: "sweep", files: {} }));
|
|
121
|
+
save("C");
|
|
122
|
+
await act(() =>
|
|
123
|
+
hook().recordEdit({
|
|
124
|
+
label: "Moved Title",
|
|
125
|
+
files: { "index.html": { before: "B", after: "C" } },
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
expect(await act(() => hook().undo({ readFile }))).toMatchObject({ label: "Undid: Moved Title" });
|
|
129
|
+
expect(file()).toBe("B");
|
|
130
|
+
});
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
132
|
+
it("an undo's writes carry the write token Studio marked, so their echo is not read as an outside edit", async () => {
|
|
133
|
+
const { dir, hook, save, readFile } = await studio();
|
|
134
|
+
save("B");
|
|
135
|
+
await act(() =>
|
|
136
|
+
hook().recordEdit({
|
|
137
|
+
label: "Moved Title",
|
|
138
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
await act(() => hook().undo({ readFile }));
|
|
142
|
+
const receipt = identifyFileWrite(join(dir, "index.html"), fileContentVersion("A"));
|
|
143
|
+
expect(consumeStudioWriteToken(receipt?.writeToken ?? null)).toBe(true);
|
|
144
|
+
});
|
|
142
145
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
]);
|
|
146
|
+
it("without a history on the server an edit still saves, and there is nothing to undo", async () => {
|
|
147
|
+
const { hook, file, save, readFile } = await studio({ withHistory: false });
|
|
148
|
+
save("B");
|
|
149
|
+
await act(() =>
|
|
150
|
+
hook().recordEdit({
|
|
151
|
+
label: "Moved Title",
|
|
152
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
expect(hook().canUndo).toBe(false);
|
|
156
|
+
expect(await act(() => hook().undo({ readFile }))).toEqual({ ok: false, reason: "empty" });
|
|
157
|
+
expect(file()).toBe("B");
|
|
158
|
+
});
|
|
157
159
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
/** Answers requests to `route` with `reply` instead of the engine, every other request as before; returns the undo. */
|
|
161
|
+
function answer(route: "/history/step" | "/history/claim", status: number, reply: object | string) {
|
|
162
|
+
const real = globalThis.fetch;
|
|
163
|
+
vi.stubGlobal("fetch", (url: string, init?: RequestInit) =>
|
|
164
|
+
url.endsWith(route)
|
|
165
|
+
? Promise.resolve(
|
|
166
|
+
new Response(typeof reply === "string" ? reply : JSON.stringify(reply), { status }),
|
|
167
|
+
)
|
|
168
|
+
: real(url, init),
|
|
169
|
+
);
|
|
170
|
+
return () => vi.stubGlobal("fetch", real);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
it("a step the server could not take says why, instead of reading as nothing to undo", async () => {
|
|
174
|
+
const { hook, readFile } = await studio();
|
|
175
|
+
answer("/history/step", 500, { error: "disk full" });
|
|
176
|
+
expect(await act(() => hook().undo({ readFile }))).toEqual({
|
|
177
|
+
ok: false,
|
|
178
|
+
reason: "failed",
|
|
179
|
+
message: "disk full",
|
|
161
180
|
});
|
|
181
|
+
});
|
|
162
182
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
await store.recordEdit({
|
|
179
|
-
label: "Edit second file",
|
|
180
|
-
kind: "manual",
|
|
181
|
-
files: { "second.html": { before: "second-before", after: "second-after" } },
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
const files: Record<string, string> = {
|
|
185
|
-
"first.html": "first-after",
|
|
186
|
-
"second.html": "second-after",
|
|
187
|
-
};
|
|
188
|
-
const readPaths: string[] = [];
|
|
189
|
-
|
|
190
|
-
await Promise.all([
|
|
191
|
-
store.undo({
|
|
192
|
-
readFile: async (path) => {
|
|
193
|
-
readPaths.push(path);
|
|
194
|
-
return files[path];
|
|
195
|
-
},
|
|
196
|
-
writeFile: async (path, content) => {
|
|
197
|
-
files[path] = content;
|
|
198
|
-
},
|
|
199
|
-
}),
|
|
200
|
-
store.undo({
|
|
201
|
-
readFile: async (path) => {
|
|
202
|
-
readPaths.push(path);
|
|
203
|
-
return files[path];
|
|
204
|
-
},
|
|
205
|
-
writeFile: async (path, content) => {
|
|
206
|
-
files[path] = content;
|
|
207
|
-
},
|
|
208
|
-
}),
|
|
209
|
-
]);
|
|
210
|
-
|
|
211
|
-
expect(readPaths).toEqual(["second.html", "first.html"]);
|
|
212
|
-
expect(files).toEqual({
|
|
213
|
-
"first.html": "first-before",
|
|
214
|
-
"second.html": "second-before",
|
|
215
|
-
});
|
|
216
|
-
expect(store.snapshot().canUndo).toBe(false);
|
|
217
|
-
expect(store.snapshot().canRedo).toBe(true);
|
|
183
|
+
it("a refused step names the files that changed since its edit", async () => {
|
|
184
|
+
const { hook, save, readFile } = await studio();
|
|
185
|
+
save("B");
|
|
186
|
+
await act(() =>
|
|
187
|
+
hook().recordEdit({
|
|
188
|
+
label: "Moved Title",
|
|
189
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
|
|
193
|
+
answer("/history/step", 200, { ok: false, conflict: { files: ["index.html"], newer: [] } });
|
|
194
|
+
expect(await act(() => hook().undo({ readFile }))).toEqual({
|
|
195
|
+
ok: false,
|
|
196
|
+
reason: "content-mismatch",
|
|
197
|
+
paths: ["index.html"],
|
|
218
198
|
});
|
|
199
|
+
});
|
|
219
200
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const blocked = new Promise<void>((resolve) => {
|
|
238
|
-
release = resolve;
|
|
239
|
-
});
|
|
240
|
-
const writeFile = vi.fn(async (_path: string, content: string) => {
|
|
241
|
-
disk = content;
|
|
242
|
-
});
|
|
243
|
-
const priorMutation = serializeStudioFileMutation(writeFile, "index.html", async () => {
|
|
244
|
-
await blocked;
|
|
245
|
-
disk = "newer-edit";
|
|
246
|
-
});
|
|
247
|
-
const readFile = vi.fn(async () => disk);
|
|
248
|
-
const undo = store.undo({
|
|
249
|
-
readFile,
|
|
250
|
-
writeFile,
|
|
251
|
-
serialize: (paths, task) => serializeStudioFileMutations(writeFile, paths, task),
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
await Promise.resolve();
|
|
255
|
-
expect(readFile).not.toHaveBeenCalled();
|
|
256
|
-
release();
|
|
257
|
-
await priorMutation;
|
|
258
|
-
await expect(undo).resolves.toMatchObject({ ok: false, reason: "content-mismatch" });
|
|
259
|
-
expect(disk).toBe("newer-edit");
|
|
260
|
-
expect(writeFile).not.toHaveBeenCalled();
|
|
261
|
-
expect(store.snapshot().canUndo).toBe(true);
|
|
201
|
+
it("a claim the server refused is logged, and the write stays undoable as a change made outside", async () => {
|
|
202
|
+
const { hook, file, save, readFile } = await studio();
|
|
203
|
+
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
204
|
+
cleanup.push(() => logged.mockRestore());
|
|
205
|
+
const restoreFetch = answer("/history/claim", 500, { error: "disk full" });
|
|
206
|
+
save("B");
|
|
207
|
+
await act(() =>
|
|
208
|
+
hook().recordEdit({
|
|
209
|
+
label: "Moved Title",
|
|
210
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
expect(logged).toHaveBeenCalledWith('"Moved Title" was not recorded as your edit: disk full');
|
|
214
|
+
restoreFetch();
|
|
215
|
+
expect(await act(() => hook().undo({ readFile }))).toMatchObject({
|
|
216
|
+
ok: true,
|
|
217
|
+
label: "Undid: Changed outside the app",
|
|
262
218
|
});
|
|
219
|
+
expect(file()).toBe("A");
|
|
220
|
+
});
|
|
263
221
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
onChange: () => {},
|
|
272
|
-
});
|
|
273
|
-
await store.recordEdit({
|
|
274
|
-
label: "Move layer",
|
|
275
|
-
kind: "manual",
|
|
276
|
-
files: { "index.html": { before: "OLD", after: "NEW" } },
|
|
277
|
-
});
|
|
278
|
-
const disk: Record<string, string> = { "index.html": "NEW" };
|
|
279
|
-
const undo = await store.undo({
|
|
280
|
-
readFile: async (p) => disk[p],
|
|
281
|
-
writeFile: async (p, c) => {
|
|
282
|
-
disk[p] = c;
|
|
283
|
-
},
|
|
284
|
-
});
|
|
285
|
-
// `restored` = bytes written (the undo target), `previous` = the current live bytes.
|
|
286
|
-
expect(undo.files).toEqual({ "index.html": { previous: "NEW", restored: "OLD" } });
|
|
287
|
-
|
|
288
|
-
const redo = await store.redo({
|
|
289
|
-
readFile: async (p) => disk[p],
|
|
290
|
-
writeFile: async (p, c) => {
|
|
291
|
-
disk[p] = c;
|
|
292
|
-
},
|
|
293
|
-
});
|
|
294
|
-
expect(redo.files).toEqual({ "index.html": { previous: "OLD", restored: "NEW" } });
|
|
222
|
+
it("a step whose reply cannot be read says so, instead of throwing", async () => {
|
|
223
|
+
const { hook, readFile } = await studio();
|
|
224
|
+
answer("/history/step", 200, "<html>");
|
|
225
|
+
expect(await act(() => hook().undo({ readFile }))).toEqual({
|
|
226
|
+
ok: false,
|
|
227
|
+
reason: "failed",
|
|
228
|
+
message: "The history's reply was unreadable.",
|
|
295
229
|
});
|
|
230
|
+
});
|
|
296
231
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
label: "Edit files",
|
|
308
|
-
kind: "manual",
|
|
309
|
-
files: {
|
|
310
|
-
"first.html": { before: "first-before", after: "first-after" },
|
|
311
|
-
"second.html": { before: "second-before", after: "second-after" },
|
|
312
|
-
},
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
const files: Record<string, string> = {
|
|
316
|
-
"first.html": "first-after",
|
|
317
|
-
"second.html": "second-after",
|
|
318
|
-
};
|
|
319
|
-
const result = store.undo({
|
|
320
|
-
readFile: async (path) => files[path],
|
|
321
|
-
writeFile: async (path, content) => {
|
|
322
|
-
if (path === "second.html" && content === "second-before") {
|
|
323
|
-
throw new Error("write failed");
|
|
324
|
-
}
|
|
325
|
-
files[path] = content;
|
|
326
|
-
},
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
await expect(result).rejects.toThrow("write failed");
|
|
330
|
-
expect(files).toEqual({
|
|
331
|
-
"first.html": "first-after",
|
|
332
|
-
"second.html": "second-after",
|
|
333
|
-
});
|
|
334
|
-
expect(store.snapshot().undoLabel).toBe("Edit files");
|
|
335
|
-
expect(store.snapshot().canRedo).toBe(false);
|
|
232
|
+
it("a step that cannot reach the server says so", async () => {
|
|
233
|
+
const { hook, readFile } = await studio();
|
|
234
|
+
const real = globalThis.fetch;
|
|
235
|
+
vi.stubGlobal("fetch", (url: string, init?: RequestInit) =>
|
|
236
|
+
url.endsWith("/history/step") ? Promise.reject(new TypeError("fetch failed")) : real(url, init),
|
|
237
|
+
);
|
|
238
|
+
expect(await act(() => hook().undo({ readFile }))).toEqual({
|
|
239
|
+
ok: false,
|
|
240
|
+
reason: "failed",
|
|
241
|
+
message: "Studio could not reach its server.",
|
|
336
242
|
});
|
|
337
243
|
});
|