@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.
- package/dist/components/index.d.ts +2 -2
- package/dist/components/preact/DropZone/DropZone.d.ts +21 -2
- package/dist/components/preact/DropZone/DropZone.js +59 -54
- package/dist/components/preact/DropZone/DropZone.test.d.ts +1 -0
- package/dist/components/preact/DropZone/index.d.ts +3 -3
- package/dist/components/preact/DropZone/index.js +25 -23
- package/dist/components/preact/DropZone/useDropDeploy.d.ts +10 -6
- package/dist/components/preact/DropZone/useDropDeploy.js +184 -131
- package/dist/components/preact/DropZone/useDropDeploy.test.d.ts +1 -0
- package/dist/components/preact/index.d.ts +2 -2
- package/dist/components/preact/index.js +25 -19
- package/dist/drop-core/analytics.d.ts +107 -0
- package/dist/drop-core/analytics.js +21 -0
- package/dist/drop-core/analytics.test.d.ts +1 -0
- package/dist/drop-core/authedApi.d.ts +55 -0
- package/dist/drop-core/authedApi.js +35 -0
- package/dist/drop-core/authedApi.test.d.ts +1 -0
- package/dist/drop-core/authedDropClient.js +109 -0
- package/dist/drop-core/authedDropClient.test.d.ts +1 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/buildClient.d.ts +5 -13
- package/dist/drop-core/buildClient.js +84 -0
- package/dist/drop-core/buildClient.test.d.ts +1 -0
- package/dist/drop-core/buildStash.d.ts +37 -0
- package/dist/drop-core/buildStash.js +84 -0
- package/dist/drop-core/buildStash.test.d.ts +0 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/deploy.d.ts +7 -0
- package/dist/drop-core/deploy.js +53 -0
- package/dist/drop-core/deploy.test.d.ts +1 -0
- package/dist/drop-core/detectBuild.test.d.ts +1 -0
- package/dist/drop-core/digest.test.d.ts +1 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/errors.js +1 -1
- package/dist/drop-core/errors.test.d.ts +1 -0
- package/dist/drop-core/fileTypes.test.d.ts +1 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/index.d.ts +3 -0
- package/dist/drop-core/index.js +51 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.js +2 -2
- package/dist/drop-core/readFiles.test.d.ts +1 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/types.d.ts +10 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.js +1 -1
- package/dist/drop-core/zip.test.d.ts +1 -0
- package/package.json +4 -1
- package/packages/components/index.ts +19 -0
- package/packages/components/preact/DropZone/DropZone.test.ts +71 -0
- package/packages/components/preact/DropZone/DropZone.tsx +23 -2
- package/packages/components/preact/DropZone/index.tsx +14 -3
- package/packages/components/preact/DropZone/useDropDeploy.test.ts +376 -0
- package/packages/components/preact/DropZone/useDropDeploy.ts +297 -132
- package/packages/components/preact/index.ts +19 -2
- package/packages/drop-core/analytics.test.ts +17 -0
- package/packages/drop-core/analytics.ts +122 -0
- package/packages/drop-core/authedApi.test.ts +100 -0
- package/packages/drop-core/authedApi.ts +94 -0
- package/packages/drop-core/authedDropClient.test.ts +196 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.ts +30 -51
- package/packages/drop-core/buildClient.test.ts +112 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/buildClient.ts +28 -57
- package/packages/drop-core/buildStash.test.ts +85 -0
- package/packages/drop-core/buildStash.ts +185 -0
- package/packages/drop-core/deploy.test.ts +135 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/deploy.ts +18 -4
- package/packages/drop-core/detectBuild.test.ts +139 -0
- package/packages/drop-core/digest.test.ts +24 -0
- package/packages/drop-core/errors.test.ts +69 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/errors.ts +1 -1
- package/packages/drop-core/fileTypes.test.ts +37 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/index.ts +3 -0
- package/packages/drop-core/readFiles.test.ts +125 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/types.ts +12 -6
- package/packages/drop-core/zip.test.ts +92 -0
- package/dist/components/preact/DropZone/drop-core/authedDropClient.js +0 -123
- package/dist/components/preact/DropZone/drop-core/buildClient.js +0 -99
- package/dist/components/preact/DropZone/drop-core/deploy.js +0 -48
- package/dist/components/preact/DropZone/drop-core/index.js +0 -36
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/client.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/client.js +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.js +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.js +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/errors.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.js +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/types.js +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.d.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/client.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/detectBuild.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/digest.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/fileTypes.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/readFiles.ts +0 -0
- /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 '
|
|
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(
|
|
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 '
|
|
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 '
|
|
43
|
-
export type {
|
|
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
|
+
});
|