@open-webapp/drive-sync 0.1.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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * An in-memory fake of the subset of the Google Drive v3 REST API that this
3
+ * library needs: files (list/get/create/update/delete, including multipart
4
+ * and media-upload variants) and permissions (list/create/update/delete).
5
+ *
6
+ * Intended use:
7
+ *
8
+ * ```ts
9
+ * const driveFake = createDriveFake()
10
+ * vi.stubGlobal('fetch', driveFake.fetch)
11
+ * // ... exercise code under test ...
12
+ * expect(driveFake.folderCreationLog).toEqual(['OpenWebApp', 'Planning'])
13
+ * ```
14
+ */
15
+ export interface DriveFakeFile {
16
+ id: string;
17
+ name: string;
18
+ mimeType: string;
19
+ parents: string[];
20
+ content: string;
21
+ contentType?: string;
22
+ trashed?: boolean;
23
+ }
24
+ export interface DriveFakePermission {
25
+ id: string;
26
+ type: string;
27
+ role: string;
28
+ emailAddress?: string;
29
+ [key: string]: unknown;
30
+ }
31
+ export interface StatusOverrideOptions {
32
+ retryAfter?: number;
33
+ body?: string;
34
+ /** How many matching requests this override applies to. Defaults to 1. */
35
+ times?: number;
36
+ }
37
+ export interface DriveFake {
38
+ fetch: typeof fetch;
39
+ /** Every folder name involved in a folder lookup query or a folder create, in call order. */
40
+ folderCreationLog: string[];
41
+ /** Force a specific response for the next N (default 1) requests whose URL matches `pattern`. */
42
+ setStatusOverride(pattern: string | RegExp, status: number, opts?: StatusOverrideOptions): void;
43
+ /** Clear all in-memory state (files, permissions, overrides, logs). */
44
+ reset(): void;
45
+ /** Inspect current in-memory files, keyed by id. */
46
+ readonly files: Map<string, DriveFakeFile>;
47
+ /** Inspect current in-memory permissions, keyed by fileId then permission id. */
48
+ readonly permissions: Map<string, Map<string, DriveFakePermission>>;
49
+ }
50
+ export declare function createDriveFake(): DriveFake;
@@ -0,0 +1,423 @@
1
+ /**
2
+ * An in-memory fake of the subset of the Google Drive v3 REST API that this
3
+ * library needs: files (list/get/create/update/delete, including multipart
4
+ * and media-upload variants) and permissions (list/create/update/delete).
5
+ *
6
+ * Intended use:
7
+ *
8
+ * ```ts
9
+ * const driveFake = createDriveFake()
10
+ * vi.stubGlobal('fetch', driveFake.fetch)
11
+ * // ... exercise code under test ...
12
+ * expect(driveFake.folderCreationLog).toEqual(['OpenWebApp', 'Planning'])
13
+ * ```
14
+ */
15
+ const DRIVE_BASE = 'https://www.googleapis.com/drive/v3';
16
+ const UPLOAD_BASE = 'https://www.googleapis.com/upload/drive/v3';
17
+ const FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder';
18
+ function matchesPattern(pattern, url) {
19
+ return typeof pattern === 'string' ? url.includes(pattern) : pattern.test(url);
20
+ }
21
+ function jsonResponse(body, status = 200, headers) {
22
+ return new Response(JSON.stringify(body), {
23
+ status,
24
+ headers: { 'Content-Type': 'application/json', ...(headers ?? {}) },
25
+ });
26
+ }
27
+ function errorResponse(status, message, retryAfter, bodyOverride) {
28
+ const headers = {};
29
+ if (retryAfter !== undefined)
30
+ headers['Retry-After'] = String(retryAfter);
31
+ const body = bodyOverride ??
32
+ JSON.stringify({
33
+ error: {
34
+ code: status,
35
+ message,
36
+ errors: [{ reason: statusToReason(status), message }],
37
+ },
38
+ });
39
+ return new Response(body, {
40
+ status,
41
+ headers: { 'Content-Type': 'application/json', ...headers },
42
+ });
43
+ }
44
+ function statusToReason(status) {
45
+ switch (status) {
46
+ case 401:
47
+ return 'authError';
48
+ case 403:
49
+ return 'forbidden';
50
+ case 404:
51
+ return 'notFound';
52
+ case 429:
53
+ return 'rateLimitExceeded';
54
+ default:
55
+ return status >= 500 ? 'internalError' : 'invalid';
56
+ }
57
+ }
58
+ let idSeq = 0;
59
+ function nextId() {
60
+ idSeq += 1;
61
+ return `fake-id-${idSeq}`;
62
+ }
63
+ /** Extract a quoted-string argument from a Drive `q=` query, e.g. name='X' -> X. */
64
+ function extractQuotedField(q, field) {
65
+ const re = new RegExp(`${field}\\s*=\\s*'((?:[^'\\\\]|\\\\.)*)'`);
66
+ const m = re.exec(q);
67
+ return m ? m[1].replace(/\\'/g, "'") : undefined;
68
+ }
69
+ /** Extract the parent id from a `'parentId' in parents` clause. */
70
+ function extractParentInParents(q) {
71
+ const m = /'((?:[^'\\]|\\.)*)'\s+in\s+parents/.exec(q);
72
+ return m ? m[1].replace(/\\'/g, "'") : undefined;
73
+ }
74
+ function parseMultipartBody(body, boundary) {
75
+ const parts = body.split(`--${boundary}`).filter((p) => p.trim() && p.trim() !== '--');
76
+ let metadata = {};
77
+ let mediaContent = '';
78
+ let mediaType;
79
+ for (const rawPart of parts) {
80
+ const part = rawPart.replace(/^\r\n/, '').replace(/\r\n--$/, '');
81
+ const headerEnd = part.indexOf('\r\n\r\n');
82
+ const headerBlock = headerEnd >= 0 ? part.slice(0, headerEnd) : '';
83
+ const content = headerEnd >= 0 ? part.slice(headerEnd + 4) : part;
84
+ const contentTypeMatch = /Content-Type:\s*([^\r\n]+)/i.exec(headerBlock);
85
+ const contentType = contentTypeMatch?.[1]?.trim();
86
+ const trimmedContent = content.replace(/\r\n$/, '');
87
+ if (contentType?.includes('application/json')) {
88
+ try {
89
+ metadata = JSON.parse(trimmedContent);
90
+ }
91
+ catch {
92
+ metadata = {};
93
+ }
94
+ }
95
+ else {
96
+ mediaContent = trimmedContent;
97
+ mediaType = contentType;
98
+ }
99
+ }
100
+ return { metadata, mediaContent, mediaType };
101
+ }
102
+ async function readBodyText(init) {
103
+ const body = init?.body;
104
+ if (body === undefined || body === null)
105
+ return '';
106
+ if (typeof body === 'string')
107
+ return body;
108
+ if (body instanceof Uint8Array)
109
+ return new TextDecoder().decode(body);
110
+ if (typeof body.text === 'function')
111
+ return await body.text();
112
+ return String(body);
113
+ }
114
+ function getHeader(init, name) {
115
+ const headers = init?.headers;
116
+ if (!headers)
117
+ return undefined;
118
+ if (headers instanceof Headers)
119
+ return headers.get(name) ?? undefined;
120
+ if (Array.isArray(headers)) {
121
+ const found = headers.find(([k]) => k.toLowerCase() === name.toLowerCase());
122
+ return found?.[1];
123
+ }
124
+ const rec = headers;
125
+ const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase());
126
+ return key ? rec[key] : undefined;
127
+ }
128
+ export function createDriveFake() {
129
+ const files = new Map();
130
+ const permissions = new Map();
131
+ const folderCreationLog = [];
132
+ const statusOverrides = [];
133
+ function takeOverride(url) {
134
+ const idx = statusOverrides.findIndex((o) => o.remaining > 0 && matchesPattern(o.pattern, url));
135
+ if (idx === -1)
136
+ return undefined;
137
+ const override = statusOverrides[idx];
138
+ override.remaining -= 1;
139
+ if (override.remaining <= 0) {
140
+ statusOverrides.splice(idx, 1);
141
+ }
142
+ return override;
143
+ }
144
+ function fileToMetadata(f) {
145
+ return { id: f.id, name: f.name, mimeType: f.mimeType, parents: f.parents };
146
+ }
147
+ async function handleFilesList(url) {
148
+ const q = url.searchParams.get('q') ?? '';
149
+ const name = extractQuotedField(q, 'name');
150
+ const mimeType = extractQuotedField(q, 'mimeType');
151
+ const parentId = extractParentInParents(q);
152
+ const trashedMatch = /trashed\s*=\s*(true|false)/.exec(q);
153
+ const wantTrashed = trashedMatch ? trashedMatch[1] === 'true' : undefined;
154
+ if (mimeType === FOLDER_MIME_TYPE && name !== undefined) {
155
+ folderCreationLog.push(name);
156
+ }
157
+ const results = [...files.values()].filter((f) => {
158
+ if (name !== undefined && f.name !== name)
159
+ return false;
160
+ if (mimeType !== undefined && f.mimeType !== mimeType)
161
+ return false;
162
+ if (parentId !== undefined && !f.parents.includes(parentId))
163
+ return false;
164
+ if (wantTrashed !== undefined && Boolean(f.trashed) !== wantTrashed)
165
+ return false;
166
+ return true;
167
+ });
168
+ return jsonResponse({ files: results.map(fileToMetadata) });
169
+ }
170
+ async function handleFileGet(url, id) {
171
+ const file = files.get(id);
172
+ if (!file || file.trashed) {
173
+ return errorResponse(404, `File not found: ${id}.`);
174
+ }
175
+ if (url.searchParams.get('alt') === 'media') {
176
+ return new Response(file.content, {
177
+ status: 200,
178
+ headers: { 'Content-Type': file.contentType ?? 'application/octet-stream' },
179
+ });
180
+ }
181
+ return jsonResponse(fileToMetadata(file));
182
+ }
183
+ async function handleFilesCreate(url, init) {
184
+ const uploadType = url.searchParams.get('uploadType');
185
+ const contentType = getHeader(init, 'Content-Type') ?? '';
186
+ const bodyText = await readBodyText(init);
187
+ let metadata = {};
188
+ let content = '';
189
+ let mediaType;
190
+ if (uploadType === 'multipart' || contentType.includes('multipart/related')) {
191
+ const boundaryMatch = /boundary=(?:"([^"]+)"|([^\s;]+))/i.exec(contentType);
192
+ const boundary = boundaryMatch?.[1] ?? boundaryMatch?.[2];
193
+ if (boundary) {
194
+ const parsed = parseMultipartBody(bodyText, boundary);
195
+ metadata = parsed.metadata;
196
+ content = parsed.mediaContent;
197
+ mediaType = parsed.mediaType;
198
+ }
199
+ }
200
+ else if (uploadType === 'media') {
201
+ content = bodyText;
202
+ mediaType = contentType || undefined;
203
+ }
204
+ else {
205
+ try {
206
+ metadata = bodyText ? JSON.parse(bodyText) : {};
207
+ }
208
+ catch {
209
+ metadata = {};
210
+ }
211
+ }
212
+ const id = nextId();
213
+ const mimeType = metadata.mimeType ?? mediaType ?? 'application/octet-stream';
214
+ const file = {
215
+ id,
216
+ name: metadata.name ?? '',
217
+ mimeType,
218
+ parents: Array.isArray(metadata.parents) ? metadata.parents : [],
219
+ content,
220
+ contentType: mediaType,
221
+ };
222
+ files.set(id, file);
223
+ if (mimeType === FOLDER_MIME_TYPE) {
224
+ folderCreationLog.push(file.name);
225
+ }
226
+ return jsonResponse(fileToMetadata(file), 200);
227
+ }
228
+ async function handleFileUpdate(url, id, init) {
229
+ const file = files.get(id);
230
+ if (!file) {
231
+ return errorResponse(404, `File not found: ${id}.`);
232
+ }
233
+ const uploadType = url.searchParams.get('uploadType');
234
+ const bodyText = await readBodyText(init);
235
+ if (uploadType === 'media') {
236
+ file.content = bodyText;
237
+ const contentType = getHeader(init, 'Content-Type');
238
+ if (contentType)
239
+ file.contentType = contentType;
240
+ return jsonResponse(fileToMetadata(file));
241
+ }
242
+ if (uploadType === 'multipart') {
243
+ const contentType = getHeader(init, 'Content-Type') ?? '';
244
+ const boundaryMatch = /boundary=(?:"([^"]+)"|([^\s;]+))/i.exec(contentType);
245
+ const boundary = boundaryMatch?.[1] ?? boundaryMatch?.[2];
246
+ if (boundary) {
247
+ const parsed = parseMultipartBody(bodyText, boundary);
248
+ if (parsed.metadata.name !== undefined)
249
+ file.name = parsed.metadata.name;
250
+ if (parsed.metadata.mimeType !== undefined)
251
+ file.mimeType = parsed.metadata.mimeType;
252
+ if (Array.isArray(parsed.metadata.parents))
253
+ file.parents = parsed.metadata.parents;
254
+ file.content = parsed.mediaContent;
255
+ if (parsed.mediaType)
256
+ file.contentType = parsed.mediaType;
257
+ }
258
+ return jsonResponse(fileToMetadata(file));
259
+ }
260
+ // Plain metadata PATCH (JSON body).
261
+ try {
262
+ const metadata = bodyText ? JSON.parse(bodyText) : {};
263
+ if (metadata.name !== undefined)
264
+ file.name = metadata.name;
265
+ if (metadata.mimeType !== undefined)
266
+ file.mimeType = metadata.mimeType;
267
+ if (Array.isArray(metadata.parents))
268
+ file.parents = metadata.parents;
269
+ if (metadata.trashed !== undefined)
270
+ file.trashed = metadata.trashed;
271
+ }
272
+ catch {
273
+ // ignore malformed body
274
+ }
275
+ // Support addParents / removeParents query params used by the real API.
276
+ const addParents = url.searchParams.get('addParents');
277
+ const removeParents = url.searchParams.get('removeParents');
278
+ if (addParents) {
279
+ for (const p of addParents.split(',')) {
280
+ if (!file.parents.includes(p))
281
+ file.parents.push(p);
282
+ }
283
+ }
284
+ if (removeParents) {
285
+ const removeSet = new Set(removeParents.split(','));
286
+ file.parents = file.parents.filter((p) => !removeSet.has(p));
287
+ }
288
+ return jsonResponse(fileToMetadata(file));
289
+ }
290
+ async function handleFileDelete(id) {
291
+ if (!files.has(id)) {
292
+ return errorResponse(404, `File not found: ${id}.`);
293
+ }
294
+ files.delete(id);
295
+ permissions.delete(id);
296
+ return new Response(null, { status: 204 });
297
+ }
298
+ async function handlePermissionsList(fileId) {
299
+ const perms = permissions.get(fileId);
300
+ return jsonResponse({ permissions: perms ? [...perms.values()] : [] });
301
+ }
302
+ async function handlePermissionsCreate(fileId, init) {
303
+ const bodyText = await readBodyText(init);
304
+ let body = {};
305
+ try {
306
+ body = bodyText ? JSON.parse(bodyText) : {};
307
+ }
308
+ catch {
309
+ body = {};
310
+ }
311
+ const id = nextId();
312
+ const permission = {
313
+ id,
314
+ type: body.type ?? 'user',
315
+ role: body.role ?? 'reader',
316
+ emailAddress: body.emailAddress,
317
+ };
318
+ if (!permissions.has(fileId))
319
+ permissions.set(fileId, new Map());
320
+ permissions.get(fileId).set(id, permission);
321
+ return jsonResponse(permission);
322
+ }
323
+ async function handlePermissionUpdate(fileId, permId, init) {
324
+ const perms = permissions.get(fileId);
325
+ const permission = perms?.get(permId);
326
+ if (!permission) {
327
+ return errorResponse(404, `Permission not found: ${permId}.`);
328
+ }
329
+ const bodyText = await readBodyText(init);
330
+ try {
331
+ const body = bodyText ? JSON.parse(bodyText) : {};
332
+ if (body.role !== undefined)
333
+ permission.role = body.role;
334
+ }
335
+ catch {
336
+ // ignore malformed body
337
+ }
338
+ return jsonResponse(permission);
339
+ }
340
+ async function handlePermissionDelete(fileId, permId) {
341
+ const perms = permissions.get(fileId);
342
+ if (!perms?.has(permId)) {
343
+ return errorResponse(404, `Permission not found: ${permId}.`);
344
+ }
345
+ perms.delete(permId);
346
+ return new Response(null, { status: 204 });
347
+ }
348
+ const fakeFetch = async (input, init) => {
349
+ const url = typeof input === 'string' ? input : input?.url ?? String(input);
350
+ if (!url.startsWith(DRIVE_BASE) && !url.startsWith(UPLOAD_BASE)) {
351
+ throw new Error(`driveFake: unhandled URL (not a Drive API endpoint): ${url}`);
352
+ }
353
+ const override = takeOverride(url);
354
+ if (override) {
355
+ return errorResponse(override.status, 'Forced error (test override).', override.retryAfter, override.body);
356
+ }
357
+ const parsedUrl = new URL(url);
358
+ const method = (init?.method ?? 'GET').toUpperCase();
359
+ const base = url.startsWith(UPLOAD_BASE) ? UPLOAD_BASE : DRIVE_BASE;
360
+ const path = parsedUrl.pathname.slice(new URL(base).pathname.length).replace(/^\/+/, '');
361
+ const segments = path.split('/').filter(Boolean);
362
+ try {
363
+ // files collection: files, files/{id}, files/{id}/permissions, files/{id}/permissions/{permId}
364
+ if (segments[0] === 'files') {
365
+ if (segments.length === 1) {
366
+ if (method === 'GET')
367
+ return await handleFilesList(parsedUrl);
368
+ if (method === 'POST')
369
+ return await handleFilesCreate(parsedUrl, init);
370
+ }
371
+ else if (segments.length === 2) {
372
+ const id = segments[1];
373
+ if (method === 'GET')
374
+ return await handleFileGet(parsedUrl, id);
375
+ if (method === 'PATCH' || method === 'PUT')
376
+ return await handleFileUpdate(parsedUrl, id, init);
377
+ if (method === 'DELETE')
378
+ return await handleFileDelete(id);
379
+ }
380
+ else if (segments.length === 3 && segments[2] === 'permissions') {
381
+ const id = segments[1];
382
+ if (method === 'GET')
383
+ return await handlePermissionsList(id);
384
+ if (method === 'POST')
385
+ return await handlePermissionsCreate(id, init);
386
+ }
387
+ else if (segments.length === 4 && segments[2] === 'permissions') {
388
+ const id = segments[1];
389
+ const permId = segments[3];
390
+ if (method === 'PATCH' || method === 'PUT')
391
+ return await handlePermissionUpdate(id, permId, init);
392
+ if (method === 'DELETE')
393
+ return await handlePermissionDelete(id, permId);
394
+ }
395
+ }
396
+ }
397
+ catch (err) {
398
+ return errorResponse(500, err instanceof Error ? err.message : String(err));
399
+ }
400
+ return errorResponse(404, `driveFake: unhandled request ${method} ${url}`);
401
+ };
402
+ return {
403
+ fetch: fakeFetch,
404
+ folderCreationLog,
405
+ setStatusOverride(pattern, status, opts) {
406
+ statusOverrides.push({
407
+ pattern,
408
+ status,
409
+ retryAfter: opts?.retryAfter,
410
+ body: opts?.body,
411
+ remaining: opts?.times ?? 1,
412
+ });
413
+ },
414
+ reset() {
415
+ files.clear();
416
+ permissions.clear();
417
+ folderCreationLog.length = 0;
418
+ statusOverrides.length = 0;
419
+ },
420
+ files,
421
+ permissions,
422
+ };
423
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A scriptable double for Google Identity Services (GIS) token client,
3
+ * for use in tests of code that calls
4
+ * `window.google.accounts.oauth2.initTokenClient(...)`.
5
+ *
6
+ * Usage:
7
+ *
8
+ * ```ts
9
+ * const gisFake = createGisFake()
10
+ * gisFake.install()
11
+ * gisFake.queueResponse({ access_token: 'tok', expires_in: 3600, scope: 'a b' })
12
+ * // ... exercise code under test ...
13
+ * expect(gisFake.calls).toEqual([{ prompt: 'consent', hint: undefined, scope: 'a b' }])
14
+ * ```
15
+ */
16
+ export interface GisTokenResponse {
17
+ access_token?: string;
18
+ expires_in?: number;
19
+ scope?: string;
20
+ error?: string;
21
+ }
22
+ export interface GisRecordedCall {
23
+ prompt: string;
24
+ hint?: string;
25
+ scope: string;
26
+ }
27
+ export interface GisTokenClientConfig {
28
+ client_id?: string;
29
+ scope?: string;
30
+ prompt?: string;
31
+ hint?: string;
32
+ callback?: (response: GisTokenResponse) => void;
33
+ error_callback?: (error: {
34
+ type?: string;
35
+ message?: string;
36
+ }) => void;
37
+ [key: string]: unknown;
38
+ }
39
+ export interface GisRequestAccessTokenOverride {
40
+ prompt?: string;
41
+ hint?: string;
42
+ scope?: string;
43
+ [key: string]: unknown;
44
+ }
45
+ export interface GisTokenClient {
46
+ requestAccessToken(overrideConfig?: GisRequestAccessTokenOverride): void;
47
+ }
48
+ export interface GisFake {
49
+ /** All calls made via `requestAccessToken`, in order. */
50
+ calls: GisRecordedCall[];
51
+ /** Queue a response to be delivered to the next `requestAccessToken` call. */
52
+ queueResponse(response: GisTokenResponse): void;
53
+ /** Stub `window.google.accounts.oauth2.initTokenClient` with this fake. */
54
+ install(): void;
55
+ /** Remove the stub installed by `install()`, restoring prior state. */
56
+ uninstall(): void;
57
+ /** Clear queued responses and call history. */
58
+ reset(): void;
59
+ }
60
+ export declare function createGisFake(): GisFake;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * A scriptable double for Google Identity Services (GIS) token client,
3
+ * for use in tests of code that calls
4
+ * `window.google.accounts.oauth2.initTokenClient(...)`.
5
+ *
6
+ * Usage:
7
+ *
8
+ * ```ts
9
+ * const gisFake = createGisFake()
10
+ * gisFake.install()
11
+ * gisFake.queueResponse({ access_token: 'tok', expires_in: 3600, scope: 'a b' })
12
+ * // ... exercise code under test ...
13
+ * expect(gisFake.calls).toEqual([{ prompt: 'consent', hint: undefined, scope: 'a b' }])
14
+ * ```
15
+ */
16
+ export function createGisFake() {
17
+ const responseQueue = [];
18
+ const calls = [];
19
+ let previousGoogle;
20
+ let hadGoogle = false;
21
+ function nextResponse() {
22
+ const next = responseQueue.shift();
23
+ if (next)
24
+ return next;
25
+ // Default: a generic successful token response.
26
+ return { access_token: 'fake-access-token', expires_in: 3600, scope: '' };
27
+ }
28
+ function initTokenClient(config) {
29
+ return {
30
+ requestAccessToken(overrideConfig) {
31
+ const prompt = overrideConfig?.prompt ?? config.prompt ?? 'consent';
32
+ const hint = overrideConfig?.hint ?? config.hint;
33
+ const scope = overrideConfig?.scope ?? config.scope ?? '';
34
+ calls.push({ prompt, hint, scope });
35
+ const response = nextResponse();
36
+ const callback = config.callback;
37
+ // Deliver asynchronously (microtask), matching the real GIS client's
38
+ // callback-based, non-synchronous delivery.
39
+ queueMicrotask(() => {
40
+ if (response.error) {
41
+ // The real GIS client invokes the token callback with an object
42
+ // containing an `error` field (e.g. `{ error: 'access_denied' }`)
43
+ // rather than throwing or rejecting a promise.
44
+ callback?.({ error: response.error });
45
+ return;
46
+ }
47
+ callback?.(response);
48
+ });
49
+ },
50
+ };
51
+ }
52
+ return {
53
+ calls,
54
+ queueResponse(response) {
55
+ responseQueue.push(response);
56
+ },
57
+ install() {
58
+ const w = globalThis;
59
+ hadGoogle = Object.prototype.hasOwnProperty.call(w, 'google');
60
+ previousGoogle = w.google;
61
+ if (!w.google) {
62
+ w.google = {};
63
+ }
64
+ if (!w.google.accounts) {
65
+ w.google.accounts = {};
66
+ }
67
+ if (!w.google.accounts.oauth2) {
68
+ w.google.accounts.oauth2 = {};
69
+ }
70
+ w.google.accounts.oauth2.initTokenClient = initTokenClient;
71
+ },
72
+ uninstall() {
73
+ const w = globalThis;
74
+ if (hadGoogle) {
75
+ w.google = previousGoogle;
76
+ }
77
+ else {
78
+ delete w.google;
79
+ }
80
+ },
81
+ reset() {
82
+ responseQueue.length = 0;
83
+ calls.length = 0;
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,4 @@
1
+ export { createGisFake } from './gisFake.js';
2
+ export type { GisFake, GisTokenResponse, GisRecordedCall } from './gisFake.js';
3
+ export { createDriveFake } from './driveFake.js';
4
+ export type { DriveFake, DriveFakeFile, DriveFakePermission, StatusOverrideOptions } from './driveFake.js';
@@ -0,0 +1,2 @@
1
+ export { createGisFake } from './gisFake.js';
2
+ export { createDriveFake } from './driveFake.js';
@@ -0,0 +1,54 @@
1
+ import type { Logger } from './logger.js';
2
+ import type { StoredToken } from './types.js';
3
+ /** Minimal shape of the GIS token response passed to a token client's callback. */
4
+ interface GisTokenResponse {
5
+ access_token?: string;
6
+ expires_in?: number;
7
+ scope?: string;
8
+ error?: string;
9
+ }
10
+ /**
11
+ * Persists a freshly-acquired GIS token response as a StoredToken, deriving
12
+ * expiresAt from the response's own expires_in (never hardcoded) and
13
+ * grantedScopes from the response's space-delimited scope string.
14
+ */
15
+ export declare function persistTokenResponse(appId: string, projectId: string, response: GisTokenResponse): Promise<StoredToken>;
16
+ export interface AcquireTokenOptions {
17
+ appId: string;
18
+ projectId: string;
19
+ clientId: string;
20
+ scopes: string[];
21
+ interactive: boolean;
22
+ hint?: string;
23
+ logger?: Logger;
24
+ }
25
+ /**
26
+ * Records that another tab just persisted a fresh token for `projectId` (via
27
+ * a cross-tab `token` broadcast). The NEXT non-interactive `acquireToken`
28
+ * call for this project will, instead of unconditionally starting a new GIS
29
+ * round-trip, first re-read the token this tab already shares via
30
+ * IndexedDB — skipping the redundant network request when that stored token
31
+ * turns out to already be usable.
32
+ */
33
+ export declare function notifyExternalTokenRefresh(projectId: string): void;
34
+ /**
35
+ * Single entry point for acquiring a Drive access token, used by BOTH the
36
+ * interactive "connect" path (interactive: true -> prompt: 'consent', no
37
+ * hint) and the silent "refresh" path (interactive: false -> prompt: 'none',
38
+ * hint: the connection's known email).
39
+ *
40
+ * Design contract (see report): if `interactive` is false and GIS reports an
41
+ * error on the silent attempt, this function throws a NeedsReauthError
42
+ * itself (rather than pushing that decision to the caller). Callers that
43
+ * want to surface a different error type on the interactive path may catch
44
+ * and rethrow.
45
+ *
46
+ * Every call creates a FRESH `initTokenClient` — there is no module-level
47
+ * client, no module-level resolve/reject, and no module-level in-flight
48
+ * promise. Concurrent calls for different (projectId, scopes) pairs cannot
49
+ * collide; concurrent calls for the SAME (projectId, scopes) pair are
50
+ * coalesced onto a single in-flight promise (removed from the map in a
51
+ * `finally` once it settles).
52
+ */
53
+ export declare function acquireToken(opts: AcquireTokenOptions): Promise<StoredToken>;
54
+ export {};