@hyperframes/studio 0.8.172 → 0.8.174

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.
@@ -1,8 +1,8 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  /**
4
- * "Edit with Framey": shown where the preview server has the app or its download to offer; a press opens the project in the app,
5
- * or introduces Framey with the download while the app cannot take it or is missing.
4
+ * "Install the desktop app": shown where the preview server has the app or its download to offer. With the app ready a
5
+ * press opens the project in it; otherwise it opens the guide card (download, install, open).
6
6
  */
7
7
  import React, { act } from "react";
8
8
  import { createRoot, type Root } from "react-dom/client";
@@ -12,21 +12,39 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
12
12
 
13
13
  const showToast = vi.fn();
14
14
  vi.mock("../contexts/StudioContext", () => ({ useStudioShellContext: () => ({ showToast }) }));
15
- vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
15
+ const track = vi.fn();
16
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: track }));
17
+ const FOLDER = "/work/launch-teaser";
18
+ vi.mock("../contexts/FileManagerContext", () => ({
19
+ useFileManagerContextOptional: () => ({ projectDir: FOLDER }),
20
+ }));
21
+ const copied: string[] = [];
22
+ vi.mock("../utils/clipboard", () => ({
23
+ copyTextToClipboard: async (text: string) => (copied.push(text), true),
24
+ }));
16
25
 
17
26
  const { OpenInDesktopButton } = await import("./OpenInDesktopButton");
27
+ const { resolveTabId } = await import("../telemetry/tabId");
28
+ const tagged = (url: string) => {
29
+ const withId = new URL(url);
30
+ withId.searchParams.set("hf_cta", resolveTabId());
31
+ return withId.toString();
32
+ };
18
33
 
19
34
  const DOWNLOAD = "https://hyperframes.dev/studio/download";
20
35
  let mounted: { root: Root; host: HTMLElement } | null = null;
21
36
  let posts: number;
37
+ let postBodies: unknown[];
22
38
 
