@netlify/spark-ui 1.31.0-alpha.3 → 1.31.0-alpha.4

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.
Files changed (92) hide show
  1. package/dist/components/index.d.ts +2 -2
  2. package/dist/components/preact/DropZone/DropZone.d.ts +21 -2
  3. package/dist/components/preact/DropZone/DropZone.js +59 -54
  4. package/dist/components/preact/DropZone/DropZone.test.d.ts +1 -0
  5. package/dist/components/preact/DropZone/index.d.ts +3 -3
  6. package/dist/components/preact/DropZone/index.js +25 -23
  7. package/dist/components/preact/DropZone/useDropDeploy.d.ts +10 -6
  8. package/dist/components/preact/DropZone/useDropDeploy.js +184 -131
  9. package/dist/components/preact/DropZone/useDropDeploy.test.d.ts +1 -0
  10. package/dist/components/preact/index.d.ts +2 -2
  11. package/dist/components/preact/index.js +25 -19
  12. package/dist/drop-core/analytics.d.ts +107 -0
  13. package/dist/drop-core/analytics.js +21 -0
  14. package/dist/drop-core/analytics.test.d.ts +1 -0
  15. package/dist/drop-core/authedApi.d.ts +55 -0
  16. package/dist/drop-core/authedApi.js +35 -0
  17. package/dist/drop-core/authedApi.test.d.ts +1 -0
  18. package/dist/drop-core/authedDropClient.js +109 -0
  19. package/dist/drop-core/authedDropClient.test.d.ts +1 -0
  20. package/dist/{components/preact/DropZone/drop-core → drop-core}/buildClient.d.ts +5 -13
  21. package/dist/drop-core/buildClient.js +84 -0
  22. package/dist/drop-core/buildClient.test.d.ts +1 -0
  23. package/dist/drop-core/buildStash.d.ts +37 -0
  24. package/dist/drop-core/buildStash.js +84 -0
  25. package/dist/drop-core/buildStash.test.d.ts +0 -0
  26. package/dist/{components/preact/DropZone/drop-core → drop-core}/deploy.d.ts +7 -0
  27. package/dist/drop-core/deploy.js +53 -0
  28. package/dist/drop-core/deploy.test.d.ts +1 -0
  29. package/dist/drop-core/detectBuild.test.d.ts +1 -0
  30. package/dist/drop-core/digest.test.d.ts +1 -0
  31. package/dist/{components/preact/DropZone/drop-core → drop-core}/errors.js +1 -1
  32. package/dist/drop-core/errors.test.d.ts +1 -0
  33. package/dist/drop-core/fileTypes.test.d.ts +1 -0
  34. package/dist/{components/preact/DropZone/drop-core → drop-core}/index.d.ts +3 -0
  35. package/dist/drop-core/index.js +51 -0
  36. package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.js +2 -2
  37. package/dist/drop-core/readFiles.test.d.ts +1 -0
  38. package/dist/{components/preact/DropZone/drop-core → drop-core}/types.d.ts +10 -0
  39. package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.js +1 -1
  40. package/dist/drop-core/zip.test.d.ts +1 -0
  41. package/package.json +4 -1
  42. package/packages/components/index.ts +19 -0
  43. package/packages/components/preact/DropZone/DropZone.test.ts +71 -0
  44. package/packages/components/preact/DropZone/DropZone.tsx +23 -2
  45. package/packages/components/preact/DropZone/index.tsx +14 -3
  46. package/packages/components/preact/DropZone/useDropDeploy.test.ts +376 -0
  47. package/packages/components/preact/DropZone/useDropDeploy.ts +297 -132
  48. package/packages/components/preact/index.ts +19 -2
  49. package/packages/drop-core/analytics.test.ts +17 -0
  50. package/packages/drop-core/analytics.ts +122 -0
  51. package/packages/drop-core/authedApi.test.ts +100 -0
  52. package/packages/drop-core/authedApi.ts +94 -0
  53. package/packages/drop-core/authedDropClient.test.ts +196 -0
  54. package/packages/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.ts +30 -51
  55. package/packages/drop-core/buildClient.test.ts +112 -0
  56. package/packages/{components/preact/DropZone/drop-core → drop-core}/buildClient.ts +28 -57
  57. package/packages/drop-core/buildStash.test.ts +85 -0
  58. package/packages/drop-core/buildStash.ts +185 -0
  59. package/packages/drop-core/deploy.test.ts +135 -0
  60. package/packages/{components/preact/DropZone/drop-core → drop-core}/deploy.ts +18 -4
  61. package/packages/drop-core/detectBuild.test.ts +139 -0
  62. package/packages/drop-core/digest.test.ts +24 -0
  63. package/packages/drop-core/errors.test.ts +69 -0
  64. package/packages/{components/preact/DropZone/drop-core → drop-core}/errors.ts +1 -1
  65. package/packages/drop-core/fileTypes.test.ts +37 -0
  66. package/packages/{components/preact/DropZone/drop-core → drop-core}/index.ts +3 -0
  67. package/packages/drop-core/readFiles.test.ts +125 -0
  68. package/packages/{components/preact/DropZone/drop-core → drop-core}/types.ts +12 -6
  69. package/packages/drop-core/zip.test.ts +92 -0
  70. package/dist/components/preact/DropZone/drop-core/authedDropClient.js +0 -123
  71. package/dist/components/preact/DropZone/drop-core/buildClient.js +0 -99
  72. package/dist/components/preact/DropZone/drop-core/deploy.js +0 -48
  73. package/dist/components/preact/DropZone/drop-core/index.js +0 -36
  74. /package/dist/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.d.ts +0 -0
  75. /package/dist/{components/preact/DropZone/drop-core → drop-core}/client.d.ts +0 -0
  76. /package/dist/{components/preact/DropZone/drop-core → drop-core}/client.js +0 -0
  77. /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.d.ts +0 -0
  78. /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.js +0 -0
  79. /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.d.ts +0 -0
  80. /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.js +0 -0
  81. /package/dist/{components/preact/DropZone/drop-core → drop-core}/errors.d.ts +0 -0
  82. /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.d.ts +0 -0
  83. /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.js +0 -0
  84. /package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.d.ts +0 -0
  85. /package/dist/{components/preact/DropZone/drop-core → drop-core}/types.js +0 -0
  86. /package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.d.ts +0 -0
  87. /package/packages/{components/preact/DropZone/drop-core → drop-core}/client.ts +0 -0
  88. /package/packages/{components/preact/DropZone/drop-core → drop-core}/detectBuild.ts +0 -0
  89. /package/packages/{components/preact/DropZone/drop-core → drop-core}/digest.ts +0 -0
  90. /package/packages/{components/preact/DropZone/drop-core → drop-core}/fileTypes.ts +0 -0
  91. /package/packages/{components/preact/DropZone/drop-core → drop-core}/readFiles.ts +0 -0
  92. /package/packages/{components/preact/DropZone/drop-core → drop-core}/zip.ts +0 -0
