cursedbelt 5.1.0 → 5.2.0
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/react/engagement/beacon.d.ts +38 -0
- package/dist/react/engagement/beacon.d.ts.map +1 -0
- package/dist/react/engagement/beacon.js +62 -0
- package/dist/react/engagement/beacon.js.map +1 -0
- package/dist/react/engagement/beaconClient.d.ts +119 -0
- package/dist/react/engagement/beaconClient.d.ts.map +1 -0
- package/dist/react/engagement/beaconClient.js +203 -0
- package/dist/react/engagement/beaconClient.js.map +1 -0
- package/dist/react/engagement/index.d.ts +3 -0
- package/dist/react/engagement/index.d.ts.map +1 -0
- package/dist/react/engagement/index.js +5 -0
- package/dist/react/engagement/index.js.map +1 -0
- package/dist/react/lib/directUpload.d.ts +130 -0
- package/dist/react/lib/directUpload.d.ts.map +1 -0
- package/dist/react/lib/directUpload.js +228 -0
- package/dist/react/lib/directUpload.js.map +1 -0
- package/dist/styles-areas/auth.css +2 -2
- package/dist/styles-areas/core.css +2 -2
- package/package.json +13 -1
- package/scripts/checkAreaStyles.spec.ts +23 -0
- package/scripts/checkAreaStyles.ts +20 -1
- package/scripts/styleAreas.ts +2 -0
- package/src/react/engagement/beacon.tsx +63 -0
- package/src/react/engagement/beaconClient.spec.ts +138 -0
- package/src/react/engagement/beaconClient.ts +228 -0
- package/src/react/engagement/index.ts +13 -0
- package/src/react/lib/directUpload.spec.ts +128 -0
- package/src/react/lib/directUpload.ts +334 -0
- package/src/styles-areas/auth.css +2 -2
- package/src/styles-areas/core.css +2 -2
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt/react/engagement-beacon` — the engagement beacon's plain-JS half (no React). Lifted
|
|
3
|
+
* 2026-09-23 (cursedbelt 5.2.0, task 098-280's browser half) from `src/kit/engagement/beacon.client.ts`,
|
|
4
|
+
* identical in collections, family, music and roms (auth's differed only in a comment). The
|
|
5
|
+
* server half is `cursedbelt-server/engagement`.
|
|
6
|
+
*
|
|
7
|
+
* The browser half — report which PLACE the user is on, once per arrival.
|
|
8
|
+
*
|
|
9
|
+
* ── Why an explicit beacon and not the request log ──────────────────────────
|
|
10
|
+
* Most satellites are SPA-routed: moving from Notes' Files page to its Ask page
|
|
11
|
+
* fires zero navigations and, on a warm cache, zero HTTP requests. So the
|
|
12
|
+
* request metrics already in `metrics/` can tell you an app is used and cannot
|
|
13
|
+
* tell you which of its pages are — which is exactly the question the owner
|
|
14
|
+
* asked ("notes is used but its slideshow is not"). One small event, sent by
|
|
15
|
+
* the router, is the only thing that can answer it.
|
|
16
|
+
*
|
|
17
|
+
* ── It is ONE implementation, adopted; never per-app ────────────────────────
|
|
18
|
+
* Eleven hand-rolled beacons would be eleven different place vocabularies, and
|
|
19
|
+
* the console would be comparing strings that mean different things. Apps call
|
|
20
|
+
* `reportPlace` (or mount `useEngagementBeacon`) and send an id from their own
|
|
21
|
+
* route manifest; the server resolves it against that same manifest.
|
|
22
|
+
*
|
|
23
|
+
* ── What it does NOT send ───────────────────────────────────────────────────
|
|
24
|
+
* No path, no query, no title, no referrer, no identifiers of any kind — just
|
|
25
|
+
* `{ place }`. The account comes from the session cookie the request already
|
|
26
|
+
* carries, server-side. There is nothing in the payload that could carry a note
|
|
27
|
+
* id, a ROM title or a search string, which is the property that makes this
|
|
28
|
+
* safe to run inside private apps at all. See `policy.ts`.
|
|
29
|
+
*
|
|
30
|
+
* ── Failure is silence ──────────────────────────────────────────────────────
|
|
31
|
+
* Every call is fire-and-forget and swallows its error. Telemetry that can
|
|
32
|
+
* surface an error toast, block a route change, or reject an unhandled promise
|
|
33
|
+
* is telemetry that makes the app worse than having none.
|
|
34
|
+
*
|
|
35
|
+
* 🔴 …and "silence" includes the BROWSER's console, which is why the beacon
|
|
36
|
+
* asks whether there is a session before it reports (`SESSION_PATH` below).
|
|
37
|
+
* Swallowing the rejection in JS is not enough: a `fetch` that comes back 401
|
|
38
|
+
* leaves the browser's own *"Failed to load resource: the server responded with
|
|
39
|
+
* a status of 401"* in the console no matter what the caller does with the
|
|
40
|
+
* promise. Measured 2026-08-14 against live prod — `music` and `learn` both
|
|
41
|
+
* failed their signed-out route-health check on exactly this, with a correctly
|
|
42
|
+
* rendered sign-in card on screen and nothing else wrong. `apps/roms`' App.tsx
|
|
43
|
+
* already wrote the rule down for the feedback provider: *"an app that logs an
|
|
44
|
+
* error on its front door is an app nobody can tell apart from a broken one."*
|
|
45
|
+
* The recorder's 401 is deliberate and stays — `api.ts` mounts it AFTER the
|
|
46
|
+
* session gate on purpose, because the gate is what puts the user where
|
|
47
|
+
* `resolveUser` can read them — so the fix belongs on this side.
|
|
48
|
+
*
|
|
49
|
+
* It is also the semantically right answer, not merely a quieter one: the
|
|
50
|
+
* recorder attributes a view to an account, so a signed-out arrival could never
|
|
51
|
+
* have been recorded. Nothing that used to be counted stops being counted.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** Where the recorder is mounted. Apps that remount it pass `basePath`. */
|
|
55
|
+
export const ENGAGEMENT_BASE_PATH = "/api/engagement";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The public session probe every satellite serves
|
|
59
|
+
* (`ssoConsumer.server.ts` → `{ authenticated, user?, accountsUrl }`).
|
|
60
|
+
*
|
|
61
|
+
* Deliberately NOT session-gated on the server, which is what makes it usable
|
|
62
|
+
* here: asking it costs one small same-origin GET and can never itself produce
|
|
63
|
+
* the console line this exists to avoid.
|
|
64
|
+
*/
|
|
65
|
+
export const SESSION_PATH = "/api/auth/session";
|
|
66
|
+
|
|
67
|
+
export interface ReportPlaceOptions {
|
|
68
|
+
basePath?: string;
|
|
69
|
+
/** Injected in tests; defaults to the global. */
|
|
70
|
+
fetchImpl?: typeof fetch;
|
|
71
|
+
/** Override the session probe. Apps that remount the consumer elsewhere. */
|
|
72
|
+
sessionPath?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Skip the session probe and report unconditionally.
|
|
75
|
+
*
|
|
76
|
+
* For a caller that ALREADY knows there is a session — a React tree below the
|
|
77
|
+
* authenticated branch, say — where the probe would be a second answer to a
|
|
78
|
+
* question already asked. Never set it from `main.tsx`, which runs before
|
|
79
|
+
* anything knows.
|
|
80
|
+
*/
|
|
81
|
+
assumeSignedIn?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The last place reported, so a router that re-renders does not re-report.
|
|
86
|
+
*
|
|
87
|
+
* A React router re-runs its effects on every state change that touches the
|
|
88
|
+
* route object, and a beacon per keystroke in a search box would turn one visit
|
|
89
|
+
* into forty views — inflating exactly the number the console reads as
|
|
90
|
+
* "interest". De-duplicating on the place id is what keeps a view meaning "an
|
|
91
|
+
* arrival".
|
|
92
|
+
*/
|
|
93
|
+
let lastReported: string | null = null;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Where the browser is, as a string the server can resolve.
|
|
97
|
+
*
|
|
98
|
+
* The hash when there is one (every satellite but roms routes on it), else the
|
|
99
|
+
* pathname. Never the search string: `normalizePlaceId` strips one anyway, and
|
|
100
|
+
* not sending it in the first place means user text does not travel at all.
|
|
101
|
+
*/
|
|
102
|
+
export function currentLocationPlace(): string {
|
|
103
|
+
const { hash, pathname } = window.location;
|
|
104
|
+
const raw = hash && hash !== "#" ? hash : pathname;
|
|
105
|
+
return raw.split("?")[0] ?? "/";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The answer to "is anyone signed in?", remembered for this page load.
|
|
110
|
+
*
|
|
111
|
+
* One probe per load, not one per navigation: the answer cannot change without
|
|
112
|
+
* a navigation that replaces the document (the SSO callback is a full page
|
|
113
|
+
* load), and re-asking on every hash change would trade one wasted request for
|
|
114
|
+
* dozens.
|
|
115
|
+
*/
|
|
116
|
+
let sessionAnswer: Promise<boolean> | null = null;
|
|
117
|
+
|
|
118
|
+
/** Reset the de-duplication memory. Tests, and a sign-out. */
|
|
119
|
+
export function resetEngagementBeacon(): void {
|
|
120
|
+
lastReported = null;
|
|
121
|
+
sessionAnswer = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Is there a session? Silent, cached, and false on any doubt.
|
|
126
|
+
*
|
|
127
|
+
* Fails CLOSED — a probe that throws, answers non-2xx or returns a body this
|
|
128
|
+
* cannot read means "do not report". The cost of a false negative is one
|
|
129
|
+
* uncounted view; the cost of a false positive is the console line on the front
|
|
130
|
+
* door that this whole path exists to prevent.
|
|
131
|
+
*/
|
|
132
|
+
async function hasSession(options: ReportPlaceOptions): Promise<boolean> {
|
|
133
|
+
const doFetch = options.fetchImpl ?? globalThis.fetch;
|
|
134
|
+
if (typeof doFetch !== "function") return false;
|
|
135
|
+
try {
|
|
136
|
+
const res = await doFetch(options.sessionPath ?? SESSION_PATH, {
|
|
137
|
+
credentials: "same-origin",
|
|
138
|
+
});
|
|
139
|
+
if (!res.ok) return false;
|
|
140
|
+
const body = (await res.json()) as { authenticated?: unknown } | null;
|
|
141
|
+
return body?.authenticated === true;
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Report an arrival at `place`. Returns whether anything was sent.
|
|
149
|
+
*
|
|
150
|
+
* Not awaited by callers in practice — the promise is returned so a test can
|
|
151
|
+
* settle it without a timer.
|
|
152
|
+
*/
|
|
153
|
+
export async function reportPlace(
|
|
154
|
+
place: string,
|
|
155
|
+
options: ReportPlaceOptions = {},
|
|
156
|
+
): Promise<boolean> {
|
|
157
|
+
if (!place || place === lastReported) return false;
|
|
158
|
+
const doFetch = options.fetchImpl ?? globalThis.fetch;
|
|
159
|
+
if (typeof doFetch !== "function") return false;
|
|
160
|
+
|
|
161
|
+
// 🔴 BEFORE the de-duplication is committed, not after. A signed-out arrival
|
|
162
|
+
// must leave no trace at all — recording it as `lastReported` would mean the
|
|
163
|
+
// view that happens once the visitor signs in and lands back on this same
|
|
164
|
+
// place is silently dropped as a duplicate.
|
|
165
|
+
if (options.assumeSignedIn !== true) {
|
|
166
|
+
sessionAnswer ??= hasSession(options);
|
|
167
|
+
if (!(await sessionAnswer)) return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (place === lastReported) return false;
|
|
171
|
+
lastReported = place;
|
|
172
|
+
try {
|
|
173
|
+
await doFetch(`${options.basePath ?? ENGAGEMENT_BASE_PATH}/view`, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: { "content-type": "application/json" },
|
|
176
|
+
// Same-origin credentials: the session cookie is the only identity
|
|
177
|
+
// this request carries, and it is the only one it may carry.
|
|
178
|
+
credentials: "same-origin",
|
|
179
|
+
body: JSON.stringify({ place }),
|
|
180
|
+
keepalive: true,
|
|
181
|
+
});
|
|
182
|
+
return true;
|
|
183
|
+
} catch {
|
|
184
|
+
// See the header: telemetry never becomes the user's problem.
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Detaches the listeners `startLocationBeacon` installed. */
|
|
190
|
+
export type StopBeacon = () => void;
|
|
191
|
+
|
|
192
|
+
let running: StopBeacon | null = null;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Start reporting wherever the browser is, and keep reporting as it moves.
|
|
196
|
+
*
|
|
197
|
+
* The form every app adopts, called once from `main.tsx` beside
|
|
198
|
+
* `initColorScheme()` — one line, no React, and no per-app mapping from a
|
|
199
|
+
* router's internal view name to a place id. The server resolves the LOCATION
|
|
200
|
+
* against that app's own `routes.manifest.json` (`policy.ts:resolvePlace`),
|
|
201
|
+
* which is the list the app already maintains and already gates on.
|
|
202
|
+
*
|
|
203
|
+
* `hashchange` and `popstate` between them cover both kinds of router, which is
|
|
204
|
+
* why BOTH are listened for when `collections` — hash-routed — only needs the
|
|
205
|
+
* first. The census that settled it was the previous generation's twelve apps:
|
|
206
|
+
* eleven hash routers and one pushState app. Keeping the second listener costs
|
|
207
|
+
* a no-op call, and losing it would be silent.
|
|
208
|
+
*
|
|
209
|
+
* Idempotent — a second call replaces the first rather than doubling every
|
|
210
|
+
* view, which matters because React 18 StrictMode mounts twice in development
|
|
211
|
+
* and a doubled count is a wrong number rather than a visible fault.
|
|
212
|
+
*/
|
|
213
|
+
export function startLocationBeacon(options: ReportPlaceOptions = {}): StopBeacon {
|
|
214
|
+
running?.();
|
|
215
|
+
const send = (): void => {
|
|
216
|
+
void reportPlace(currentLocationPlace(), options);
|
|
217
|
+
};
|
|
218
|
+
send();
|
|
219
|
+
window.addEventListener("hashchange", send);
|
|
220
|
+
window.addEventListener("popstate", send);
|
|
221
|
+
const stop: StopBeacon = () => {
|
|
222
|
+
window.removeEventListener("hashchange", send);
|
|
223
|
+
window.removeEventListener("popstate", send);
|
|
224
|
+
if (running === stop) running = null;
|
|
225
|
+
};
|
|
226
|
+
running = stop;
|
|
227
|
+
return stop;
|
|
228
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// `cursedbelt/react/engagement-beacon` — the engagement beacon: `reportPlace` and
|
|
2
|
+
// `startLocationBeacon` (plain JS, no React) and the React hooks over them.
|
|
3
|
+
export {
|
|
4
|
+
ENGAGEMENT_BASE_PATH,
|
|
5
|
+
type ReportPlaceOptions,
|
|
6
|
+
reportPlace,
|
|
7
|
+
resetEngagementBeacon,
|
|
8
|
+
SESSION_PATH,
|
|
9
|
+
type StopBeacon,
|
|
10
|
+
startLocationBeacon,
|
|
11
|
+
currentLocationPlace,
|
|
12
|
+
} from './beaconClient.js';
|
|
13
|
+
export { EngagementBeacon, useEngagementBeacon, useLocationBeacon } from './beacon.js';
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt/react/upload-direct` — the browser half of a resumable upload to binary-server.
|
|
3
|
+
*
|
|
4
|
+
* Neither app that carried this module (collections, family) had a test of it, so these pin the
|
|
5
|
+
* behaviours a 10 GB upload depends on, against a scripted `fetch`: resume skips delivered parts,
|
|
6
|
+
* parts are PUT with their bytes, a 403 re-mints the token, a 507 is terminal and never retried, a
|
|
7
|
+
* dropped part is retried, low disk only WARNS, and a lost final response is recovered by asking.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
10
|
+
import { chunkCount, fetchSessionState, InsufficientStorageError, type UploadSession, uploadDirect } from './directUpload.js';
|
|
11
|
+
|
|
12
|
+
const SESSION: UploadSession = {
|
|
13
|
+
baseUrl: 'https://bs.test',
|
|
14
|
+
path: 'family/media/abc def',
|
|
15
|
+
token: 't1',
|
|
16
|
+
sid: 's',
|
|
17
|
+
totalChunks: 3,
|
|
18
|
+
chunkBytes: 4,
|
|
19
|
+
};
|
|
20
|
+
const FILE = new Blob(['aaaabbbbcc'], { type: 'image/jpeg' });
|
|
21
|
+
|
|
22
|
+
interface Call {
|
|
23
|
+
method: string;
|
|
24
|
+
url: URL;
|
|
25
|
+
body: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const realFetch = globalThis.fetch;
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
globalThis.fetch = realFetch;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** Install a `fetch` that records every call and answers from `answer`. */
|
|
34
|
+
function script(answer: (call: Call) => Response | Promise<Response>): Call[] {
|
|
35
|
+
const calls: Call[] = [];
|
|
36
|
+
globalThis.fetch = (async (input: string, init?: RequestInit) => {
|
|
37
|
+
const body = init?.body instanceof Blob ? await init.body.text() : null;
|
|
38
|
+
const call = { method: init?.method ?? 'GET', url: new URL(String(input)), body };
|
|
39
|
+
calls.push(call);
|
|
40
|
+
return answer(call);
|
|
41
|
+
}) as unknown as typeof fetch;
|
|
42
|
+
return calls;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status });
|
|
46
|
+
const status = (received: number[], extra: Record<string, unknown> = {}) => json(200, { received, complete: false, ...extra });
|
|
47
|
+
|
|
48
|
+
describe('chunkCount and fetchSessionState', () => {
|
|
49
|
+
test('parts round UP, and an empty file is still one part', () => {
|
|
50
|
+
expect(chunkCount(10, 4)).toBe(3);
|
|
51
|
+
expect(chunkCount(8, 4)).toBe(2);
|
|
52
|
+
expect(chunkCount(0, 4)).toBe(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a status the server cannot answer is a FRESH session, not a failure', async () => {
|
|
56
|
+
script(() => new Response('nope', { status: 500 }));
|
|
57
|
+
const state = await fetchSessionState(SESSION);
|
|
58
|
+
expect([...state.received]).toEqual([]);
|
|
59
|
+
expect(state.complete).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('uploadDirect', () => {
|
|
64
|
+
test('resumes: sends only the parts binary-server does not hold, with their bytes, to an encoded path', async () => {
|
|
65
|
+
const calls = script((c) =>
|
|
66
|
+
c.method === 'GET' ? status([1]) : json(200, c.url.searchParams.get('ci') === '2' ? { assembled: true, size: 10, checksum: 'h', key: SESSION.path } : {}),
|
|
67
|
+
);
|
|
68
|
+
const result = await uploadDirect(FILE, SESSION, { concurrency: 1 });
|
|
69
|
+
const puts = calls.filter((c) => c.method === 'PUT');
|
|
70
|
+
expect(puts.map((c) => [c.url.searchParams.get('ci'), c.body])).toEqual([
|
|
71
|
+
['0', 'aaaa'],
|
|
72
|
+
['2', 'cc'],
|
|
73
|
+
]);
|
|
74
|
+
expect(puts[0]?.url.pathname).toBe('/upload-chunk/family/media/abc%20def');
|
|
75
|
+
expect(result).toEqual({ key: SESSION.path, size: 10, checksum: 'h' });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('an object already complete sends NOTHING', async () => {
|
|
79
|
+
const calls = script(() => json(200, { received: [0, 1, 2], complete: true, size: 10, checksum: 'h' }));
|
|
80
|
+
expect(await uploadDirect(FILE, SESSION)).toEqual({ key: SESSION.path, size: 10, checksum: 'h' });
|
|
81
|
+
expect(calls.filter((c) => c.method === 'PUT')).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a 403 re-mints the token and the retry carries the new one', async () => {
|
|
85
|
+
let first = true;
|
|
86
|
+
const calls = script((c) => {
|
|
87
|
+
if (c.method === 'GET') return status([0, 1]);
|
|
88
|
+
if (first) {
|
|
89
|
+
first = false;
|
|
90
|
+
return new Response('expired', { status: 403 });
|
|
91
|
+
}
|
|
92
|
+
return json(200, { assembled: true, size: 10, checksum: null });
|
|
93
|
+
});
|
|
94
|
+
await uploadDirect(FILE, SESSION, { refreshToken: async () => 't2', retries: 3 });
|
|
95
|
+
const puts = calls.filter((c) => c.method === 'PUT').map((c) => c.url.searchParams.get('token'));
|
|
96
|
+
expect(puts).toEqual(['t1', 't2']);
|
|
97
|
+
}, 10_000);
|
|
98
|
+
|
|
99
|
+
test('🔴 a 507 is TERMINAL — one attempt, a named error, no retry storm against a full disk', async () => {
|
|
100
|
+
const calls = script((c) => (c.method === 'GET' ? status([0, 1]) : json(507, { freeBytes: 0 })));
|
|
101
|
+
await expect(uploadDirect(FILE, SESSION, { retries: 5 })).rejects.toBeInstanceOf(InsufficientStorageError);
|
|
102
|
+
expect(calls.filter((c) => c.method === 'PUT')).toHaveLength(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('🔴 low disk only WARNS — the upload still runs (the owner, 2026-08-13)', async () => {
|
|
106
|
+
const warned: unknown[] = [];
|
|
107
|
+
script((c) => (c.method === 'GET' ? status([], { freeBytes: 1 }) : json(200, { assembled: c.url.searchParams.get('ci') === '2' })));
|
|
108
|
+
await uploadDirect(FILE, SESSION, { onLowDisk: (info) => warned.push(info) });
|
|
109
|
+
expect(warned).toEqual([{ freeBytes: 1, needBytes: 10 }]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('a lost final response is recovered by ASKING, not by re-sending', async () => {
|
|
113
|
+
let gets = 0;
|
|
114
|
+
const calls = script((c) => {
|
|
115
|
+
if (c.method === 'GET') return ++gets === 1 ? status([0, 1]) : json(200, { received: [0, 1, 2], complete: true, size: 10, checksum: 'z' });
|
|
116
|
+
return json(200, {}); // the part landed; the assemble result never reached us
|
|
117
|
+
});
|
|
118
|
+
expect(await uploadDirect(FILE, SESSION)).toEqual({ key: SESSION.path, size: 10, checksum: 'z' });
|
|
119
|
+
expect(calls.filter((c) => c.method === 'PUT')).toHaveLength(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('progress reaches 1', async () => {
|
|
123
|
+
const seen: number[] = [];
|
|
124
|
+
script((c) => (c.method === 'GET' ? status([]) : json(200, { assembled: c.url.searchParams.get('ci') === '2' })));
|
|
125
|
+
await uploadDirect(FILE, SESSION, { onProgress: (f) => seen.push(f) });
|
|
126
|
+
expect(seen.at(-1)).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
});
|