@netlify/spark-ui 1.31.0-alpha.2 → 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 (94) hide show
  1. package/dist/components/index.d.ts +2 -2
  2. package/dist/components/preact/DropZone/DropZone.d.ts +28 -2
  3. package/dist/components/preact/DropZone/DropZone.js +58 -51
  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 +11 -6
  8. package/dist/components/preact/DropZone/useDropDeploy.js +183 -129
  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.d.ts +100 -0
  19. package/dist/drop-core/authedDropClient.js +109 -0
  20. package/dist/drop-core/authedDropClient.test.d.ts +1 -0
  21. package/dist/{components/preact/DropZone/drop-core → drop-core}/buildClient.d.ts +32 -26
  22. package/dist/drop-core/buildClient.js +84 -0
  23. package/dist/drop-core/buildClient.test.d.ts +1 -0
  24. package/dist/drop-core/buildStash.d.ts +37 -0
  25. package/dist/drop-core/buildStash.js +84 -0
  26. package/dist/drop-core/buildStash.test.d.ts +0 -0
  27. package/dist/{components/preact/DropZone/drop-core → drop-core}/deploy.d.ts +7 -0
  28. package/dist/drop-core/deploy.js +53 -0
  29. package/dist/drop-core/deploy.test.d.ts +1 -0
  30. package/dist/drop-core/detectBuild.test.d.ts +1 -0
  31. package/dist/drop-core/digest.test.d.ts +1 -0
  32. package/dist/{components/preact/DropZone/drop-core → drop-core}/errors.js +1 -1
  33. package/dist/drop-core/errors.test.d.ts +1 -0
  34. package/dist/drop-core/fileTypes.test.d.ts +1 -0
  35. package/dist/{components/preact/DropZone/drop-core → drop-core}/index.d.ts +3 -0
  36. package/dist/drop-core/index.js +51 -0
  37. package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.js +2 -2
  38. package/dist/drop-core/readFiles.test.d.ts +1 -0
  39. package/dist/{components/preact/DropZone/drop-core → drop-core}/types.d.ts +10 -0
  40. package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.js +1 -1
  41. package/dist/drop-core/zip.test.d.ts +1 -0
  42. package/package.json +4 -1
  43. package/packages/components/index.ts +19 -0
  44. package/packages/components/preact/DropZone/DropZone.test.ts +71 -0
  45. package/packages/components/preact/DropZone/DropZone.tsx +32 -2
  46. package/packages/components/preact/DropZone/index.tsx +14 -3
  47. package/packages/components/preact/DropZone/useDropDeploy.test.ts +376 -0
  48. package/packages/components/preact/DropZone/useDropDeploy.ts +305 -134
  49. package/packages/components/preact/index.ts +19 -2
  50. package/packages/drop-core/analytics.test.ts +17 -0
  51. package/packages/drop-core/analytics.ts +122 -0
  52. package/packages/drop-core/authedApi.test.ts +100 -0
  53. package/packages/drop-core/authedApi.ts +94 -0
  54. package/packages/drop-core/authedDropClient.test.ts +196 -0
  55. package/packages/drop-core/authedDropClient.ts +210 -0
  56. package/packages/drop-core/buildClient.test.ts +112 -0
  57. package/packages/{components/preact/DropZone/drop-core → drop-core}/buildClient.ts +55 -70
  58. package/packages/drop-core/buildStash.test.ts +85 -0
  59. package/packages/drop-core/buildStash.ts +185 -0
  60. package/packages/drop-core/deploy.test.ts +135 -0
  61. package/packages/{components/preact/DropZone/drop-core → drop-core}/deploy.ts +18 -4
  62. package/packages/drop-core/detectBuild.test.ts +139 -0
  63. package/packages/drop-core/digest.test.ts +24 -0
  64. package/packages/drop-core/errors.test.ts +69 -0
  65. package/packages/{components/preact/DropZone/drop-core → drop-core}/errors.ts +1 -1
  66. package/packages/drop-core/fileTypes.test.ts +37 -0
  67. package/packages/{components/preact/DropZone/drop-core → drop-core}/index.ts +3 -0
  68. package/packages/drop-core/readFiles.test.ts +125 -0
  69. package/packages/{components/preact/DropZone/drop-core → drop-core}/types.ts +12 -6
  70. package/packages/drop-core/zip.test.ts +92 -0
  71. package/dist/components/preact/DropZone/drop-core/authedDropClient.d.ts +0 -77
  72. package/dist/components/preact/DropZone/drop-core/authedDropClient.js +0 -97
  73. package/dist/components/preact/DropZone/drop-core/buildClient.js +0 -92
  74. package/dist/components/preact/DropZone/drop-core/deploy.js +0 -48
  75. package/dist/components/preact/DropZone/drop-core/index.js +0 -36
  76. package/packages/components/preact/DropZone/drop-core/authedDropClient.ts +0 -185
  77. /package/dist/{components/preact/DropZone/drop-core → drop-core}/client.d.ts +0 -0
  78. /package/dist/{components/preact/DropZone/drop-core → drop-core}/client.js +0 -0
  79. /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.d.ts +0 -0
  80. /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.js +0 -0
  81. /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.d.ts +0 -0
  82. /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.js +0 -0
  83. /package/dist/{components/preact/DropZone/drop-core → drop-core}/errors.d.ts +0 -0
  84. /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.d.ts +0 -0
  85. /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.js +0 -0
  86. /package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.d.ts +0 -0
  87. /package/dist/{components/preact/DropZone/drop-core → drop-core}/types.js +0 -0
  88. /package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.d.ts +0 -0
  89. /package/packages/{components/preact/DropZone/drop-core → drop-core}/client.ts +0 -0
  90. /package/packages/{components/preact/DropZone/drop-core → drop-core}/detectBuild.ts +0 -0
  91. /package/packages/{components/preact/DropZone/drop-core → drop-core}/digest.ts +0 -0
  92. /package/packages/{components/preact/DropZone/drop-core → drop-core}/fileTypes.ts +0 -0
  93. /package/packages/{components/preact/DropZone/drop-core → drop-core}/readFiles.ts +0 -0
  94. /package/packages/{components/preact/DropZone/drop-core → drop-core}/zip.ts +0 -0