@@ -0,0 +1,71 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { h, render } from 'preact';
4
+ import DropZone from './DropZone';
5
+
6
+ function mountZone(props: Record<string, unknown> = {}) {
7
+ const events: Array<{ event: string; props: Record<string, unknown> | undefined }> = [];
8
+ const container = document.createElement('div');
9
+ document.body.appendChild(container);
10
+ render(
11
+ h(DropZone as never, {
12
+ onTrack: (event: string, properties?: Record<string, unknown>) =>
13
+ void events.push({ event, props: properties }),
14
+ ...props,
15
+ }),
16
+ container
17
+ );
18
+ const zone = container.querySelector<HTMLElement>('.n-dropzone')!;
19
+ return {
20
+ events,
21
+ zone,
22
+ unmount: () => {
23
+ render(null, container);
24
+ container.remove();
25
+ },
26
+ };
27
+ }
28
+
29
+ afterEach(() => vi.restoreAllMocks());
30
+
31
+ describe('DropZone', () => {
32
+ it('opens the picker from the keyboard and tracks it as such', () => {
33
+ const { zone, events, unmount } = mountZone();
34
+ zone.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
35
+ expect(events).toEqual([{ event: 'dropzone_browse_opened', props: { method: 'keyboard' } }]);
36
+ unmount();
37
+ });
38
+
39
+ it("opens the picker from a click on the zone's own empty area", () => {
40
+ const { zone, events, unmount } = mountZone();
41
+ zone.dispatchEvent(new MouseEvent('click', { bubbles: true }));
42
+ expect(events).toEqual([{ event: 'dropzone_browse_opened', props: { method: 'click' } }]);
43
+ unmount();
44
+ });
45
+
46
+ it('stamps dropzone_id onto every event when analyticsId is set — and only then', () => {
47
+ const tagged = mountZone({ analyticsId: 'homepage_hero' });
48
+ tagged.zone.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
49
+ expect(tagged.events[0].props).toEqual({ dropzone_id: 'homepage_hero', method: 'keyboard' });
50
+ tagged.unmount();
51
+
52
+ const untagged = mountZone();
53
+ untagged.zone.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
54
+ expect(untagged.events[0].props).not.toHaveProperty('dropzone_id');
55
+ untagged.unmount();
56
+ });
57
+
58
+ it('mirrors the deploy status on data-state for bare-mode consumers', () => {
59
+ const { zone, unmount } = mountZone({ showStatus: false });
60
+ // Idle at rest — the attribute CSS keys off (`[data-state='error']` etc.).
61
+ expect(zone.getAttribute('data-state')).toBe('idle');
62
+ expect(zone.className).toContain('is-bare');
63
+ unmount();
64
+ });
65
+
66
+ it('renders no overlay until there is something to show', () => {
67
+ const { zone, unmount } = mountZone();
68
+ expect(zone.querySelector('.n-dropzone-overlay')).toBeNull();
69
+ unmount();
70
+ });
71
+ });
@@ -1,13 +1,14 @@
1
1
  import { useEffect, useRef, useState } from 'preact/hooks';