23
39
  function serve(get: Response | null, post?: unknown) {
24
40
  posts = 0;
41
+ postBodies = [];
25
42
  vi.stubGlobal(
26
43
  "fetch",
27
44
  vi.fn(async (_url: string, init?: RequestInit) => {
28
45
  if (init?.method === "POST") {
29
46
  posts += 1;
47
+ postBodies.push(JSON.parse(String(init.body ?? "null")));
30
48
  return Response.json(post);
31
49
  }
32
50
  return get ?? new Response("Not found", { status: 404 });
@@ -43,15 +61,32 @@ async function mount(): Promise<HTMLElement> {
43
61
  return host;
44
62
  }
45
63
 
46
- const downloadHref = () =>
47
- [...document.querySelectorAll("a")]
48
- .find((a) => a.textContent?.includes("Get the desktop app"))
49
- ?.getAttribute("href");
50
-
51
64
  const button = () =>
52
65
  document.querySelector<HTMLButtonElement>('[data-testid="header-open-in-desktop"]');
53
66
 
54
- beforeEach(() => showToast.mockReset());
67
+ let reveal: (() => void) | null = null;
68
+
69
+ beforeEach(() => {
70
+ showToast.mockReset();
71
+ track.mockReset();
72
+ reveal = null;
73
+ vi.stubGlobal(
74
+ "IntersectionObserver",
75
+ class {
76
+ constructor(private readonly onChange: (entries: { isIntersecting: boolean }[]) => void) {}
77
+ observe() {
78
+ reveal = () => this.onChange([{ isIntersecting: true }]);
79
+ }
80
+ disconnect() {
81
+ reveal = null;
82
+ }
83
+ },
84
+ );
85
+ });
86
+
87
+ const events = (name: string) =>
88
+ track.mock.calls.filter(([event]) => event === name).map(([, props]) => props);
89
+ const MAC = { os: "darwin", app_installed: true, download_available: true };
55
90
 
56
91
  afterEach(() => {
57
92
  vi.unstubAllGlobals();
@@ -90,16 +125,186 @@ it("opens the project in an installed app that has no download (Windows)", async
90
125
  expect(posts).toBe(1);
91
126
  });
92
127
 
93
- it("while the app cannot take a project yet, introduces Framey with the download and opens nothing", async () => {
94
- const linux = `${DOWNLOAD}?os=linux`;
95
- serve(Response.json({ available: true, handoff: false, downloadUrl: linux }), { opened: true });
128
+ /** Runs `body` with the poll and slow-download timers under test control. */
129
+ async function withFakeTimers(body: () => Promise<void>) {
130
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "setTimeout", "clearTimeout"] });
131
+ try {
132
+ await body();
133
+ } finally {
134
+ vi.useRealTimers();
135
+ }
136
+ }
137
+
138
+ const guide = () => document.querySelector<HTMLElement>('[data-testid="desktop-guide"]');
139
+ const stepState = (n: number) =>
140
+ guide()?.querySelector<HTMLElement>(`[data-step="${n}"]`)?.dataset.state ?? null;
141
+ const cardButton = (text: string) =>
142
+ [...(guide()?.querySelectorAll("button") ?? [])].find((b) => b.textContent === text) ?? null;
143
+ const noApp = { os: "darwin", app_installed: false, download_available: true };
144
+ const routeBody = (over: Record<string, unknown> = {}) => ({
145
+ ...noApp,
146
+ available: true,
147
+ handoff: false,
148
+ downloadUrl: DOWNLOAD,
149
+ installed: false,
150
+ ...over,
151
+ });
152
+
153
+ it("reads 'Install the desktop app' beside Framey, and 'Open in desktop app' once the app takes projects", async () => {
154
+ serve(Response.json(routeBody()));
155
+ await mount();
156
+ expect(button()!.textContent).toBe("Install the desktop app");
157
+ expect(button()!.querySelector("svg .hf-framey-eye")).not.toBeNull();
158
+ act(() => mounted!.root.unmount());
159
+ mounted = null;
160
+
161
+ serve(Response.json(routeBody({ handoff: true, installed: true, app_installed: true })));
162
+ await mount();
163
+ expect(button()!.textContent).toBe("Open in desktop app");
164
+ });
165
+
166
+ it("without the app, a press opens the guide at step 1 and hands nothing over", async () => {
167
+ serve(Response.json(routeBody()), { opened: true });
96
168
  await mount();
97
169
  await act(async () => button()!.click());
170
+ expect(guide()).not.toBeNull();
171
+ expect([stepState(1), stepState(2), stepState(3)]).toEqual(["now", "todo", "todo"]);
172
+ expect(cardButton("Open in desktop app")!.disabled).toBe(true);
98
173
  expect(posts).toBe(0);
99
- expect(document.body.textContent).toContain("Meet Framey");
100
- expect(downloadHref()).toBe(linux);
174
+ expect(events("desktop_cta_guide")).toEqual([{ step: "opened", ...noApp }]);
175
+ });
176
+
177
+ it("step 1 opens the tagged download in a new tab, then the button says what is left", async () => {
178
+ const opened: string[] = [];
179
+ const tab = { opener: {} as unknown };
180
+ vi.stubGlobal("open", (url: string) => (opened.push(url), tab));
181
+ serve(Response.json(routeBody()));
182
+ await mount();
183
+ await act(async () => button()!.click());
184
+ await act(async () => cardButton("Download for Mac")!.click());
185
+ expect(opened).toEqual([tagged(DOWNLOAD)]);
186
+ expect(tab.opener).toBeNull();
187
+ expect([stepState(1), stepState(2)]).toEqual(["done", "now"]);
188
+ expect(guide()!.textContent).toContain("Looking for the app…");
189
+ expect(button()!.textContent).toBe("Finish installing2 of 3");
190
+ expect(events("desktop_cta_outcome")).toEqual([{ outcome: "sent_to_download", ...noApp }]);
191
+ expect(events("desktop_cta_guide").map((e) => (e as { step: string }).step)).toEqual([
192
+ "opened",
193
+ "download_opened",
194
+ ]);
195
+ });
196
+
197
+ it("names the download when the browser blocks the new tab, and leaves step 1 to do", async () => {
198
+ vi.stubGlobal("open", () => null);
199
+ serve(Response.json(routeBody()));
200
+ await mount();
201
+ await act(async () => button()!.click());
202
+ await act(async () => cardButton("Download for Mac")!.click());
203
+ expect(showToast).toHaveBeenCalledWith(`Get the HyperFrames desktop app at ${DOWNLOAD}`, "info");
204
+ expect(stepState(1)).toBe("now");
205
+ expect(events("desktop_cta_outcome")).toEqual([
206
+ { outcome: "failed", reason: "download_tab_blocked", ...noApp },
207
+ ]);
101
208
  });
102
209
 
210
+ it("finds the install only from the preview server, asking only while the card is open", () =>
211
+ withFakeTimers(async () => {
212
+ let answer = routeBody();
213
+ let gets = 0;
214
+ vi.stubGlobal(
215
+ "fetch",
216
+ vi.fn(async (_url: string, init?: RequestInit) => {
217
+ if (init?.method === "POST") return Response.json({ opened: true });
218
+ gets += 1;
219
+ return Response.json(answer);
220
+ }),
221
+ );
222
+ vi.stubGlobal("open", () => ({ opener: null }));
223
+ await mount();
224
+ expect(gets).toBe(1);
225
+ await act(async () => vi.advanceTimersByTime(10_000));
226
+ expect(gets).toBe(1);
227
+
228
+ await act(async () => button()!.click());
229
+ await act(async () => cardButton("Download for Mac")!.click());
230
+ await act(async () => vi.advanceTimersByTime(2000));
231
+ const asked = gets;
232
+ expect(asked).toBeGreaterThan(1);
233
+ expect(events("desktop_cta_guide").map((e) => (e as { step: string }).step)).not.toContain(
234
+ "install_detected",
235
+ );
236
+
237
+ answer = routeBody({ handoff: true, installed: true });
238
+ await act(async () => vi.advanceTimersByTime(2000));
239
+ expect([stepState(2), stepState(3)]).toEqual(["done", "now"]);
240
+ expect(cardButton("Open in desktop app")!.disabled).toBe(false);
241
+ expect(button()!.textContent).toBe("Open in desktop app");
242
+ expect(guide()!.querySelector('svg[data-mood="found"]')).not.toBeNull();
243
+ const detected = events("desktop_cta_guide").filter(
244
+ (e) => (e as { step: string }).step === "install_detected",
245
+ );
246
+ expect(detected).toEqual([
247
+ { step: "install_detected", os: "darwin", app_installed: true, download_available: true },
248
+ ]);
249
+ const settled = gets;
250
+ await act(async () => vi.advanceTimersByTime(10_000));
251
+ expect(gets).toBe(settled);
252
+ }));
253
+
254
+ it("records the install once per page, even when the app goes and comes back", () =>
255
+ withFakeTimers(async () => {
256
+ let answer = routeBody();
257
+ vi.stubGlobal(
258
+ "fetch",
259
+ vi.fn(async (_url: string, init?: RequestInit) => {
260
+ if (init?.method === "POST")
261
+ return Response.json({ opened: false, reason: "not-installed", downloadUrl: DOWNLOAD });
262
+ return Response.json(answer);
263
+ }),
264
+ );
265
+ await mount();
266
+ await act(async () => button()!.click());
267
+ answer = routeBody({ handoff: true, installed: true });
268
+ await act(async () => vi.advanceTimersByTime(2000));
269
+ expect(stepState(2)).toBe("done");
270
+
271
+ // The app is removed: the hand-off finds it gone, and the server stops reporting it.
272
+ answer = routeBody();
273
+ await act(async () => cardButton("Open in desktop app")!.click());
274
+ expect(stepState(2)).toBe("todo");
275
+ // Installed again: the card finds it a second time.
276
+ answer = routeBody({ handoff: true, installed: true });
277
+ await act(async () => vi.advanceTimersByTime(2000));
278
+ expect(stepState(2)).toBe("done");
279
+
280
+ const detected = events("desktop_cta_guide").filter(
281
+ (e) => (e as { step: string }).step === "install_detected",
282
+ );
283
+ expect(detected).toHaveLength(1);
284
+ }));
285
+
286
+ it("stops asking the preview server when the card closes", () =>
287
+ withFakeTimers(async () => {
288
+ let gets = 0;
289
+ vi.stubGlobal(
290
+ "fetch",
291
+ vi.fn(async () => ((gets += 1), Response.json(routeBody()))),
292
+ );
293
+ await mount();
294
+ await act(async () => button()!.click());
295
+ await act(async () => vi.advanceTimersByTime(4000));
296
+ expect(gets).toBeGreaterThan(2);
297
+ await act(async () => button()!.click());
298
+ expect(guide()).toBeNull();
299
+ const closedAt = gets;
300
+ await act(async () => vi.advanceTimersByTime(10_000));
301
+ expect(gets).toBe(closedAt);
302
+ expect(events("desktop_cta_guide").map((e) => (e as { step: string }).step)).toEqual([
303
+ "opened",
304
+ "closed",
305
+ ]);
306
+ }));
307
+
103
308
  it("opens the project in the app and says so", async () => {
104
309
  serve(Response.json({ available: true, handoff: true, downloadUrl: DOWNLOAD }), {
105
310
  opened: true,
@@ -108,23 +313,200 @@ it("opens the project in the app and says so", async () => {
108
313
  await mount();
109
314
  await act(async () => button()!.click());
110
315
  expect(posts).toBe(1);
316
+ expect(postBodies).toEqual([{ cta: resolveTabId() }]);
317
+ expect(guide()).toBeNull();
111
318
  expect(showToast).toHaveBeenCalledWith(
112
- "Framey is opening this project in the HyperFrames desktop app",
319
+ "Opening this project in the HyperFrames desktop app",
113
320
  "info",
114
321
  );
115
322
  expect(button()!.dataset.opening).toBe("true");
116
- expect(document.body.textContent).not.toContain("Meet Framey");
117
323
  });
118
324
 
119
- it("introduces Framey and offers the download when the app is missing", async () => {
120
- serve(Response.json({ available: true, handoff: true, downloadUrl: DOWNLOAD }), {
325
+ it("a failed hand-off opens the card at step 3 with the folder to copy and Try again", async () => {
326
+ const opened: string[] = [];
327
+ vi.stubGlobal("open", (url: string) => (opened.push(url), {}));
328
+ copied.length = 0;
329
+ serve(Response.json(routeBody({ handoff: true, installed: true })), {
330
+ opened: false,
331
+ reason: "open-failed",
332
+ downloadUrl: DOWNLOAD,
333
+ });
334
+ await mount();
335
+ await act(async () => button()!.click());
336
+ expect(opened).toEqual([]);
337
+ expect(button()!.textContent).toBe("Couldn't open");
338
+ expect(stepState(3)).toBe("bad");
339
+ expect(guide()!.textContent).toContain("Almost there: the last step needs another try.");
340
+ expect(guide()!.textContent).not.toContain("Everything is ready");
341
+ expect(guide()!.querySelector('[data-testid="desktop-guide-folder"]')!.textContent).toBe(FOLDER);
342
+ await act(async () =>
343
+ guide()!.querySelector<HTMLButtonElement>('[aria-label="Copy folder path"]')!.click(),
344
+ );
345
+ expect(copied).toEqual([FOLDER]);
346
+ await act(async () => cardButton("Try again")!.click());
347
+ expect(posts).toBe(2);
348
+ });
349
+
350
+ it("a hand-off that finds the app gone goes back to the download step", async () => {
351
+ serve(Response.json(routeBody({ handoff: true, installed: true })), {
121
352
  opened: false,
122
353
  reason: "not-installed",
123
354
  downloadUrl: DOWNLOAD,
124
355
  });
125
356
  await mount();
126
357
  await act(async () => button()!.click());
127
- expect(showToast).not.toHaveBeenCalled();
128
- expect(document.body.textContent).toContain("Meet Framey");
129
- expect(downloadHref()).toBe(DOWNLOAD);
358
+ expect([stepState(1), stepState(3)]).toEqual(["now", "todo"]);
359
+ expect(button()!.textContent).toBe("Install the desktop app");
360
+ });
361
+
362
+ it("where the app is installed but cannot take a project (Linux), step 3 shows the folder to drop in", async () => {
363
+ const linux = { os: "linux", app_installed: true, download_available: true };
364
+ serve(
365
+ Response.json({
366
+ ...linux,
367
+ available: true,
368
+ handoff: false,
369
+ downloadUrl: `${DOWNLOAD}?os=linux`,
370
+ installed: true,
371
+ }),
372
+ { opened: true },
373
+ );
374
+ await mount();
375
+ await act(async () => button()!.click());
376
+ expect([stepState(1), stepState(2), stepState(3)]).toEqual(["done", "done", "now"]);
377
+ expect(guide()!.textContent).toContain("Found HyperFrames on this computer.");
378
+ expect(guide()!.textContent).toContain("Open HyperFrames and drop this folder in:");
379
+ expect(guide()!.querySelector('[data-testid="desktop-guide-folder"]')!.textContent).toBe(FOLDER);
380
+ expect(cardButton("Open in desktop app")).toBeNull();
381
+ expect(posts).toBe(0);
382
+ });
383
+
384
+ it("counts where the button can show, the app here, and platforms with no app", async () => {
385
+ serve(
386
+ Response.json({
387
+ ...MAC,
388
+ available: true,
389
+ handoff: true,
390
+ downloadUrl: DOWNLOAD,
391
+ installed: true,
392
+ }),
393
+ );
394
+ await mount();
395
+ expect(events("desktop_cta_eligibility")).toEqual([{ eligibility: "open_in_app", ...MAC }]);
396
+ act(() => mounted!.root.unmount());
397
+ mounted = null;
398
+
399
+ track.mockReset();
400
+ serve(
401
+ Response.json({
402
+ available: false,
403
+ handoff: false,
404
+ downloadUrl: null,
405
+ os: "win32",
406
+ installed: false,
407
+ }),
408
+ );
409
+ await mount();
410
+ expect(button()).toBeNull();
411
+ expect(events("desktop_cta_eligibility")).toEqual([
412
+ {
413
+ eligibility: "no_app_for_platform",
414
+ os: "win32",
415
+ app_installed: false,
416
+ download_available: false,
417
+ },
418
+ ]);
419
+ expect(events("desktop_cta_impression")).toEqual([]);
420
+ });
421
+
422
+ it("records one impression, only once the button is on screen", async () => {
423
+ serve(
424
+ Response.json({
425
+ ...MAC,
426
+ available: true,
427
+ handoff: true,
428
+ downloadUrl: DOWNLOAD,
429
+ installed: true,
430
+ }),
431
+ );
432
+ await mount();
433
+ expect(events("desktop_cta_impression")).toEqual([]);
434
+ act(() => reveal!());
435
+ expect(events("desktop_cta_impression")).toEqual([{ eligibility: "open_in_app", ...MAC }]);
436
+ expect(reveal).toBeNull();
437
+ });
438
+
439
+ it("records the click and its outcome for each way a hand-off ends", async () => {
440
+ const outcomes = async (post: unknown) => {
441
+ track.mockReset();
442
+ serve(
443
+ Response.json({
444
+ ...MAC,
445
+ available: true,
446
+ handoff: true,
447
+ downloadUrl: DOWNLOAD,
448
+ installed: true,
449
+ }),
450
+ post,
451
+ );
452
+ await mount();
453
+ await act(async () => button()!.click());
454
+ const result = { clicks: events("toolbar_action"), outcomes: events("desktop_cta_outcome") };
455
+ act(() => mounted!.root.unmount());
456
+ mounted = null;
457
+ return result;
458
+ };
459
+ expect(await outcomes({ opened: true })).toEqual({
460
+ clicks: [{ action: "open_in_desktop", ...MAC }],
461
+ outcomes: [{ outcome: "opened", ...MAC }],
462
+ });
463
+ expect(
464
+ (await outcomes({ opened: false, reason: "open-failed", downloadUrl: DOWNLOAD })).outcomes,
465
+ ).toEqual([{ outcome: "failed", reason: "open-failed", ...MAC }]);
466
+ expect(
467
+ (await outcomes({ opened: false, reason: "not-installed", downloadUrl: DOWNLOAD })).outcomes,
468
+ ).toEqual([{ outcome: "failed", reason: "not-installed", ...MAC }]);
469
+ });
470
+
471
+ it("records a press the preview server never answered as failed", async () => {
472
+ vi.stubGlobal(
473
+ "fetch",
474
+ vi.fn(async (_url: string, init?: RequestInit) => {
475
+ if (init?.method === "POST") throw new TypeError("network");
476
+ return Response.json({
477
+ ...MAC,
478
+ available: true,
479
+ handoff: true,
480
+ downloadUrl: DOWNLOAD,
481
+ installed: true,
482
+ });
483
+ }),
484
+ );
485
+ await mount();
486
+ await act(async () => button()!.click());
487
+ expect(events("desktop_cta_outcome")).toEqual([
488
+ { outcome: "failed", reason: "preview_server_unreachable", ...MAC },
489
+ ]);
490
+ });
491
+
492
+ it("records the download press as sent to the download, never as an install", async () => {
493
+ const linux = { os: "linux", app_installed: false, download_available: true };
494
+ vi.stubGlobal("open", () => ({ opener: null }));
495
+ serve(
496
+ Response.json({
497
+ ...linux,
498
+ available: true,
499
+ handoff: false,
500
+ downloadUrl: `${DOWNLOAD}?os=linux`,
501
+ installed: false,
502
+ }),
503
+ );
504
+ await mount();
505
+ await act(async () => button()!.click());
506
+ await act(async () => cardButton("Download for Linux")!.click());
507
+ expect(events("toolbar_action")).toEqual([{ action: "get_desktop_app", ...linux }]);
508
+ expect(events("desktop_cta_outcome")).toEqual([{ outcome: "sent_to_download", ...linux }]);
509
+ const sent = JSON.stringify(track.mock.calls);
510
+ expect(sent).not.toMatch(/"(outcome|step)":"[a-z_]*install/);
511
+ expect(sent).not.toContain("/");
130
512
  });