From 7df75e6b430e080bf8f3931297ec171d86ff19ce Mon Sep 17 00:00:00 2001 From: Alaeddin Date: Thu, 10 Sep 2026 21:15:51 +0100 Subject: [PATCH 1/3] fix(core): reach shared drives from the Google Drive picker behind a default-off sharedDrives flag --- .changeset/google-drive-shared-drives.md | 27 ++++ .../upupuploader/optional-props.mdx | 2 + packages/core/src/drives/configs.ts | 11 ++ .../core/src/drives/google-drive-plugin.ts | 24 +++ packages/core/src/types/uploader-props.ts | 8 +- packages/core/tests/drive-configs.test.ts | 18 +++ .../core/tests/google-drive-plugin.test.ts | 142 ++++++++++++++++++ packages/vanilla/src/lib/types.ts | 8 +- 8 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 .changeset/google-drive-shared-drives.md diff --git a/.changeset/google-drive-shared-drives.md b/.changeset/google-drive-shared-drives.md new file mode 100644 index 000000000..d878a4add --- /dev/null +++ b/.changeset/google-drive-shared-drives.md @@ -0,0 +1,27 @@ +--- +'@useupup/core': patch +'@useupup/vanilla': patch +--- + +The Google Drive picker can reach shared drives, behind a default-off +`cloudDrives.googleDrive.sharedDrives` flag. + +`GoogleDrivePlugin.loadFiles` and `loadMoreFiles` sent a Drive v3 `files.list` +with no `corpora`, no `includeItemsFromAllDrives` and no `supportsAllDrives`, so +the API answered from the signed-in user's own My Drive corpus only. For a +business account that is most of the person's files: a file living in a shared +drive never appeared at any depth, and the picker's search box did not +compensate because it filters the children already loaded rather than issuing a +query. The requested scope was never the limit — `drive.readonly` covers shared +drives already. + +With `sharedDrives: true` both listing calls now send `corpora=allDrives`, +`includeItemsFromAllDrives=true` and `supportsAllDrives=true`, and the +single-file download sends `supportsAllDrives=true` so a file the widened +listing surfaced can actually be fetched instead of answering 404. `corpora` and +`includeItemsFromAllDrives` are `files.list`-only and stay off the download. + +The flag defaults off and nothing is sent when it is unset or false, so no +existing picker changes shape. `'' in parents` still bounds each +listing, so this widens what the picker can reach and download, not what the +root view enumerates on its own. diff --git a/apps/landing/content/docs/api-reference/upupuploader/optional-props.mdx b/apps/landing/content/docs/api-reference/upupuploader/optional-props.mdx index 82fea5603..94fca6c80 100644 --- a/apps/landing/content/docs/api-reference/upupuploader/optional-props.mdx +++ b/apps/landing/content/docs/api-reference/upupuploader/optional-props.mdx @@ -397,6 +397,8 @@ type UploadSource = Browser-safe cloud provider configuration for client mode. Google Drive takes `clientId`, `apiKey`, and `appId`; OneDrive, Dropbox, and Box each take `clientId` plus an optional `redirectUri`. +Google Drive also takes an optional `sharedDrives` boolean, default `false`. Left off, the picker lists the signed-in user's own My Drive only — a Drive `files.list` with no corpus set answers from that corpus, so a file living in a shared drive is invisible at every depth. Turn it on to widen the listing to shared drives; it needs no extra OAuth scope, and the picker keeps its current shape for everyone who leaves it unset. + ```tsx { + return this.config.sharedDrives + ? { + corpora: 'allDrives', + includeItemsFromAllDrives: 'true', + supportsAllDrives: 'true', + } + : {} + } + // ── Plugin lifecycle ── configure(config: GoogleDriveConfig): this { @@ -264,6 +281,7 @@ export class GoogleDrivePlugin implements DrivePlugin { fields: 'nextPageToken,files(fileExtension,id,mimeType,name,parents,size,thumbnailLink)', key: this.config.apiKey, pageSize: '1000', + ...this.sharedDriveParams(), }) const res = await this.apiRequest( @@ -325,6 +343,7 @@ export class GoogleDrivePlugin implements DrivePlugin { key: this.config.apiKey, pageSize: '1000', pageToken, + ...this.sharedDriveParams(), }) const res = await this.apiRequest( @@ -392,9 +411,14 @@ export class GoogleDrivePlugin implements DrivePlugin { private async downloadRegularFile( driveFile: DriveFile, ): Promise { + // `supportsAllDrives` is the files.get half of #391: without it a file + // the widened listing surfaced answers 404 on download, which would make + // the picker list shared-drive files it cannot fetch. `corpora` and + // `includeItemsFromAllDrives` are files.list-only and stay out of here. const params = new URLSearchParams({ key: this.config.apiKey, alt: 'media', + ...(this.config.sharedDrives ? { supportsAllDrives: 'true' } : {}), }) const res = await this.apiRequest( diff --git a/packages/core/src/types/uploader-props.ts b/packages/core/src/types/uploader-props.ts index ef6fc1d95..de6d6f206 100644 --- a/packages/core/src/types/uploader-props.ts +++ b/packages/core/src/types/uploader-props.ts @@ -75,7 +75,13 @@ export type UploaderBaseProps = { /** Cloud drive configurations. */ cloudDrives?: | { - googleDrive?: { clientId: string; apiKey: string; appId: string } + googleDrive?: { + clientId: string + apiKey: string + appId: string + /** Reach shared drives, not just My Drive (#391). Default false. */ + sharedDrives?: boolean + } oneDrive?: { clientId: string; redirectUri?: string } dropbox?: { clientId: string; redirectUri?: string } box?: { clientId: string; redirectUri?: string } diff --git a/packages/core/tests/drive-configs.test.ts b/packages/core/tests/drive-configs.test.ts index 54fab07bf..8cbf8e412 100644 --- a/packages/core/tests/drive-configs.test.ts +++ b/packages/core/tests/drive-configs.test.ts @@ -15,6 +15,24 @@ describe('drive configs exported from core (one camelCase shape)', () => { appId: string }>() }) + it('GoogleDriveConfig accepts an optional sharedDrives flag and still typechecks without it (#391)', () => { + const withoutFlag: GoogleDriveConfig = { + clientId: 'g', + apiKey: 'k', + appId: 'a', + } + const withFlag: GoogleDriveConfig = { + clientId: 'g', + apiKey: 'k', + appId: 'a', + sharedDrives: true, + } + expectTypeOf(withoutFlag).toMatchTypeOf() + expectTypeOf(withFlag).toMatchTypeOf() + expectTypeOf().toEqualTypeOf< + boolean | undefined + >() + }) it('OneDriveConfig requires clientId; redirectUri optional', () => { const _: OneDriveConfig = { clientId: 'c' } expectTypeOf(_).toMatchTypeOf() diff --git a/packages/core/tests/google-drive-plugin.test.ts b/packages/core/tests/google-drive-plugin.test.ts index de6052bcf..7a44a713a 100644 --- a/packages/core/tests/google-drive-plugin.test.ts +++ b/packages/core/tests/google-drive-plugin.test.ts @@ -718,6 +718,148 @@ describe('GoogleDrivePlugin', () => { // File download (regular files) // ──────────────────────────────────────────── + // ──────────────────────────────────────────── + // Shared drives (#391) + // ──────────────────────────────────────────── + + describe('sharedDrives config option (#391)', () => { + const SHARED_DRIVE_LIST_PARAMS = [ + 'corpora', + 'includeItemsFromAllDrives', + 'supportsAllDrives', + ] as const + + function configureWithSharedDrives(enabled: boolean): void { + plugin.configure({ + apiKey: 'test-api-key', + appId: 'test-app-id', + clientId: 'test-client-id', + sharedDrives: enabled, + }) + plugin.setAccessToken('valid-token', 3600) + } + + function paramsOfLastRequest( + fetchMock: ReturnType, + ): URLSearchParams { + const url = fetchMock.mock.calls.at(-1)![0] as string + return new URL(url).searchParams + } + + it('omits every shared-drive param from loadFiles when the option is unset, so an existing picker keeps its My-Drive-only shape', async () => { + const fetchMock = mockFetchResponse({ files: [] }) + vi.stubGlobal('fetch', fetchMock) + plugin.setAccessToken('valid-token', 3600) + + await plugin.loadFiles() + + const params = paramsOfLastRequest(fetchMock) + for (const name of SHARED_DRIVE_LIST_PARAMS) { + expect(params.has(name)).toBe(false) + } + }) + + it('omits every shared-drive param from loadFiles when the option is explicitly false', async () => { + const fetchMock = mockFetchResponse({ files: [] }) + vi.stubGlobal('fetch', fetchMock) + configureWithSharedDrives(false) + + await plugin.loadFiles() + + const params = paramsOfLastRequest(fetchMock) + for (const name of SHARED_DRIVE_LIST_PARAMS) { + expect(params.has(name)).toBe(false) + } + }) + + it('sends corpora=allDrives with both all-drives flags on loadFiles when the option is on', async () => { + const fetchMock = mockFetchResponse({ files: [] }) + vi.stubGlobal('fetch', fetchMock) + configureWithSharedDrives(true) + + await plugin.loadFiles() + + const params = paramsOfLastRequest(fetchMock) + expect(params.get('corpora')).toBe('allDrives') + expect(params.get('includeItemsFromAllDrives')).toBe('true') + expect(params.get('supportsAllDrives')).toBe('true') + }) + + it('keeps the folder query and the api key alongside the shared-drive params on loadFiles', async () => { + const fetchMock = mockFetchResponse({ files: [] }) + vi.stubGlobal('fetch', fetchMock) + configureWithSharedDrives(true) + + await plugin.loadFiles('folder-in-a-shared-drive') + + const params = paramsOfLastRequest(fetchMock) + expect(params.get('q')).toContain( + "'folder-in-a-shared-drive' in parents", + ) + expect(params.get('key')).toBe('test-api-key') + expect(params.get('corpora')).toBe('allDrives') + }) + + it('sends the shared-drive params on the paginated loadMoreFiles call too, so page 2 does not narrow back to My Drive', async () => { + const fetchMock = mockFetchResponse({ files: [] }) + vi.stubGlobal('fetch', fetchMock) + configureWithSharedDrives(true) + + await plugin.loadMoreFiles( + JSON.stringify({ + folderId: 'shared-folder', + pageToken: 'page-2-token', + }), + ) + + const params = paramsOfLastRequest(fetchMock) + expect(params.get('pageToken')).toBe('page-2-token') + expect(params.get('corpora')).toBe('allDrives') + expect(params.get('includeItemsFromAllDrives')).toBe('true') + expect(params.get('supportsAllDrives')).toBe('true') + }) + + it('omits the shared-drive params from loadMoreFiles when the option is off', async () => { + const fetchMock = mockFetchResponse({ files: [] }) + vi.stubGlobal('fetch', fetchMock) + plugin.setAccessToken('valid-token', 3600) + + await plugin.loadMoreFiles( + JSON.stringify({ folderId: 'root', pageToken: 'p2' }), + ) + + const params = paramsOfLastRequest(fetchMock) + for (const name of SHARED_DRIVE_LIST_PARAMS) { + expect(params.has(name)).toBe(false) + } + }) + + it('sends supportsAllDrives when downloading a file so a listed shared-drive file does not 404 on fetch', async () => { + const fetchMock = mockFetchResponse('file-content') + vi.stubGlobal('fetch', fetchMock) + configureWithSharedDrives(true) + + await plugin.downloadFile(makeDriveFile({ id: 'shared-file-id' })) + + const params = paramsOfLastRequest(fetchMock) + expect(params.get('alt')).toBe('media') + expect(params.get('supportsAllDrives')).toBe('true') + }) + + it('omits supportsAllDrives from the download when the option is off, and never sends the list-only params there', async () => { + const fetchMock = mockFetchResponse('file-content') + vi.stubGlobal('fetch', fetchMock) + plugin.setAccessToken('valid-token', 3600) + + await plugin.downloadFile(makeDriveFile({ id: 'my-drive-file-id' })) + + const params = paramsOfLastRequest(fetchMock) + expect(params.has('supportsAllDrives')).toBe(false) + expect(params.has('corpora')).toBe(false) + expect(params.has('includeItemsFromAllDrives')).toBe(false) + }) + }) + describe('downloadFiles() - regular files', () => { beforeEach(() => { plugin.setAccessToken('valid-token', 3600) diff --git a/packages/vanilla/src/lib/types.ts b/packages/vanilla/src/lib/types.ts index 4dc314926..b34850df2 100644 --- a/packages/vanilla/src/lib/types.ts +++ b/packages/vanilla/src/lib/types.ts @@ -46,7 +46,13 @@ export interface UploaderSnapshot { /** Cloud-drive config shape accepted by createUploader (mirrors svelte UploaderProps.cloudDrives). */ export interface VanillaCloudDrives { - googleDrive?: { clientId: string; apiKey: string; appId: string } + googleDrive?: { + clientId: string + apiKey: string + appId: string + /** Reach shared drives, not just My Drive (#391). Default false. */ + sharedDrives?: boolean + } oneDrive?: { clientId: string; redirectUri?: string } dropbox?: { clientId: string; redirectUri?: string } box?: { clientId: string; redirectUri?: string } From 7ea36acf13cd58e7ddbc5a01cc6cbb182e0f9fe5 Mon Sep 17 00:00:00 2001 From: Alaeddin Date: Thu, 10 Sep 2026 21:43:19 +0100 Subject: [PATCH 2/3] fix(core): list shared drives and Shared-with-me at the Google Drive picker root behind the sharedDrives flag --- .changeset/google-drive-shared-drives.md | 40 ++- .../upupuploader/optional-props.mdx | 2 +- .../core/src/drives/google-drive-plugin.ts | 118 ++++++- .../core/tests/google-drive-plugin.test.ts | 301 ++++++++++++++++-- 4 files changed, 425 insertions(+), 36 deletions(-) diff --git a/.changeset/google-drive-shared-drives.md b/.changeset/google-drive-shared-drives.md index d878a4add..b59d01596 100644 --- a/.changeset/google-drive-shared-drives.md +++ b/.changeset/google-drive-shared-drives.md @@ -3,8 +3,8 @@ '@useupup/vanilla': patch --- -The Google Drive picker can reach shared drives, behind a default-off -`cloudDrives.googleDrive.sharedDrives` flag. +The Google Drive picker can browse shared drives and "Shared with me", behind a +default-off `cloudDrives.googleDrive.sharedDrives` flag. `GoogleDrivePlugin.loadFiles` and `loadMoreFiles` sent a Drive v3 `files.list` with no `corpora`, no `includeItemsFromAllDrives` and no `supportsAllDrives`, so @@ -13,15 +13,31 @@ business account that is most of the person's files: a file living in a shared drive never appeared at any depth, and the picker's search box did not compensate because it filters the children already loaded rather than issuing a query. The requested scope was never the limit — `drive.readonly` covers shared -drives already. +drives, and `drives.list`, already. -With `sharedDrives: true` both listing calls now send `corpora=allDrives`, -`includeItemsFromAllDrives=true` and `supportsAllDrives=true`, and the -single-file download sends `supportsAllDrives=true` so a file the widened -listing surfaced can actually be fetched instead of answering 404. `corpora` and -`includeItemsFromAllDrives` are `files.list`-only and stay off the download. +With `sharedDrives: true`: -The flag defaults off and nothing is sent when it is unset or false, so no -existing picker changes shape. `'' in parents` still bounds each -listing, so this widens what the picker can reach and download, not what the -root view enumerates on its own. +- Both listing calls send `corpora=allDrives`, `includeItemsFromAllDrives=true` + and `supportsAllDrives=true`, and the single-file download sends + `supportsAllDrives=true` so a file the widened listing surfaced can actually be + fetched instead of answering 404. `corpora` and `includeItemsFromAllDrives` are + `files.list`-only and stay off the download. +- The ROOT listing appends the user's shared drives, from a paginated + `drives.list`, as navigable folder rows after the My Drive children. Those + params widen which files a query CAN return, but every listing is still + `'' in parents` and `'root'` resolves to My Drive root — so without + an entry to click, a shared drive stayed unreachable. A shared drive's root + folder id IS its drive id, so once one is listed the ordinary parent listing + walks it with no further special-casing. +- The root listing also carries one virtual "Shared with me" folder. Drive has no + parent whose children are the files others shared with you — it is the query + `sharedWithMe = true` — so that row uses a synthetic id the plugin branches on + in both `loadFiles` and `loadMoreFiles`. Every other part of the picker treats + it as an ordinary folder. + +Both additions land on the root's first page only, never on a continuation page, +so they appear exactly once and pagination is unaffected in either the shared +drives or the "Shared with me" view. + +The flag defaults off. Nothing is sent, no `drives.list` is issued and no extra +row appears when it is unset or false, so no existing picker changes shape. diff --git a/apps/landing/content/docs/api-reference/upupuploader/optional-props.mdx b/apps/landing/content/docs/api-reference/upupuploader/optional-props.mdx index 94fca6c80..726964c3a 100644 --- a/apps/landing/content/docs/api-reference/upupuploader/optional-props.mdx +++ b/apps/landing/content/docs/api-reference/upupuploader/optional-props.mdx @@ -397,7 +397,7 @@ type UploadSource = Browser-safe cloud provider configuration for client mode. Google Drive takes `clientId`, `apiKey`, and `appId`; OneDrive, Dropbox, and Box each take `clientId` plus an optional `redirectUri`. -Google Drive also takes an optional `sharedDrives` boolean, default `false`. Left off, the picker lists the signed-in user's own My Drive only — a Drive `files.list` with no corpus set answers from that corpus, so a file living in a shared drive is invisible at every depth. Turn it on to widen the listing to shared drives; it needs no extra OAuth scope, and the picker keeps its current shape for everyone who leaves it unset. +Google Drive also takes an optional `sharedDrives` boolean, default `false`. Left off, the picker lists the signed-in user's own My Drive only — a Drive `files.list` with no corpus set answers from that corpus, so a file living in a shared drive is invisible at every depth. Turn it on and the root of the picker also lists each of the user's shared drives as a folder, plus a "Shared with me" folder, both browsable and downloadable like any other. It needs no extra OAuth scope, and the picker keeps its current shape for everyone who leaves it unset. ```tsx ' in parents` cannot express + * it. `loadFiles`/`loadMoreFiles` branch on this one value; everything else in + * the picker (navigation, breadcrumbs, pagination) treats it as an ordinary + * folder id and needs no knowledge of it. + */ +export const SHARED_WITH_ME_FOLDER_ID = '__upup_shared_with_me__' + +/** + * Shown as the label of that virtual folder. English, like the per-provider root + * names in `drive-browser-descriptors.ts` — plugins have no translator, and a + * localised label needs a marker on `DriveFile` that no other entry carries. + */ +const SHARED_WITH_ME_FOLDER_NAME = 'Shared with me' + // ── Google Drive API response shapes (only the fields this file reads) ── interface GoogleUserInfoResponse { @@ -26,6 +46,11 @@ interface GoogleFilesListResponse { nextPageToken?: string } +interface GoogleDrivesListResponse { + drives?: { id?: string; name?: string }[] + nextPageToken?: string +} + // ── Google Workspace export mapping ── const WORKSPACE_EXPORT_MAP: Record< @@ -133,6 +158,78 @@ export class GoogleDrivePlugin implements DrivePlugin { : {} } + /** + * The `files.list` query for one picker folder. Every id is a parent except + * the synthetic "Shared with me" one, which is a query instead — Drive has no + * folder whose children are the files other people shared with you. + */ + private listQuery(parentId: string): string { + return parentId === SHARED_WITH_ME_FOLDER_ID + ? 'sharedWithMe = true and trashed = false' + : `'${parentId}' in parents and trashed = false` + } + + /** + * The user's shared drives as navigable folder entries (#391). + * + * `corpora=allDrives` widens which files a query CAN return, but every + * listing is still `'' in parents`, and `'root'` resolves to My + * Drive root — so without this a shared drive has no entry to click and its + * contents stay unreachable at every depth. A shared drive's root folder id + * IS its drive id, so once one is listed the ordinary parent listing walks it + * with no further special-casing. + * + * Requires no extra OAuth scope: `drive.readonly` covers `drives.list`. + */ + private async listSharedDriveFolders(): Promise { + const folders: DriveFile[] = [] + let pageToken: string | undefined + + // Bounded at 10 pages of 100. Someone in more than a thousand shared + // drives is past what a flat picker list serves anyway, and the cap means + // a malformed nextPageToken cannot spin here forever. + for (let page = 0; page < 10; page++) { + const params = new URLSearchParams({ + pageSize: '100', + fields: 'nextPageToken,drives(id,name)', + key: this.config.apiKey, + }) + if (pageToken) params.set('pageToken', pageToken) + + // oxlint-disable-next-line no-await-in-loop -- cursor pagination: each page's token comes from the previous response, so these cannot run in parallel + const res = await this.apiRequest( + `${DRIVES_URL}?${params.toString()}`, + { method: 'GET' }, + ) + // oxlint-disable-next-line no-await-in-loop -- same round trip as the request above + const data = (await res.json()) as GoogleDrivesListResponse + + for (const drive of data.drives ?? []) { + folders.push( + mapGoogleEntry({ + id: drive.id, + name: drive.name, + mimeType: FOLDER_MIME, + }), + ) + } + + pageToken = data.nextPageToken + if (!pageToken) break + } + + return folders + } + + /** The virtual "Shared with me" entry, shaped like any other folder row. */ + private sharedWithMeFolder(): DriveFile { + return mapGoogleEntry({ + id: SHARED_WITH_ME_FOLDER_ID, + name: SHARED_WITH_ME_FOLDER_NAME, + mimeType: FOLDER_MIME, + }) + } + // ── Plugin lifecycle ── configure(config: GoogleDriveConfig): this { @@ -274,10 +371,9 @@ export class GoogleDrivePlugin implements DrivePlugin { try { const parentId = folderId || 'root' - const q = `'${parentId}' in parents and trashed = false` const params = new URLSearchParams({ - q, + q: this.listQuery(parentId), fields: 'nextPageToken,files(fileExtension,id,mimeType,name,parents,size,thumbnailLink)', key: this.config.apiKey, pageSize: '1000', @@ -291,6 +387,17 @@ export class GoogleDrivePlugin implements DrivePlugin { const data = (await res.json()) as GoogleFilesListResponse const files: DriveFile[] = (data.files ?? []).map(mapGoogleEntry) + + // The two doors out of My Drive, appended to the root page only — + // after its own children, and never on a continuation page, so they + // appear exactly once (#391). + if (this.config.sharedDrives && parentId === 'root') { + files.push( + ...(await this.listSharedDriveFolders()), + this.sharedWithMeFolder(), + ) + } + const hasMore = !!data.nextPageToken const cursor = hasMore ? JSON.stringify({ @@ -335,10 +442,11 @@ export class GoogleDrivePlugin implements DrivePlugin { folderId: string pageToken: string } - const q = `'${folderId}' in parents and trashed = false` - const params = new URLSearchParams({ - q, + // Same builder as loadFiles, so page 2 of the "Shared with me" + // view stays a sharedWithMe query and does not silently become + // `'__upup_shared_with_me__' in parents`, which matches nothing. + q: this.listQuery(folderId), fields: 'nextPageToken,files(fileExtension,id,mimeType,name,parents,size,thumbnailLink)', key: this.config.apiKey, pageSize: '1000', diff --git a/packages/core/tests/google-drive-plugin.test.ts b/packages/core/tests/google-drive-plugin.test.ts index 7a44a713a..1d6733360 100644 --- a/packages/core/tests/google-drive-plugin.test.ts +++ b/packages/core/tests/google-drive-plugin.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { EventEmitter } from '../src/events' -import { GoogleDrivePlugin } from '../src/drives/google-drive-plugin' +import { + GoogleDrivePlugin, + SHARED_WITH_ME_FOLDER_ID, +} from '../src/drives/google-drive-plugin' import type { DriveFile } from '../src/drives/types' // ── Helpers ── @@ -37,6 +40,50 @@ function mockFetchResponse( }) } +/** + * Every request this run made to `endpoint`, as parsed query params. A root + * listing with `sharedDrives` on hits TWO endpoints, so a test must say which + * one it means rather than reading whichever call happened last. + */ +function requestsTo( + fetchMock: ReturnType, + endpoint: string, +): URLSearchParams[] { + return fetchMock.mock.calls + .map(call => call[0] as string) + .filter(url => url.startsWith(endpoint)) + .map(url => new URL(url).searchParams) +} + +function firstRequestTo( + fetchMock: ReturnType, + endpoint: string, +): URLSearchParams { + const found = requestsTo(fetchMock, endpoint)[0] + if (!found) throw new Error(`no request was made to ${endpoint}`) + return found +} + +function stubbedResponse(body: unknown) { + return { + ok: true, + status: 200, + json: vi.fn().mockResolvedValue(body), + text: vi.fn().mockResolvedValue(JSON.stringify(body)), + blob: vi.fn().mockResolvedValue(new Blob(['file-content'])), + } +} + +/** A fetch mock answering each call with the next body in the list. */ +function mockFetchSequence(bodies: unknown[]): ReturnType { + const mock = vi.fn() + for (const body of bodies) { + mock.mockResolvedValueOnce(stubbedResponse(body)) + } + mock.mockResolvedValue(stubbedResponse({})) + return mock +} + function makeDriveFile(overrides: Partial = {}): DriveFile { return { id: 'file-abc123', @@ -723,6 +770,8 @@ describe('GoogleDrivePlugin', () => { // ──────────────────────────────────────────── describe('sharedDrives config option (#391)', () => { + const FILES_ENDPOINT = 'https://www.googleapis.com/drive/v3/files' + const DRIVES_ENDPOINT = 'https://www.googleapis.com/drive/v3/drives' const SHARED_DRIVE_LIST_PARAMS = [ 'corpora', 'includeItemsFromAllDrives', @@ -739,13 +788,6 @@ describe('GoogleDrivePlugin', () => { plugin.setAccessToken('valid-token', 3600) } - function paramsOfLastRequest( - fetchMock: ReturnType, - ): URLSearchParams { - const url = fetchMock.mock.calls.at(-1)![0] as string - return new URL(url).searchParams - } - it('omits every shared-drive param from loadFiles when the option is unset, so an existing picker keeps its My-Drive-only shape', async () => { const fetchMock = mockFetchResponse({ files: [] }) vi.stubGlobal('fetch', fetchMock) @@ -753,10 +795,11 @@ describe('GoogleDrivePlugin', () => { await plugin.loadFiles() - const params = paramsOfLastRequest(fetchMock) + const params = firstRequestTo(fetchMock, FILES_ENDPOINT) for (const name of SHARED_DRIVE_LIST_PARAMS) { expect(params.has(name)).toBe(false) } + expect(requestsTo(fetchMock, DRIVES_ENDPOINT)).toHaveLength(0) }) it('omits every shared-drive param from loadFiles when the option is explicitly false', async () => { @@ -766,20 +809,43 @@ describe('GoogleDrivePlugin', () => { await plugin.loadFiles() - const params = paramsOfLastRequest(fetchMock) + const params = firstRequestTo(fetchMock, FILES_ENDPOINT) for (const name of SHARED_DRIVE_LIST_PARAMS) { expect(params.has(name)).toBe(false) } + expect(requestsTo(fetchMock, DRIVES_ENDPOINT)).toHaveLength(0) + }) + + it('returns only the real My Drive children at the root when the option is off — no shared drives and no Shared-with-me entry', async () => { + vi.stubGlobal( + 'fetch', + mockFetchSequence([ + { + files: [ + { + id: 'f1', + name: 'own.txt', + mimeType: 'text/plain', + }, + ], + }, + ]), + ) + plugin.setAccessToken('valid-token', 3600) + + const result = await plugin.loadFiles() + + expect(result.files.map(f => f.id)).toEqual(['f1']) }) it('sends corpora=allDrives with both all-drives flags on loadFiles when the option is on', async () => { - const fetchMock = mockFetchResponse({ files: [] }) + const fetchMock = mockFetchSequence([{ files: [] }, { drives: [] }]) vi.stubGlobal('fetch', fetchMock) configureWithSharedDrives(true) await plugin.loadFiles() - const params = paramsOfLastRequest(fetchMock) + const params = firstRequestTo(fetchMock, FILES_ENDPOINT) expect(params.get('corpora')).toBe('allDrives') expect(params.get('includeItemsFromAllDrives')).toBe('true') expect(params.get('supportsAllDrives')).toBe('true') @@ -792,7 +858,7 @@ describe('GoogleDrivePlugin', () => { await plugin.loadFiles('folder-in-a-shared-drive') - const params = paramsOfLastRequest(fetchMock) + const params = firstRequestTo(fetchMock, FILES_ENDPOINT) expect(params.get('q')).toContain( "'folder-in-a-shared-drive' in parents", ) @@ -812,14 +878,14 @@ describe('GoogleDrivePlugin', () => { }), ) - const params = paramsOfLastRequest(fetchMock) + const params = firstRequestTo(fetchMock, FILES_ENDPOINT) expect(params.get('pageToken')).toBe('page-2-token') expect(params.get('corpora')).toBe('allDrives') expect(params.get('includeItemsFromAllDrives')).toBe('true') expect(params.get('supportsAllDrives')).toBe('true') }) - it('omits the shared-drive params from loadMoreFiles when the option is off', async () => { + it('omits the shared-drive params from loadMoreFiles when the option is off, and asks for no drives list', async () => { const fetchMock = mockFetchResponse({ files: [] }) vi.stubGlobal('fetch', fetchMock) plugin.setAccessToken('valid-token', 3600) @@ -828,10 +894,12 @@ describe('GoogleDrivePlugin', () => { JSON.stringify({ folderId: 'root', pageToken: 'p2' }), ) - const params = paramsOfLastRequest(fetchMock) + const params = firstRequestTo(fetchMock, FILES_ENDPOINT) for (const name of SHARED_DRIVE_LIST_PARAMS) { expect(params.has(name)).toBe(false) } + expect(params.get('q')).toContain("'root' in parents") + expect(requestsTo(fetchMock, DRIVES_ENDPOINT)).toHaveLength(0) }) it('sends supportsAllDrives when downloading a file so a listed shared-drive file does not 404 on fetch', async () => { @@ -841,7 +909,7 @@ describe('GoogleDrivePlugin', () => { await plugin.downloadFile(makeDriveFile({ id: 'shared-file-id' })) - const params = paramsOfLastRequest(fetchMock) + const params = firstRequestTo(fetchMock, FILES_ENDPOINT) expect(params.get('alt')).toBe('media') expect(params.get('supportsAllDrives')).toBe('true') }) @@ -853,11 +921,208 @@ describe('GoogleDrivePlugin', () => { await plugin.downloadFile(makeDriveFile({ id: 'my-drive-file-id' })) - const params = paramsOfLastRequest(fetchMock) + const params = firstRequestTo(fetchMock, FILES_ENDPOINT) expect(params.has('supportsAllDrives')).toBe(false) expect(params.has('corpora')).toBe(false) expect(params.has('includeItemsFromAllDrives')).toBe(false) }) + + // ── On: the shared drives are reachable, not merely queryable ── + + it('appends each shared drive to the root listing as a navigable folder, after the My Drive children', async () => { + vi.stubGlobal( + 'fetch', + mockFetchSequence([ + { + files: [ + { + id: 'f1', + name: 'own.txt', + mimeType: 'text/plain', + }, + ], + }, + { + drives: [ + { id: 'drive-a', name: 'Team A' }, + { id: 'drive-b', name: 'Team B' }, + ], + }, + ]), + ) + configureWithSharedDrives(true) + + const result = await plugin.loadFiles() + + expect(result.files.map(f => f.id)).toEqual([ + 'f1', + 'drive-a', + 'drive-b', + SHARED_WITH_ME_FOLDER_ID, + ]) + const teamA = result.files[1]! + expect(teamA.name).toBe('Team A') + expect(teamA.isFolder).toBe(true) + expect(teamA.mimeType).toBe('folder') + expect(teamA.thumbnail).toBeUndefined() + }) + + it('asks drives.list for id and name a hundred at a time', async () => { + const fetchMock = mockFetchSequence([{ files: [] }, { drives: [] }]) + vi.stubGlobal('fetch', fetchMock) + configureWithSharedDrives(true) + + await plugin.loadFiles() + + const params = firstRequestTo(fetchMock, DRIVES_ENDPOINT) + expect(params.get('pageSize')).toBe('100') + expect(params.get('fields')).toBe('nextPageToken,drives(id,name)') + expect(params.get('key')).toBe('test-api-key') + }) + + it('follows drives.list pagination so a user in more shared drives than one page still sees them all', async () => { + const fetchMock = mockFetchSequence([ + { files: [] }, + { + drives: [{ id: 'drive-a', name: 'Team A' }], + nextPageToken: 'drives-page-2', + }, + { drives: [{ id: 'drive-b', name: 'Team B' }] }, + ]) + vi.stubGlobal('fetch', fetchMock) + configureWithSharedDrives(true) + + const result = await plugin.loadFiles() + + const driveCalls = requestsTo(fetchMock, DRIVES_ENDPOINT) + expect(driveCalls).toHaveLength(2) + expect(driveCalls[0]!.has('pageToken')).toBe(false) + expect(driveCalls[1]!.get('pageToken')).toBe('drives-page-2') + expect(result.files.map(f => f.id)).toEqual([ + 'drive-a', + 'drive-b', + SHARED_WITH_ME_FOLDER_ID, + ]) + }) + + it('lists a shared drive by the ordinary parent query once the user navigates into it, and asks for no second drives list', async () => { + const fetchMock = mockFetchSequence([ + { + files: [ + { + id: 'v1', + name: 'launch.mp4', + mimeType: 'video/mp4', + }, + ], + }, + ]) + vi.stubGlobal('fetch', fetchMock) + configureWithSharedDrives(true) + + const result = await plugin.loadFiles('drive-a') + + const params = firstRequestTo(fetchMock, FILES_ENDPOINT) + expect(params.get('q')).toBe( + "'drive-a' in parents and trashed = false", + ) + expect(params.get('corpora')).toBe('allDrives') + expect(params.get('includeItemsFromAllDrives')).toBe('true') + expect(params.get('supportsAllDrives')).toBe('true') + expect(requestsTo(fetchMock, DRIVES_ENDPOINT)).toHaveLength(0) + expect(result.files.map(f => f.name)).toEqual(['launch.mp4']) + }) + + it('paginates a shared drive by its drive id, and does not re-append the shared drives to the continuation page', async () => { + const fetchMock = mockFetchSequence([ + { + files: [ + { + id: 'v2', + name: 'teaser.mp4', + mimeType: 'video/mp4', + }, + ], + }, + ]) + vi.stubGlobal('fetch', fetchMock) + configureWithSharedDrives(true) + + const page = await plugin.loadMoreFiles( + JSON.stringify({ + folderId: 'drive-a', + pageToken: 'drive-a-page-2', + }), + ) + + const params = firstRequestTo(fetchMock, FILES_ENDPOINT) + expect(params.get('q')).toBe( + "'drive-a' in parents and trashed = false", + ) + expect(params.get('pageToken')).toBe('drive-a-page-2') + expect(page.files.map(f => f.id)).toEqual(['v2']) + expect(requestsTo(fetchMock, DRIVES_ENDPOINT)).toHaveLength(0) + }) + + it('carries the root cursor so a My Drive root with more pages still paginates with the option on', async () => { + vi.stubGlobal( + 'fetch', + mockFetchSequence([ + { files: [], nextPageToken: 'root-page-2' }, + { drives: [] }, + ]), + ) + configureWithSharedDrives(true) + + const result = await plugin.loadFiles() + + expect(result.hasMore).toBe(true) + expect(result.cursor).toBe( + JSON.stringify({ + folderId: 'root', + pageToken: 'root-page-2', + }), + ) + }) + + // ── On: the Shared-with-me virtual folder ── + + it('queries sharedWithMe rather than a parent for the virtual Shared-with-me folder, because Drive has no such parent', async () => { + const fetchMock = mockFetchSequence([{ files: [] }]) + vi.stubGlobal('fetch', fetchMock) + configureWithSharedDrives(true) + + const result = await plugin.loadFiles(SHARED_WITH_ME_FOLDER_ID) + + const params = firstRequestTo(fetchMock, FILES_ENDPOINT) + expect(params.get('q')).toBe( + 'sharedWithMe = true and trashed = false', + ) + expect(params.get('q')).not.toContain('in parents') + expect(params.get('corpora')).toBe('allDrives') + expect(result.folderId).toBe(SHARED_WITH_ME_FOLDER_ID) + expect(requestsTo(fetchMock, DRIVES_ENDPOINT)).toHaveLength(0) + }) + + it('keeps the sharedWithMe query on page 2 of that folder, instead of asking for a parent that matches nothing', async () => { + const fetchMock = mockFetchSequence([{ files: [] }]) + vi.stubGlobal('fetch', fetchMock) + configureWithSharedDrives(true) + + await plugin.loadMoreFiles( + JSON.stringify({ + folderId: SHARED_WITH_ME_FOLDER_ID, + pageToken: 'shared-page-2', + }), + ) + + const params = firstRequestTo(fetchMock, FILES_ENDPOINT) + expect(params.get('q')).toBe( + 'sharedWithMe = true and trashed = false', + ) + expect(params.get('pageToken')).toBe('shared-page-2') + expect(params.get('supportsAllDrives')).toBe('true') + }) }) describe('downloadFiles() - regular files', () => { From c1b900da2d47b26e5242c12c407a7dab69fc036f Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:24:03 +0100 Subject: [PATCH 3/3] chore(size): raise the @useupup/core budget to 420 KB, measured --- .size-limit.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.json b/.size-limit.json index 42e15f72a..ae1016e88 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -4,7 +4,7 @@ "path": "packages/core/dist/**/*.js", "gzip": false, "brotli": false, - "limit": "410 KB" + "limit": "420 KB" }, { "name": "@useupup/react",