2
2
  import type { ComponentChildren } from 'preact';
3
3
  import {
4
+ DROPZONE_EVENTS,
4
5
  getFileTypes,
5
6
  readDataTransfer,
6
7
  readFileList,
7
8
  type BuildClient,
8
9
  type BuildDetection,
9
10
  type DropClient,
10
- } from './drop-core';
11
+ } from '../../../drop-core';
11
12
  import {
12
13
  useDropDeploy,
13
14
  type DeployedBuild,
@@ -123,6 +124,22 @@ interface Props {
123
124
  * redirects. The static drop path is unaffected either way.
124
125
  */
125
126
  onBuildRequired?: (info: BuildDetection) => void;
127
+ /**
128
+ * Called instead of the `buildSignupUrl` redirect when a build-required drop
129
+ * can't be built here (no session). By this point the drop has been stashed
130
+ * on this origin (`stashed` says whether that succeeded) — own the hand-off
131
+ * however fits your auth flow, e.g. open a signup popup that keeps the
132
+ * visitor on the page. Once they're back authenticated, a remount of the
133
+ * zone deploys the stash automatically.
134
+ */
135
+ onBuildSignupHandoff?: (info: { stashed: boolean; detection: BuildDetection }) => void;
136
+ /**
137
+ * Deploy a previously stashed build drop automatically when the zone mounts
138
+ * with an authenticated visitor (default `true`). The stash is written when a
139
+ * logged-out build drop is handed to signup; see
140
+ * [Signup hand-off and auto-resume](#signup-hand-off-and-auto-resume).
141
+ */
142
+ resumeStashedBuilds?: boolean;
126
143
  /** Called once the deploy is live. */
127
144
  onDeploy?: (result: DeployedSite) => void;
128
145
  /**
@@ -192,6 +209,8 @@ export default function DropZone({
192
209
  buildDeployUrl = defaultBuildDeployUrl,
193
210
  waitForBuild,
194
211
  onBuildRequired,
212
+ onBuildSignupHandoff,
213
+ resumeStashedBuilds,
195
214
  onDeploy,
196
215
  onBuildDeploy,
197
216
  onError,
@@ -219,6 +238,8 @@ export default function DropZone({
219
238
  buildDeployUrl,
220
239
  waitForBuild,
221
240
  onBuildRequired,
241
+ onBuildSignupHandoff,
242
+ resumeStashedBuilds,
222
243
  onDeploy,
223
244
  onBuildDeploy,
224
245
  onError,
@@ -229,7 +250,7 @@ export default function DropZone({
229
250
 
230
251
  const inputRef = useRef<HTMLInputElement>(null);
231
252
  const openPicker = (method: 'keyboard' | 'click') => {
232
- track('dropzone_browse_opened', { method });
253
+ track(DROPZONE_EVENTS.BROWSE_OPENED, { method });
233
254
  inputRef.current?.click();
234
255
  };
235
256
 
@@ -1,6 +1,7 @@
1
1
  export { default as DropZone } from './DropZone';
2
2
  export {
3
3
  AnonymousDropClient,
4
+ DROPZONE_EVENTS,
4
5
  AuthenticatedBuildClient,
5
6
  AuthenticatedDropClient,
6
7
  BuildCreateError,
@@ -18,9 +19,13 @@ export {
18
19
  SiteCreateError,
19
20
  UnsupportedDropError,
20
21
  zipFiles,
21
- } from './drop-core';
22
+ } from '../../../drop-core';
22
23
  export type {
23
24
  AuthenticatedBuildClientConfig,
25
+ DropMethod,
26
+ DropZoneEventName,
27
+ DropZoneEventProperties,
28
+ TrackFn,
24
29
  AuthenticatedDropClientConfig,
25
30
  BuildClient,
26
31
  BuildDeployOptions,
@@ -39,5 +44,11 @@ export type {
39
44
  ProcessedFile,
40
45
  ZipOptions,
41
46
  ZipResult,
42
- } from './drop-core';
43
- export type { DeployedBuild, DeployedSite, DeployError, DeployStatus } from './useDropDeploy';
47
+ } from '../../../drop-core';
48
+ export type {
49
+ DeployedBuild,
50
+ DeployedSite,
51
+ DeployError,
52
+ DeploySource,
53
+ DeployStatus,
54
+ } from './useDropDeploy';
@@ -0,0 +1,376 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { h, render } from 'preact';
4
+ import { useDropDeploy } from './useDropDeploy';
5
+ import {
6
+ DROPZONE_EVENTS,
7
+ NotAuthenticatedError,
8
+ type BuildClient,
9
+ type DropClient,
10
+ type ProcessedFile,
11
+ } from '../../../drop-core';
12
+
13
+ // The stash is unit-tested in drop-core/buildStash.test.ts; here it is mocked
14
+ // so the hook's orchestration (when it saves, loads, and clears) is what's
15
+ // under test — and so no IndexedDB shim is needed.
16
+ vi.mock('../../../drop-core', async importOriginal => {
17
+ const original = await importOriginal<typeof import('../../../drop-core')>();
18
+ return {
19
+ ...original,
20
+ saveDropStash: vi.fn(async () => true),
21
+ loadDropStash: vi.fn(async () => null),
22
+ clearDropStash: vi.fn(async () => {}),
23
+ hasDropStashMarker: vi.fn(() => false),
24
+ };
25
+ });
26
+ import { clearDropStash, hasDropStashMarker, loadDropStash, saveDropStash } from '../../../drop-core';
27
+
28
+ const encoder = new TextEncoder();
29
+ const file = (path: string, contents: string): ProcessedFile => {
30
+ const content = encoder.encode(contents).buffer as ArrayBuffer;
31
+ return { path, content, size: content.byteLength, sha: 'test-sha', name: path.split('/').pop()! };
32
+ };
33
+
34
+ const staticFiles = () => [file('/index.html', '<h1>hi</h1>'), file('/style.css', 'body{}')];
35
+ const buildFiles = () => [
36
+ file('/package.json', '{"scripts":{"build":"vite build"}}'),
37
+ file('/index.html', '<h1>hi</h1>'),
38
+ ];
39
+
40
+ function makeAnonymousClient(log: string[]): DropClient {
41
+ return {
42
+ getToken: async () => (log.push('anon:getToken'), 'anon-token'),
43
+ createDeploy: async () => (
44
+ log.push('anon:createDeploy'),
45
+ { id: 'anon-site-id', deploy_id: 'anon-deploy-id', subdomain: 'anon-sub', required: [] }
46
+ ),
47
+ uploadFile: async () => void log.push('anon:uploadFile'),
48
+ waitUntilReady: async () => void log.push('anon:wait'),
49
+ };
50
+ }
51
+
52
+ function makeAuthedDropClient(log: string[], behavior: 'ok' | 'no-auth' | 'server-error'): DropClient {
53
+ return {
54
+ getToken: async () => {
55
+ log.push('authed:getToken');
56
+ if (behavior === 'no-auth') throw new NotAuthenticatedError();
57
+ return 'bearer-token';
58
+ },
59
+ createDeploy: async () => {
60
+ log.push('authed:createDeploy');
61
+ if (behavior === 'server-error')
62
+ throw Object.assign(new Error('deploy create failed: 500'), { status: 500 });
63
+ return { id: 'site-uuid', deploy_id: 'deploy-bson', subdomain: 'my-account-site', required: [] };
64
+ },
65
+ uploadFile: async () => void log.push('authed:uploadFile'),
66
+ waitUntilReady: async () => void log.push('authed:wait'),
67
+ };
68
+ }
69
+
70
+ function makeBuildClient(behavior: 'ok' | 'no-auth'): BuildClient {
71
+ return {
72
+ createSite: async () => {
73
+ if (behavior === 'no-auth') throw new NotAuthenticatedError();
74
+ return { id: 'site-uuid', name: 'built-site' };
75
+ },
76
+ createBuildFromZip: async () => ({ id: 'build-id', deploy_id: 'deploy-bson' }),
77
+ waitUntilBuildReady: async () => {},
78
+ };
79
+ }
80
+
81
+ interface HarnessResult {
82
+ api: ReturnType<typeof useDropDeploy>;
83
+ events: Array<{ event: string; props: Record<string, unknown> | undefined }>;
84
+ deploys: unknown[];
85
+ buildDeploys: unknown[];
86
+ errors: Array<{ message: string; reason: string }>;
87
+ handoffs: unknown[];
88
+ assigned: string[];
89
+ unmount: () => void;
90
+ }
91
+
92
+ function mountHook(options: Partial<Parameters<typeof useDropDeploy>[0]> = {}): HarnessResult {
93
+ const result = {
94
+ events: [],
95
+ deploys: [],
96
+ buildDeploys: [],
97
+ errors: [],
98
+ handoffs: [],
99
+ assigned: [],
100
+ } as unknown as HarnessResult;
101
+
102
+ window.location.assign = ((url: string) => result.assigned.push(String(url))) as never;
103
+
104
+ function Harness() {
105
+ result.api = useDropDeploy({
106
+ claimUrl: name => `https://app.netlify.com/drop/${name}`,
107
+ siteDashboardUrl: name => `https://app.netlify.com/projects/${name}`,
108
+ buildSignupUrl: 'https://app.netlify.com/signup?next=/drop',
109
+ buildDeployUrl: build => `https://app.netlify.com/projects/${(build as { siteName: string }).siteName}`,
110
+ onDeploy: site => void result.deploys.push(site),
111
+ onBuildDeploy: build => void result.buildDeploys.push(build),
112
+ onError: error => void result.errors.push(error),
113
+ track: (event, props) => void result.events.push({ event, props }),
114
+ ...options,
115
+ });
116
+ return null;
117
+ }
118
+
119
+ const container = document.createElement('div');
120
+ render(h(Harness, {}), container);
121
+ result.unmount = () => render(null, container);
122
+ return result;
123
+ }
124
+
125
+ /** Preact effects and chained promises settle on real timers — poll briefly. */
126
+ async function waitUntil(condition: () => boolean, ms = 500) {
127
+ const start = Date.now();
128
+ while (!condition()) {
129
+ if (Date.now() - start > ms) throw new Error('condition never became true');
130
+ await new Promise(r => setTimeout(r, 5));
131
+ }
132
+ }
133
+
134
+ const settle = () => new Promise(r => setTimeout(r, 10));
135
+
136
+ beforeEach(() => {
137
+ localStorage.clear();
138
+ vi.mocked(saveDropStash).mockClear().mockResolvedValue(true);
139
+ vi.mocked(loadDropStash).mockClear().mockResolvedValue(null);
140
+ vi.mocked(clearDropStash).mockClear();
141
+ vi.mocked(hasDropStashMarker).mockClear().mockReturnValue(false);
142
+ });
143
+
144
+ afterEach(() => vi.restoreAllMocks());
145
+
146
+ describe('static drops', () => {
147
+ it('deploys into the account and lands on the dashboard — no claim artifacts anywhere', async () => {
148
+ const log: string[] = [];
149
+ const harness = mountHook({
150
+ authenticatedDropClient: makeAuthedDropClient(log, 'ok'),
151
+ client: makeAnonymousClient(log),
152
+ });
153
+ await harness.api.deploy(async () => staticFiles(), 'drag', ['text/html']);
154
+ await settle();
155
+
156
+ expect(harness.assigned).toEqual(['https://app.netlify.com/projects/my-account-site']);
157
+ expect(localStorage.getItem('drop-token')).toBeNull();
158
+ expect(localStorage.getItem('dropSiteName')).toBeNull();
159
+ expect(harness.deploys[0]).toEqual({
160
+ url: 'https://my-account-site.netlify.app',
161
+ siteName: 'my-account-site',
162
+ siteId: 'site-uuid',
163
+ authenticated: true,
164
+ });
165
+ const succeeded = harness.events.find(e => e.event === DROPZONE_EVENTS.DEPLOY_SUCCEEDED);
166
+ expect(succeeded?.props).toEqual({
167
+ method: 'drag',
168
+ site_name: 'my-account-site',
169
+ file_count: 2,
170
+ authenticated: true,
171
+ file_types: ['text/html'],
172
+ });
173
+ expect(log.some(entry => entry.startsWith('anon:'))).toBe(false);
174
+ });
175
+
176
+ it('falls back to the anonymous claim flow when the session is gone', async () => {
177
+ const log: string[] = [];
178
+ const harness = mountHook({
179
+ authenticatedDropClient: makeAuthedDropClient(log, 'no-auth'),
180
+ client: makeAnonymousClient(log),
181
+ });
182
+ await harness.api.deploy(async () => staticFiles(), 'drag', ['text/html']);
183
+ await settle();
184
+
185
+ expect(harness.assigned).toEqual(['https://app.netlify.com/drop/anon-sub#drop_token=anon-token']);
186
+ expect(localStorage.getItem('drop-token')).toBe('anon-token');
187
+ expect(localStorage.getItem('dropSiteName')).toBe('anon-sub');
188
+ const succeeded = harness.events.find(e => e.event === DROPZONE_EVENTS.DEPLOY_SUCCEEDED);
189
+ expect(succeeded?.props).toMatchObject({ authenticated: false, auth_fallback: true });
190
+ expect(log[0]).toBe('authed:getToken'); // authed tried first, then anonymous
191
+ expect(log).toContain('anon:getToken');
192
+ });
193
+
194
+ it('surfaces a real authed failure as an error — never a silent anonymous duplicate', async () => {
195
+ const log: string[] = [];
196
+ const harness = mountHook({
197
+ authenticatedDropClient: makeAuthedDropClient(log, 'server-error'),
198
+ client: makeAnonymousClient(log),
199
+ });
200
+ await harness.api.deploy(async () => staticFiles(), 'drag', []);
201
+ await settle();
202
+
203
+ expect(harness.assigned).toEqual([]);
204
+ expect(harness.api.status.kind).toBe('error');
205
+ expect(harness.errors).toHaveLength(1);
206
+ expect(harness.events.some(e => e.event === DROPZONE_EVENTS.DEPLOY_FAILED)).toBe(true);
207
+ expect(log.some(entry => entry.startsWith('anon:'))).toBe(false);
208
+ expect(harness.deploys).toHaveLength(0);
209
+ });
210
+
211
+ it('stays fully anonymous without authenticated wiring', async () => {
212
+ const harness = mountHook({ client: makeAnonymousClient([]) });
213
+ await harness.api.deploy(async () => staticFiles(), 'browse', []);
214
+ await settle();
215
+
216
+ expect(harness.assigned).toEqual(['https://app.netlify.com/drop/anon-sub#drop_token=anon-token']);
217
+ const succeeded = harness.events.find(e => e.event === DROPZONE_EVENTS.DEPLOY_SUCCEEDED);
218
+ expect(succeeded?.props).toMatchObject({ authenticated: false });
219
+ expect(succeeded?.props).not.toHaveProperty('auth_fallback');
220
+ expect(succeeded?.props).not.toHaveProperty('file_types'); // omitted, never []
221
+ });
222
+
223
+ it('reports an empty selection as a failure with file_count 0', async () => {
224
+ const harness = mountHook({ client: makeAnonymousClient([]) });
225
+ await harness.api.deploy(async () => [], 'drag', []);
226
+ expect(harness.api.status).toEqual({ kind: 'error', message: 'No files found to deploy.' });
227
+ const failed = harness.events.find(e => e.event === DROPZONE_EVENTS.DEPLOY_FAILED);
228
+ expect(failed?.props).toMatchObject({ file_count: 0 });
229
+ });
230
+
231
+ it('emits FILES_DROPPED for drags only', async () => {
232
+ const dragged = mountHook({ client: makeAnonymousClient([]) });
233
+ await dragged.api.deploy(async () => staticFiles(), 'drag', []);
234
+ expect(dragged.events.some(e => e.event === DROPZONE_EVENTS.FILES_DROPPED)).toBe(true);
235
+
236
+ const browsed = mountHook({ client: makeAnonymousClient([]) });
237
+ await browsed.api.deploy(async () => staticFiles(), 'browse', []);
238
+ expect(browsed.events.some(e => e.event === DROPZONE_EVENTS.FILES_DROPPED)).toBe(false);
239
+ });
240
+ });
241
+
242
+ describe('build-required drops', () => {
243
+ it('builds in place for an authenticated visitor and lands on the project', async () => {
244
+ const harness = mountHook({ buildClient: makeBuildClient('ok'), getUploadToken: async () => 'tok' });
245
+ await harness.api.deploy(async () => buildFiles(), 'drag', []);
246
+ await settle();
247
+
248
+ expect(harness.assigned).toEqual(['https://app.netlify.com/projects/built-site']);
249
+ expect(harness.buildDeploys).toHaveLength(1);
250
+ expect(harness.events.map(e => e.event)).toContain(DROPZONE_EVENTS.BUILD_DEPLOY_SUCCEEDED);
251
+ expect(saveDropStash).not.toHaveBeenCalled();
252
+ });
253
+
254
+ it('stashes the archive and redirects to signup when there is no session', async () => {
255
+ const harness = mountHook({ buildClient: makeBuildClient('no-auth'), getUploadToken: async () => null });
256
+ await harness.api.deploy(async () => buildFiles(), 'drag', []);
257
+ await settle();
258
+
259
+ expect(saveDropStash).toHaveBeenCalledOnce();
260
+ const [archive] = vi.mocked(saveDropStash).mock.calls[0];
261
+ expect(archive.name).toBe('source.zip');
262
+ expect(harness.events.map(e => e.event)).toContain(DROPZONE_EVENTS.PROJECT_STASHED);
263
+ expect(harness.assigned).toEqual(['https://app.netlify.com/signup?next=/drop']);
264
+ });
265
+
266
+ it('lets onBuildSignupHandoff own the hand-off instead of redirecting', async () => {
267
+ const handoffs: unknown[] = [];
268
+ const harness = mountHook({
269
+ buildClient: makeBuildClient('no-auth'),
270
+ getUploadToken: async () => null,
271
+ onBuildSignupHandoff: info => void handoffs.push(info),
272
+ });
273
+ await harness.api.deploy(async () => buildFiles(), 'drag', []);
274
+ await settle();
275
+
276
+ expect(harness.assigned).toEqual([]);
277
+ expect(handoffs).toEqual([
278
+ { stashed: true, detection: { buildRequired: true, reason: 'package-json-build-script' } },
279
+ ]);
280
+ expect(harness.api.status.kind).toBe('idle'); // zone stays usable behind the popup
281
+ });
282
+
283
+ it('redirects to signup without stashing when nothing here could resume', async () => {
284
+ const harness = mountHook({}); // no build wiring at all
285
+ await harness.api.deploy(async () => buildFiles(), 'drag', []);
286
+ await settle();
287
+
288
+ expect(saveDropStash).not.toHaveBeenCalled();
289
+ expect(harness.assigned).toEqual(['https://app.netlify.com/signup?next=/drop']);
290
+ });
291
+
292
+ it('cedes everything to onBuildRequired when set', async () => {
293
+ const required: unknown[] = [];
294
+ const harness = mountHook({
295
+ buildClient: makeBuildClient('ok'),
296
+ getUploadToken: async () => 'tok',
297
+ onBuildRequired: info => void required.push(info),
298
+ });
299
+ await harness.api.deploy(async () => buildFiles(), 'drag', []);
300
+ await settle();
301
+
302
+ expect(required).toHaveLength(1);
303
+ expect(harness.assigned).toEqual([]);
304
+ expect(harness.api.status.kind).toBe('idle');
305
+ });
306
+ });
307
+
308
+ describe('stash resume on mount', () => {
309
+ const stashRecord = () => ({
310
+ zipFile: new File(['zip'], 'source.zip', { type: 'application/zip' }),
311
+ buildSettings: null,
312
+ createdAt: Date.now(),
313
+ });
314
+
315
+ it('deploys the stash for an authenticated visitor and consumes it', async () => {
316
+ vi.mocked(hasDropStashMarker).mockReturnValue(true);
317
+ vi.mocked(loadDropStash).mockResolvedValue(stashRecord());
318
+ const harness = mountHook({ buildClient: makeBuildClient('ok'), getUploadToken: async () => 'tok' });
319
+
320
+ await waitUntil(() => harness.assigned.length > 0);
321
+ expect(harness.assigned).toEqual(['https://app.netlify.com/projects/built-site']);
322
+ expect(harness.events.map(e => e.event)).toContain(DROPZONE_EVENTS.RESUMED);
323
+ expect(clearDropStash).toHaveBeenCalled();
324
+ expect(harness.buildDeploys).toHaveLength(1);
325
+ });
326
+
327
+ it('keeps the stash when the visitor came back logged out', async () => {
328
+ vi.mocked(hasDropStashMarker).mockReturnValue(true);
329
+ vi.mocked(loadDropStash).mockResolvedValue(stashRecord());
330
+ const harness = mountHook({ buildClient: makeBuildClient('no-auth'), getUploadToken: async () => null });
331
+
332
+ await waitUntil(() => vi.mocked(loadDropStash).mock.calls.length > 0);
333
+ await settle();
334
+ expect(clearDropStash).not.toHaveBeenCalled(); // preserved for the next authenticated visit
335
+ expect(harness.api.status.kind).toBe('idle');
336
+ expect(harness.errors).toHaveLength(0);
337
+ });
338
+
339
+ it('surfaces a real resume failure with re-drop copy, stash consumed', async () => {
340
+ vi.mocked(hasDropStashMarker).mockReturnValue(true);
341
+ vi.mocked(loadDropStash).mockResolvedValue(stashRecord());
342
+ const failing = makeBuildClient('ok');
343
+ failing.createBuildFromZip = async () => {
344
+ throw new Error('boom');
345
+ };
346
+ const harness = mountHook({ buildClient: failing, getUploadToken: async () => 'tok' });
347
+
348
+ await waitUntil(() => harness.api.status.kind === 'error');
349
+ expect(harness.api.status).toMatchObject({
350
+ kind: 'error',
351
+ message: 'We couldn’t automatically deploy your project. Drop it again to publish it.',
352
+ });
353
+ const failed = harness.events.find(e => e.event === DROPZONE_EVENTS.DEPLOY_FAILED);
354
+ expect(failed?.props).toEqual({ reason: 'resume_failed' });
355
+ expect(clearDropStash).toHaveBeenCalled();
356
+ });
357
+
358
+ it('never attempts without the marker, wiring, or with resume disabled', async () => {
359
+ mountHook({ buildClient: makeBuildClient('ok'), getUploadToken: async () => 'tok' });
360
+ await settle();
361
+ expect(loadDropStash).not.toHaveBeenCalled(); // no marker
362
+
363
+ vi.mocked(hasDropStashMarker).mockReturnValue(true);
364
+ mountHook({}); // marker but no wiring
365
+ await settle();
366
+ expect(loadDropStash).not.toHaveBeenCalled();
367
+
368
+ mountHook({
369
+ buildClient: makeBuildClient('ok'),
370
+ getUploadToken: async () => 'tok',
371
+ resumeStashedBuilds: false,
372
+ });
373
+ await settle();
374
+ expect(loadDropStash).not.toHaveBeenCalled();
375
+ });
376
+ });