@@ -0,0 +1,185 @@
1
+ import type { InferredBuildSettings } from './detectBuild';
2
+
3
+ /**
4
+ * Carry a build-required drop across the signup round-trip.
5
+ *
6
+ * A logged-out visitor's drop can't be built (builds have no anonymous
7
+ * equivalent), so before handing them to signup the zone stashes the source
8
+ * archive here — IndexedDB, because the zip is far beyond localStorage's
9
+ * budget. When they return to this origin authenticated, the zone deploys the
10
+ * stash automatically instead of asking them to drop again.
11
+ *
12
+ * Storage is per-origin: a stash written on www.netlify.com can only ever be
13
+ * resumed on www.netlify.com. That is the design, not a limitation — it is
14
+ * exactly why the hand-off page must bring the visitor back here.
15
+ *
16
+ * Every operation is failure-swallowing: the stash is an enhancement, and a
17
+ * browser without IndexedDB (or with a full quota) degrades to the plain
18
+ * signup redirect the zone always supported.
19
+ */
20
+
21
+ export interface DropBuildStash {
22
+ /** The `source.zip` exactly as it would have been uploaded. */
23
+ zipFile: File;
24
+ /** Settings synthesised into the archive's netlify.toml, if any. */
25
+ buildSettings: InferredBuildSettings | null;
26
+ createdAt: number;
27
+ }
28
+
29
+ const DB_NAME = 'netlify-drop';
30
+ const DB_VERSION = 1;
31
+ const STORE_NAME = 'build-stash';
32
+ const STASH_KEY = 'latest';
33
+ const MARKER_KEY = 'dropBuildStash';
34
+ const STASH_TTL_MS = 24 * 60 * 60 * 1000;
35
+ const OPERATION_TIMEOUT_MS = 3000;
36
+ const MAX_ZIP_BYTES = 200 * 1024 * 1024;
37
+ /** Only stash when the quota comfortably fits the zip — never wedge the origin's storage. */
38
+ const QUOTA_HEADROOM = 2;
39
+
40
+ const withTimeout = <T>(promise: Promise<T>): Promise<T> =>
41
+ Promise.race([
42
+ promise,
43
+ new Promise<never>((_resolve, reject) => {
44
+ setTimeout(() => reject(new Error('IndexedDB operation timed out')), OPERATION_TIMEOUT_MS);
45
+ }),
46
+ ]);
47
+
48
+ const openDatabase = (): Promise<IDBDatabase> =>
49
+ new Promise((resolve, reject) => {
50
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
51
+ request.onupgradeneeded = () => {
52
+ request.result.createObjectStore(STORE_NAME);
53
+ };
54
+ request.onsuccess = () => resolve(request.result);
55
+ request.onerror = () => reject(request.error);
56
+ });
57
+
58
+ // One request per transaction; awaiting mid-transaction would auto-commit it.
59
+ const runStoreOperation = <T>(
60
+ mode: IDBTransactionMode,
61
+ makeRequest: (store: IDBObjectStore) => IDBRequest<T>
62
+ ): Promise<T> =>
63
+ withTimeout(
64
+ openDatabase().then(
65
+ db =>
66
+ new Promise<T>((resolve, reject) => {
67
+ const request = makeRequest(db.transaction(STORE_NAME, mode).objectStore(STORE_NAME));
68
+ request.onsuccess = () => {
69
+ db.close();
70
+ resolve(request.result);
71
+ };
72
+ request.onerror = () => {
73
+ db.close();
74
+ reject(request.error);
75
+ };
76
+ })
77
+ )
78
+ );
79
+
80
+ const hasEnoughQuota = async (bytes: number): Promise<boolean> => {
81
+ const { quota, usage } = await navigator.storage.estimate();
82
+ return quota !== undefined && usage !== undefined && quota - usage > bytes * QUOTA_HEADROOM;
83
+ };
84
+
85
+ // The marker is a plain localStorage flag with its own expiry, so page loads can
86
+ // ask "is there anything to resume?" synchronously without opening IndexedDB.
87
+ const setMarker = () => {
88
+ localStorage.setItem(MARKER_KEY, JSON.stringify({ expiresAt: Date.now() + STASH_TTL_MS }));
89
+ };
90
+
91
+ const clearMarker = () => {
92
+ localStorage.removeItem(MARKER_KEY);
93
+ };
94
+
95
+ /**
96
+ * Stash a drop's source archive for an automatic deploy after signup. Returns
97
+ * whether it was actually stored — callers use that only to phrase the
98
+ * hand-off, never to block it.
99
+ */
100
+ export async function saveDropStash(
101
+ zipFile: File,
102
+ buildSettings: InferredBuildSettings | null = null
103
+ ): Promise<boolean> {
104
+ try {
105
+ if (
106
+ typeof indexedDB === 'undefined' ||
107
+ !navigator.storage?.estimate ||
108
+ zipFile.size > MAX_ZIP_BYTES ||
109
+ !(await hasEnoughQuota(zipFile.size))
110
+ ) {
111
+ return false;
112
+ }
113
+ const record: DropBuildStash = { zipFile, buildSettings, createdAt: Date.now() };
114
+ await runStoreOperation('readwrite', store => store.put(record, STASH_KEY));
115
+ setMarker();
116
+ return true;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ /** The stashed drop, or null when there is none, it expired, or storage is unavailable. */
123
+ export async function loadDropStash(): Promise<DropBuildStash | null> {
124
+ try {
125
+ if (typeof indexedDB === 'undefined') return null;
126
+ const record = await runStoreOperation<DropBuildStash | undefined>('readonly', store =>
127
+ store.get(STASH_KEY)
128
+ );
129
+ const zipFile = record && rehydrateZipFile(record.zipFile);
130
+ if (!record || !zipFile) return null;
131
+ if (Date.now() - record.createdAt > STASH_TTL_MS) {
132
+ await clearDropStash();
133
+ return null;
134
+ }
135
+ return { ...record, zipFile };
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * The archive back out of storage as a real `File`. Structured clone should
143
+ * preserve File identity, but implementations vary in whether the name and
144
+ * prototype survive — and the build upload needs both (the multipart field is
145
+ * named by the File). A Blob-shaped survivor is rewrapped; anything else is
146
+ * treated as a corrupt record.
147
+ */
148
+ function rehydrateZipFile(stored: unknown): File | null {
149
+ if (stored instanceof File && stored.name) return stored;
150
+ if (stored instanceof Blob || (stored && typeof (stored as Blob).arrayBuffer === 'function')) {
151
+ return new File([stored as Blob], 'source.zip', { type: 'application/zip' });
152
+ }
153
+ return null;
154
+ }
155
+
156
+ /** Remove the stash and its marker. Idempotent; never throws. */
157
+ export async function clearDropStash(): Promise<void> {
158
+ try {
159
+ clearMarker();
160
+ } catch {
161
+ // storage disabled — nothing to clear
162
+ }
163
+ try {
164
+ if (typeof indexedDB === 'undefined') return;
165
+ await runStoreOperation('readwrite', store => store.delete(STASH_KEY));
166
+ } catch {
167
+ // best effort — an orphaned record expires via its TTL
168
+ }
169
+ }
170
+
171
+ /** Cheap synchronous check so page loads don't open IndexedDB just to look for a stash. */
172
+ export function hasDropStashMarker(): boolean {
173
+ try {
174
+ const marker = localStorage.getItem(MARKER_KEY);
175
+ if (!marker) return false;
176
+ const { expiresAt } = JSON.parse(marker) as { expiresAt?: number };
177
+ if (typeof expiresAt !== 'number' || Date.now() > expiresAt) {
178
+ clearMarker();
179
+ return false;
180
+ }
181
+ return true;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { deployBuild, deployFiles } from './deploy';
3
+ import type { BuildClient } from './buildClient';
4
+ import type { DropClient } from './client';
5
+ import type { DeployProgress, ProcessedFile } from './types';
6
+
7
+ const encoder = new TextEncoder();
8
+
9
+ const file = (path: string, sha: string, contents = 'x'): ProcessedFile => {
10
+ const content = encoder.encode(contents).buffer as ArrayBuffer;
11
+ return { path, sha, content, size: content.byteLength, name: path.split('/').pop()! };
12
+ };
13
+
14
+ const files = [file('/index.html', 'aaa'), file('/site.css', 'bbb'), file('/app.js', 'ccc')];
15
+
16
+ function stubDropClient(required: string[]) {
17
+ const uploaded: string[] = [];
18
+ let inFlight = 0;
19
+ let maxInFlight = 0;
20
+ const client: DropClient = {
21
+ getToken: vi.fn(async () => 'tok'),
22
+ createDeploy: vi.fn(async () => ({
23
+ id: 'site',
24
+ deploy_id: 'deploy',
25
+ subdomain: 'sub',
26
+ required,
27
+ })),
28
+ uploadFile: vi.fn(async (deployId: string, path: string) => {
29
+ inFlight++;
30
+ maxInFlight = Math.max(maxInFlight, inFlight);
31
+ await new Promise(r => setTimeout(r, 2));
32
+ uploaded.push(`${deployId}:${path}`);
33
+ inFlight--;
34
+ }),
35
+ waitUntilReady: vi.fn(async () => {}),
36
+ };
37
+ return { client, uploaded, maxConcurrent: () => maxInFlight };
38
+ }
39
+
40
+ describe('deployFiles', () => {
41
+ it('uploads only the SHAs the backend reported missing, keyed by deploy_id', async () => {
42
+ const { client, uploaded } = stubDropClient(['aaa', 'ccc']);
43
+ const result = await deployFiles(client, files);
44
+ expect(uploaded.sort()).toEqual(['deploy:/app.js', 'deploy:/index.html']);
45
+ expect(result.url).toBe('https://sub.netlify.app');
46
+ expect(result.token).toBe('tok');
47
+ expect(client.waitUntilReady).toHaveBeenCalledWith(
48
+ expect.objectContaining({ deploy_id: 'deploy' }),
49
+ 'tok'
50
+ );
51
+ });
52
+
53
+ it('never runs more than five uploads at once', async () => {
54
+ const many = Array.from({ length: 20 }, (_, i) => file(`/f${i}.txt`, `sha${i}`));
55
+ const { client, maxConcurrent } = stubDropClient(many.map(f => f.sha));
56
+ await deployFiles(client, many);
57
+ expect(maxConcurrent()).toBeLessThanOrEqual(5);
58
+ });
59
+
60
+ it('reports progress through the full phase sequence', async () => {
61
+ const { client } = stubDropClient(['aaa']);
62
+ const phases: DeployProgress[] = [];
63
+ await deployFiles(client, files, { onProgress: e => phases.push(e) });
64
+ expect(phases.map(p => p.phase)).toEqual([
65
+ 'creating',
66
+ 'uploading',
67
+ 'uploading',
68
+ 'processing',
69
+ 'ready',
70
+ ]);
71
+ expect(phases[1]).toEqual({ phase: 'uploading', uploaded: 0, total: 1 });
72
+ });
73
+
74
+ it('throws on an empty file set before any network call', async () => {
75
+ const { client } = stubDropClient([]);
76
+ await expect(deployFiles(client, [])).rejects.toThrow('No files to deploy');
77
+ expect(client.getToken).not.toHaveBeenCalled();
78
+ });
79
+ });
80
+
81
+ describe('deployBuild', () => {
82
+ const stubBuildClient = (): BuildClient & { calls: string[] } => {
83
+ const calls: string[] = [];
84
+ return {
85
+ calls,
86
+ createSite: vi.fn(async () => (calls.push('createSite'), { id: 'site', name: 'sub' })),
87
+ createBuildFromZip: vi.fn(
88
+ async () => (calls.push('createBuildFromZip'), { id: 'b', deploy_id: 'd' })
89
+ ),
90
+ waitUntilBuildReady: vi.fn(async () => {
91
+ calls.push('waitUntilBuildReady');
92
+ }),
93
+ };
94
+ };
95
+
96
+ it('zips, creates the site, uploads, and reports the site URL', async () => {
97
+ const client = stubBuildClient();
98
+ const result = await deployBuild(client, files);
99
+ expect(result.site.name).toBe('sub');
100
+ expect(result.build.deploy_id).toBe('d');
101
+ expect(result.url).toBe('https://sub.netlify.app');
102
+ // Off by default: the useful hand-off is the project page, not a spinner.
103
+ expect(client.waitUntilBuildReady).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it('zips BEFORE creating the site, so a failed zip leaves no empty project behind', async () => {
107
+ const client = stubBuildClient();
108
+ // An empty file set makes zipFiles throw before any client call.
109
+ await expect(deployBuild(client, [])).rejects.toThrow('No files to deploy');
110
+ expect(client.createSite).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('waits for the build only when asked', async () => {
114
+ const client = stubBuildClient();
115
+ await deployBuild(client, files, { waitForBuild: true });
116
+ expect(client.calls).toEqual(['createSite', 'createBuildFromZip', 'waitUntilBuildReady']);
117
+ });
118
+
119
+ it('reports progress through the build phase sequence', async () => {
120
+ const client = stubBuildClient();
121
+ const phases: string[] = [];
122
+ await deployBuild(client, files, { onProgress: e => phases.push(e.phase) });
123
+ expect(phases).toEqual(['zipping', 'creating', 'uploading', 'building', 'ready']);
124
+ });
125
+
126
+ it('falls back through ssl_url → url → derived netlify.app domain', async () => {
127
+ const client = stubBuildClient();
128
+ (client.createSite as ReturnType<typeof vi.fn>).mockResolvedValue({
129
+ id: 's',
130
+ name: 'sub',
131
+ url: 'http://custom.example',
132
+ });
133
+ expect((await deployBuild(client, files)).url).toBe('http://custom.example');
134
+ });
135
+ });
@@ -3,7 +3,7 @@ import type { BuildClient, BuildResponse, BuildSite } from './buildClient';
3
3
  import type { DropClient } from './client';
4
4
  import type { InferredBuildSettings } from './detectBuild';
5
5
  import type { DeployProgress, DeployResult, ProcessedFile } from './types';
6
- import { zipFiles } from './zip';
6
+ import { zipFiles, type ZipResult } from './zip';
7
7
 
8
8
  const MAX_CONCURRENT_UPLOADS = 5;
9
9
 
@@ -95,10 +95,24 @@ export async function deployBuild(
95
95
  opts: BuildDeployOptions = {}
96
96
  ): Promise<BuildDeployResult> {
97
97
  if (!files.length) throw new Error('No files to deploy');
98
- const { onProgress, waitForBuild = false } = opts;
99
98
 
100
- onProgress?.({ phase: 'zipping' });
101
- const { file: zip, buildSettings } = await zipFiles(files);
99
+ opts.onProgress?.({ phase: 'zipping' });
100
+ const archive = await zipFiles(files);
101
+
102
+ return deployZippedBuild(client, archive, opts);
103
+ }
104
+
105
+ /**
106
+ * Deploy an archive that was already zipped — the resume path for a drop
107
+ * stashed across the signup round-trip (see `buildStash`), where `source.zip`
108
+ * and its synthesised settings were produced before the visitor left.
109
+ */
110
+ export async function deployZippedBuild(
111
+ client: BuildClient,
112
+ { file: zip, buildSettings }: ZipResult,
113
+ opts: BuildDeployOptions = {}
114
+ ): Promise<BuildDeployResult> {
115
+ const { onProgress, waitForBuild = false } = opts;
102
116
 
103
117
  onProgress?.({ phase: 'creating' });
104
118
  const site = await client.createSite();
@@ -0,0 +1,139 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { detectBuild, inferBuildSettings } from './detectBuild';
3
+ import type { ProcessedFile } from './types';
4
+
5
+ const encoder = new TextEncoder();
6
+
7
+ const file = (path: string, contents = ''): ProcessedFile => {
8
+ const content = encoder.encode(contents).buffer as ArrayBuffer;
9
+ return { path, content, size: content.byteLength, sha: 'test-sha', name: path.split('/').pop()! };
10
+ };
11
+
12
+ const packageJson = (json: object) => file('/package.json', JSON.stringify(json));
13
+ const staticSite = () => [file('/index.html', '<h1>hi</h1>'), file('/style.css', 'body{}')];
14
+
15
+ describe('detectBuild', () => {
16
+ it('treats a plain static site as deployable without a build', () => {
17
+ expect(detectBuild(staticSite())).toEqual({ buildRequired: false, reason: null });
18
+ });
19
+
20
+ it('requires a build for a netlify.toml [build] table', () => {
21
+ const files = [
22
+ ...staticSite(),
23
+ file('/netlify.toml', '[build]\n command = "npm run build"\n'),
24
+ ];
25
+ expect(detectBuild(files)).toEqual({ buildRequired: true, reason: 'netlify-toml-build' });
26
+ });
27
+
28
+ it('counts [build] sub-tables like [build.environment]', () => {
29
+ const files = [...staticSite(), file('/netlify.toml', '[build.environment]\n FOO = "1"\n')];
30
+ expect(detectBuild(files)).toEqual({ buildRequired: true, reason: 'netlify-toml-build' });
31
+ });
32
+
33
+ it('ignores a [build] mention inside a comment', () => {
34
+ const files = [
35
+ ...staticSite(),
36
+ file('/netlify.toml', '# [build] used to live here\n[[redirects]]\n'),
37
+ ];
38
+ expect(detectBuild(files)).toEqual({ buildRequired: false, reason: null });
39
+ });
40
+
41
+ it('requires a build for a package.json build script', () => {
42
+ const files = [...staticSite(), packageJson({ scripts: { build: 'vite build' } })];
43
+ expect(detectBuild(files)).toEqual({
44
+ buildRequired: true,
45
+ reason: 'package-json-build-script',
46
+ });
47
+ });
48
+
49
+ it('does not count an empty build script', () => {
50
+ const files = [...staticSite(), packageJson({ scripts: { build: ' ' } })];
51
+ expect(detectBuild(files)).toEqual({ buildRequired: false, reason: null });
52
+ });
53
+
54
+ it('requires a build for a known framework dependency, prod or dev', () => {
55
+ expect(detectBuild([packageJson({ dependencies: { astro: '^4.0.0' } })])).toEqual({
56
+ buildRequired: true,
57
+ reason: 'framework-dependency',
58
+ });
59
+ expect(detectBuild([packageJson({ devDependencies: { vite: '^6.0.0' } })])).toEqual({
60
+ buildRequired: true,
61
+ reason: 'framework-dependency',
62
+ });
63
+ });
64
+
65
+ it('requires a build for the zero-config functions directories', () => {
66
+ expect(
67
+ detectBuild([...staticSite(), file('/netlify/functions/hello.ts', 'export {}')])
68
+ ).toEqual({
69
+ buildRequired: true,
70
+ reason: 'functions-directory',
71
+ });
72
+ expect(
73
+ detectBuild([...staticSite(), file('/netlify/edge-functions/hello.ts', 'export {}')])
74
+ ).toEqual({ buildRequired: true, reason: 'functions-directory' });
75
+ });
76
+
77
+ it('falls through on a malformed package.json rather than guessing', () => {
78
+ expect(detectBuild([...staticSite(), file('/package.json', '{ not json')])).toEqual({
79
+ buildRequired: false,
80
+ reason: null,
81
+ });
82
+ });
83
+
84
+ it('reports the first matching signal when several apply', () => {
85
+ const files = [
86
+ file('/netlify.toml', '[build]\n'),
87
+ packageJson({ scripts: { build: 'x' }, dependencies: { astro: '1' } }),
88
+ ];
89
+ expect(detectBuild(files).reason).toBe('netlify-toml-build');
90
+ });
91
+ });
92
+
93
+ describe('inferBuildSettings', () => {
94
+ const buildable = (extra: object = {}) =>
95
+ packageJson({ scripts: { build: 'astro build' }, dependencies: { astro: '^4' }, ...extra });
96
+
97
+ it('returns command + publish + framework when both halves are known', () => {
98
+ expect(inferBuildSettings([buildable()])).toEqual({
99
+ command: 'npm run build',
100
+ publish: 'dist',
101
+ framework: 'astro',
102
+ });
103
+ });
104
+
105
+ it('names the package manager from the lockfile', () => {
106
+ expect(inferBuildSettings([buildable(), file('/pnpm-lock.yaml')])?.command).toBe(
107
+ 'pnpm run build'
108
+ );
109
+ expect(inferBuildSettings([buildable(), file('/yarn.lock')])?.command).toBe('yarn run build');
110
+ expect(inferBuildSettings([buildable(), file('/bun.lock')])?.command).toBe('bun run build');
111
+ });
112
+
113
+ it('defers to an existing [build] table — the author config wins', () => {
114
+ expect(
115
+ inferBuildSettings([buildable(), file('/netlify.toml', '[build]\n publish = "out"\n')])
116
+ ).toBeNull();
117
+ });
118
+
119
+ it('still synthesises when the toml has only sub-tables (no [build] itself)', () => {
120
+ expect(
121
+ inferBuildSettings([buildable(), file('/netlify.toml', '[build.environment]\n A = "1"\n')])
122
+ ).not.toBeNull();
123
+ });
124
+
125
+ it('returns null without a build script — no command to state', () => {
126
+ expect(inferBuildSettings([packageJson({ dependencies: { astro: '^4' } })])).toBeNull();
127
+ });
128
+
129
+ it('returns null for frameworks whose publish directory is not a fixed default', () => {
130
+ // Guessing publish wrong ships source instead of output — worse than not guessing.
131
+ expect(
132
+ inferBuildSettings([packageJson({ scripts: { build: 'x' }, dependencies: { nuxt: '^3' } })])
133
+ ).toBeNull();
134
+ });
135
+
136
+ it('returns null when no known framework is present', () => {
137
+ expect(inferBuildSettings([packageJson({ scripts: { build: 'make site' } })])).toBeNull();
138
+ });
139
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getDigest } from './digest';
3
+ import type { ProcessedFile } from './types';
4
+
5
+ const file = (path: string, sha: string): ProcessedFile => ({
6
+ path,
7
+ sha,
8
+ size: 0,
9
+ content: new ArrayBuffer(0),
10
+ name: path.split('/').pop()!,
11
+ });
12
+
13
+ describe('getDigest', () => {
14
+ it('maps each path to its sha', () => {
15
+ expect(getDigest([file('/index.html', 'aaa'), file('/css/site.css', 'bbb')])).toEqual({
16
+ '/index.html': 'aaa',
17
+ '/css/site.css': 'bbb',
18
+ });
19
+ });
20
+
21
+ it('returns an empty digest for an empty file set', () => {
22
+ expect(getDigest([])).toEqual({});
23
+ });
24
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { apiError } from './client';
3
+ import {
4
+ BuildCreateError,
5
+ BuildFailedError,
6
+ BuildTimeoutError,
7
+ humanizeDropError,
8
+ NotAuthenticatedError,
9
+ SiteCreateError,
10
+ UnsupportedDropError,
11
+ } from './errors';
12
+
13
+ describe('humanizeDropError', () => {
14
+ it('maps each typed error to its user-facing copy', () => {
15
+ expect(humanizeDropError(new UnsupportedDropError())).toBe(
16
+ 'To deploy, drop a folder or a .zip file — or a single HTML page.'
17
+ );
18
+ expect(humanizeDropError(new SiteCreateError(500))).toBe(
19
+ "We couldn't create a project for this build. Please try again."
20
+ );
21
+ expect(humanizeDropError(new BuildTimeoutError())).toBe(
22
+ 'Your build is taking longer than expected. It may still finish — check your Netlify dashboard.'
23
+ );
24
+ });
25
+
26
+ it("passes a failed build's own message through — the only actionable part", () => {
27
+ expect(humanizeDropError(new BuildFailedError('Command failed: npm run build'))).toBe(
28
+ 'Your build failed: Command failed: npm run build'
29
+ );
30
+ expect(humanizeDropError(new BuildFailedError())).toBe(
31
+ 'Your build failed. Check your build settings and try again.'
32
+ );
33
+ });
34
+
35
+ it('maps API statuses attached by apiError()', () => {
36
+ expect(humanizeDropError(apiError('drop', 429))).toContain('Too many attempts');
37
+ expect(humanizeDropError(apiError('upload /x', 413))).toContain('too large');
38
+ expect(humanizeDropError(apiError('poll', 404))).toContain("couldn't find that deploy");
39
+ expect(humanizeDropError(apiError('drop', 401))).toContain("wasn't allowed");
40
+ expect(humanizeDropError(apiError('drop', 503))).toContain('servers are having trouble');
41
+ });
42
+
43
+ it('routes typed errors carrying a status through the same mapping', () => {
44
+ expect(humanizeDropError(new BuildCreateError(413))).toContain('too large');
45
+ });
46
+
47
+ it('maps the shared deploy-timeout message', () => {
48
+ expect(humanizeDropError(new Error('Deploy did not become ready in time'))).toContain(
49
+ 'taking longer than expected'
50
+ );
51
+ });
52
+
53
+ it('reads a fetch TypeError as a connectivity problem', () => {
54
+ expect(humanizeDropError(new TypeError('Failed to fetch'))).toContain(
55
+ 'Check your internet connection'
56
+ );
57
+ });
58
+
59
+ it('falls back to generic copy for anything else', () => {
60
+ expect(humanizeDropError('not even an error')).toBe('Something went wrong. Please try again.');
61
+ expect(humanizeDropError(new Error('mystery'))).toBe('Something went wrong. Please try again.');
62
+ });
63
+
64
+ it('keeps the NotAuthenticatedError copy path-agnostic — it gates static deploys and builds alike', () => {
65
+ expect(humanizeDropError(new NotAuthenticatedError())).toBe(
66
+ 'Please log in to Netlify to deploy this project, then try again.'
67
+ );
68
+ });
69
+ });
@@ -85,7 +85,7 @@ export function humanizeDropError(err: unknown): string {
85
85
  }
86
86
 
87
87
  if (err instanceof NotAuthenticatedError) {
88
- return 'Please log in to Netlify to deploy a project that needs a build, then try again.';
88
+ return 'Please log in to Netlify to deploy this project, then try again.';
89
89
  }
90
90
 
91
91
  if (err instanceof SiteCreateError) {
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getFileTypes } from './fileTypes';
3
+
4
+ /** Minimal FileList stand-in — getFileTypes only touches length and iteration. */
5
+ const fileList = (...types: string[]): FileList => {
6
+ const files = types.map((type, i) => new File(['x'], `f${i}`, { type }));
7
+ return Object.assign(files, { item: (i: number) => files[i] }) as unknown as FileList;
8
+ };
9
+
10
+ describe('getFileTypes', () => {
11
+ it('returns [] when the browser exposes no files', () => {
12
+ expect(getFileTypes(null)).toEqual([]);
13
+ expect(getFileTypes(undefined)).toEqual([]);
14
+ expect(getFileTypes(fileList())).toEqual([]);
15
+ });
16
+
17
+ it('dedupes and sorts so the same selection always yields the same array', () => {
18
+ expect(getFileTypes(fileList('text/html', 'image/png', 'text/html', 'text/css'))).toEqual([
19
+ 'image/png',
20
+ 'text/css',
21
+ 'text/html',
22
+ ]);
23
+ });
24
+
25
+ it('reports application/octet-stream for files the browser could not type', () => {
26
+ // A dragged folder or an .exe arrives with an empty File.type.
27
+ expect(getFileTypes(fileList(''))).toEqual(['application/octet-stream']);
28
+ });
29
+
30
+ it('caps the list at 20 distinct types', () => {
31
+ const many = Array.from(
32
+ { length: 30 },
33
+ (_, i) => `application/x-${String(i).padStart(2, '0')}`
34
+ );
35
+ expect(getFileTypes(fileList(...many))).toHaveLength(20);
36
+ });
37
+ });
@@ -4,8 +4,11 @@ export * from './readFiles';
4
4
  export * from './fileTypes';
5
5
  export * from './detectBuild';
6
6
  export * from './client';
7
+ export * from './authedApi';
7
8
  export * from './authedDropClient';
8
9
  export * from './buildClient';
9
10
  export * from './zip';
11
+ export * from './buildStash';
10
12
  export * from './deploy';
11
13
  export * from './errors';
14
+ export * from './analytics';