@uppy/provider-views: add e2e tests for Server side search (#6015)

Tests added as discussed in
[slack_discussion](https://transloadit.slack.com/archives/C0FMW9PSB/p1759931999124149?thread_ts=1759700542.941939&cid=C0FMW9PSB)

directory structure mocked : 

```
root/ 
├── first/
│   ├── second/
│   │   ├── third/
│   │   │   ├── nested-target.pdf
│   │   │   └── new-file.pdf
│   │   ├── deep-file.txt
│   │   ├── target.pdf
│   │   └── workspace.pdf
│   └── intermediate.doc
├── workspace/
│   └── project/
│       └── code.js
└── readme.md

```

Some of the mocked responses in CompanionHandler.ts aren’t used in the
tests, but I’ve kept them to preserve the legitimacy of the above
directory structure.
This commit is contained in:
Prakash 2025-11-07 16:50:57 +05:30 committed by GitHub
parent 46f81e2bae
commit ec75d863ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1040 additions and 2 deletions

View file

@ -56,6 +56,7 @@
},
"devDependencies": {
"@uppy/core": "workspace:^",
"@uppy/dropbox": "workspace:^",
"@uppy/google-drive": "workspace:^",
"@uppy/url": "workspace:^",
"@uppy/webcam": "workspace:^",

View file

@ -0,0 +1,568 @@
import Uppy from '@uppy/core'
import Dashboard from '@uppy/dashboard'
import Dropbox from '@uppy/dropbox'
import GoogleDrive from '@uppy/google-drive'
import { ProviderViews } from '@uppy/provider-views'
import { page, userEvent } from '@vitest/browser/context'
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'
import { worker } from './setup.js'
import '@uppy/core/css/style.css'
import '@uppy/dashboard/css/style.css'
let uppy: Uppy | undefined
/**
* In Normal mode (ListItem.tsx), folders are rendered as buttons, whereas files are rendered as checkboxes with a corresponding <label>.
* In Search mode (SearchListItem.tsx), both files and folders are rendered as buttons.
* Because of this, in Normal mode, when checking whether a file exists, we need to use:
* await expect.element(page.getByRole('button', { name:'nested-target.pdf', exact: true }))
* whereas, in Search mode, we need to scope the query to the checkbox role instead when searching for a file
*/
beforeAll(async () => {
// Disable search debounce inside ProviderView during tests to avoid long sleeps
// @ts-expect-error test-only hook
ProviderViews[Symbol.for('uppy test: searchDebounceMs')] = 0
await worker.start({
onUnhandledRequest: 'bypass',
})
})
afterAll(async () => {
await worker.stop()
})
type SourceName = 'Dropbox' | 'GoogleDrive'
function initializeUppy(sources: SourceName[] = ['Dropbox']) {
document.body.innerHTML = '<div id="app"></div>'
const instance = new Uppy({ id: 'uppy-e2e' }).use(Dashboard, {
target: '#app',
inline: true,
height: 500,
})
for (const source of sources) {
if (source === 'Dropbox') {
instance.use(Dropbox, { companionUrl: 'http://companion.test' })
} else if (source === 'GoogleDrive') {
instance.use(GoogleDrive, { companionUrl: 'http://companion.test' })
}
}
return instance
}
// Removed shared beforeEach initialization. Each test initializes its own Uppy instance.
afterEach(async () => {
if (!uppy) return
// this is done to prevent the edgecase when all plugins are removed before dashboard is unmounted from UI
// causing PickerPanelContent to crash
const dashboard = uppy.getPlugin('Dashboard') as Dashboard<any, any>
dashboard?.hideAllPanels()
const panelSelector = '[data-uppy-panelType="PickerPanel"]'
if (document.querySelector(panelSelector)) {
await expect.poll(() => document.querySelector(panelSelector)).toBeNull()
}
uppy.destroy()
uppy = undefined
})
describe('ProviderView Search E2E', () => {
test('Search for nested file in Dropbox and verify results', async () => {
uppy = initializeUppy(['Dropbox'])
await expect
.element(page.getByRole('presentation').getByText('Dropbox'))
.toBeVisible()
await page.getByRole('tab', { name: 'Dropbox' }).click()
await expect
.element(page.getByRole('heading', { name: /import from dropbox/i }))
.toBeVisible()
const panel = page.getByRole('tabpanel')
await expect.element(panel.getByText('test-user@example.com')).toBeVisible()
const searchInput = document.querySelector(
'.uppy-ProviderBrowser-searchFilterInput',
) as HTMLInputElement
expect(searchInput).toBeDefined()
// Search mode (SearchResultItem.tsx): files and folders render as buttons;
// use role=button for file assertions in search results.
await userEvent.type(searchInput, 'target')
await expect
.element(page.getByRole('button', { name: 'target.pdf', exact: true }))
.toBeVisible()
const targetPdfItem = await page.getByRole('button', {
name: 'target.pdf',
exact: true,
})
expect(targetPdfItem).toBeTruthy()
})
test('Search deep folder -> open it -> click ancestor breadcrumb and navigate correctly', async () => {
uppy = initializeUppy(['Dropbox'])
await expect
.element(page.getByRole('presentation').getByText('Dropbox'))
.toBeVisible()
await page.getByRole('tab', { name: 'Dropbox' }).click()
await expect
.element(page.getByRole('heading', { name: /import from dropbox/i }))
.toBeVisible()
const panel = page.getByRole('tabpanel')
await expect.element(panel.getByText('test-user@example.com')).toBeVisible()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
const searchInput = document.querySelector(
'.uppy-ProviderBrowser-searchFilterInput',
) as HTMLInputElement
await userEvent.clear(searchInput)
await userEvent.type(searchInput, 'second')
await expect
.element(page.getByRole('button', { name: 'second' }))
.toBeVisible()
const secondFolder = await page.getByRole('button', { name: 'second' })
await secondFolder.click()
// Normal mode (ListItem.tsx): files render as checkboxes with a corresponding <label>.
// Use role=checkbox for file assertions in browse view.
await expect
.element(
page.getByRole('checkbox', { name: 'deep-file.txt', exact: true }),
)
.toBeVisible()
// Click ancestor breadcrumb that was never loaded before in browse mode
const firstBreadcrumb = page.getByRole('button', { name: 'first' })
await firstBreadcrumb.click()
const hasSecondFolder = await page.getByRole('button', {
name: 'second',
exact: true,
})
expect(hasSecondFolder).toBeVisible()
})
test('Check folder in browse mode, search for nested item -> nested item should be checked', async () => {
uppy = initializeUppy(['Dropbox'])
await expect
.element(page.getByRole('presentation').getByText('Dropbox'))
.toBeVisible()
await page.getByRole('tab', { name: 'Dropbox' }).click()
await expect
.element(page.getByRole('heading', { name: /import from dropbox/i }))
.toBeVisible()
const panel = page.getByRole('tabpanel')
await expect.element(panel.getByText('test-user@example.com')).toBeVisible()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
const firstFolderItem = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
).find(
(item) =>
item.textContent?.includes('first') && item.querySelector('button'),
)
const firstFolderCheckbox =
firstFolderItem?.querySelector<HTMLInputElement>('input[type="checkbox"]')
expect(firstFolderCheckbox).toBeTruthy()
await firstFolderCheckbox!.click()
expect(firstFolderCheckbox!.checked).toBe(true)
const searchInput = document.querySelector(
'.uppy-ProviderBrowser-searchFilterInput',
) as HTMLInputElement
await userEvent.type(searchInput, 'second')
await expect
.element(page.getByRole('button', { name: 'second', exact: true }))
.toBeVisible()
const secondFolderItem = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
).find((item) => item.textContent?.includes('second'))
const secondFolderCheckbox =
secondFolderItem?.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)
expect(secondFolderCheckbox).toBeTruthy()
// Children inherit checked state from parent
expect(secondFolderCheckbox!.checked).toBe(true)
})
test('Search for nested item, check it, go back to normal view -> parent should be partial', async () => {
uppy = initializeUppy(['Dropbox'])
await expect
.element(page.getByRole('presentation').getByText('Dropbox'))
.toBeVisible()
await page.getByRole('tab', { name: 'Dropbox' }).click()
await expect
.element(page.getByRole('heading', { name: /import from dropbox/i }))
.toBeVisible()
const panel = page.getByRole('tabpanel')
await expect.element(panel.getByText('test-user@example.com')).toBeVisible()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
const searchInput = document.querySelector(
'.uppy-ProviderBrowser-searchFilterInput',
) as HTMLInputElement
await userEvent.type(searchInput, 'second')
await expect
.element(page.getByRole('button', { name: 'second', exact: true }))
.toBeVisible()
const secondFolderItem = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
).find((item) => item.textContent?.includes('second'))
const secondFolderCheckbox =
secondFolderItem?.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)
expect(secondFolderCheckbox).toBeTruthy()
await secondFolderCheckbox!.click()
expect(secondFolderCheckbox!.checked).toBe(true)
const clearSearchButton = document.querySelector(
'.uppy-ProviderBrowser-searchFilterReset',
) as HTMLButtonElement
expect(clearSearchButton).toBeDefined()
await clearSearchButton.click()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
const firstFolderItem = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
).find(
(item) =>
item.textContent?.includes('first') && item.querySelector('button'),
)
expect(firstFolderItem).toBeTruthy()
// Parent is partial when some (but not all) children are checked
expect(
firstFolderItem?.classList.contains(
'uppy-ProviderBrowserItem--is-partial',
),
).toBe(true)
})
test('Search for nested item, check then uncheck it, go back to normal view -> parent should be unchecked', async () => {
uppy = initializeUppy(['Dropbox'])
await expect
.element(page.getByRole('presentation').getByText('Dropbox'))
.toBeVisible()
await page.getByRole('tab', { name: 'Dropbox' }).click()
await expect
.element(page.getByRole('heading', { name: /import from dropbox/i }))
.toBeVisible()
const panel = page.getByRole('tabpanel')
await expect.element(panel.getByText('test-user@example.com')).toBeVisible()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
const searchInput = document.querySelector(
'.uppy-ProviderBrowser-searchFilterInput',
) as HTMLInputElement
await userEvent.type(searchInput, 'second')
await expect
.element(page.getByRole('button', { name: 'second', exact: true }))
.toBeVisible()
const secondFolderItem = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
).find((item) => item.textContent?.includes('second'))
const secondFolderCheckbox =
secondFolderItem?.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)
expect(secondFolderCheckbox).toBeTruthy()
await secondFolderCheckbox!.click()
expect(secondFolderCheckbox!.checked).toBe(true)
await secondFolderCheckbox!.click()
expect(secondFolderCheckbox!.checked).toBe(false)
const clearSearchButton = document.querySelector(
'.uppy-ProviderBrowser-searchFilterReset',
) as HTMLButtonElement
expect(clearSearchButton).toBeDefined()
await clearSearchButton.click()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
const firstFolderItem = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
).find(
(item) =>
item.textContent?.includes('first') && item.querySelector('button'),
)
expect(firstFolderItem).toBeTruthy()
expect(
firstFolderItem?.classList.contains(
'uppy-ProviderBrowserItem--is-checked',
),
).toBe(false)
expect(
firstFolderItem?.classList.contains(
'uppy-ProviderBrowserItem--is-partial',
),
).toBe(false)
const firstFolderCheckbox =
firstFolderItem?.querySelector<HTMLInputElement>('input[type="checkbox"]')
expect(firstFolderCheckbox).toBeTruthy()
expect(firstFolderCheckbox!.checked).toBe(false)
})
test('Navigate into folder and perform scoped search -> should find nested files at multiple levels', async () => {
uppy = initializeUppy(['Dropbox'])
await expect
.element(page.getByRole('presentation').getByText('Dropbox'))
.toBeVisible()
await page.getByRole('tab', { name: 'Dropbox' }).click()
await expect
.element(page.getByRole('heading', { name: /import from dropbox/i }))
.toBeVisible()
const panel = page.getByRole('tabpanel')
await expect.element(panel.getByText('test-user@example.com')).toBeVisible()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
const firstFolderButton = page.getByRole('button', { name: 'first' })
await firstFolderButton.click()
await expect
.element(page.getByRole('button', { name: 'second' }))
.toBeVisible()
// Normal mode (ListItem.tsx): files render as checkboxes with corresponding <label>; scope by role=checkbox. refer to a commment at the top of the file for more detailed explanation.
await expect
.element(
page.getByRole('checkbox', { name: 'intermediate.doc', exact: true }),
)
.toBeVisible()
const searchInput = document.querySelector(
'.uppy-ProviderBrowser-searchFilterInput',
) as HTMLInputElement
expect(searchInput).toBeDefined()
await userEvent.type(searchInput, 'target')
// Search mode (SearchResultItem.tsx): files render as buttons; scope by role=button.
await expect
.element(page.getByRole('button', { name: 'target.pdf', exact: true }))
.toBeVisible()
await expect
.element(
page.getByRole('button', { name: 'nested-target.pdf', exact: true }),
)
.toBeVisible()
const searchResults = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
)
const targetFiles = searchResults.filter((item) =>
item.textContent?.toLowerCase().includes('target'),
)
expect(targetFiles.length).toBe(2)
})
test('No duplicate items when searching and then browsing to the same file', async () => {
uppy = initializeUppy(['Dropbox'])
await expect
.element(page.getByRole('presentation').getByText('Dropbox'))
.toBeVisible()
await page.getByRole('tab', { name: 'Dropbox' }).click()
await expect
.element(page.getByRole('heading', { name: /import from dropbox/i }))
.toBeVisible()
const panel = page.getByRole('tabpanel')
await expect.element(panel.getByText('test-user@example.com')).toBeVisible()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
// Normal mode (ListItem.tsx): file is a checkbox; assert by role=checkbox. refer to a commment at the top of the file for more detailed explanation.
await expect
.element(page.getByRole('checkbox', { name: 'readme.md', exact: true }))
.toBeVisible()
const searchInput = document.querySelector(
'.uppy-ProviderBrowser-searchFilterInput',
) as HTMLInputElement
await userEvent.type(searchInput, 'readme')
// Search mode (SearchResultItem.tsx): file is a button; assert by role=button.
await expect
.element(page.getByRole('button', { name: 'readme.md', exact: true }))
.toBeVisible()
const searchResults = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
)
const readmeInSearch = searchResults.filter((item) =>
item.textContent?.includes('readme.md'),
)
expect(readmeInSearch.length).toBe(1)
const clearSearchButton = document.querySelector(
'.uppy-ProviderBrowser-searchFilterReset',
) as HTMLButtonElement
expect(clearSearchButton).toBeDefined()
await clearSearchButton.click()
// proceed to verify browse results directly
const browseResults = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
)
const readmeInBrowse = browseResults.filter((item) =>
item.textContent?.includes('readme.md'),
)
expect(readmeInBrowse.length).toBe(1)
const readmeCheckbox = readmeInBrowse[0]?.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)
expect(readmeCheckbox).toBeTruthy()
await readmeCheckbox!.click()
expect(readmeCheckbox!.checked).toBe(true)
// Verify checked state persists after searching again (same node in partialTree)
await userEvent.clear(searchInput)
await userEvent.type(searchInput, 'readme')
await expect
.element(page.getByRole('button', { name: 'readme.md', exact: true }))
.toBeVisible()
const searchResultsAgain = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
)
const readmeInSearchAgain = searchResultsAgain.find((item) =>
item.textContent?.includes('readme.md'),
)
const readmeCheckboxInSearch =
readmeInSearchAgain?.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)
expect(readmeCheckboxInSearch).toBeTruthy()
expect(readmeCheckboxInSearch!.checked).toBe(true)
})
test('Client-side filtering works for providers without server-side search (Google Drive)', async () => {
uppy = initializeUppy(['GoogleDrive'])
await expect
.element(page.getByRole('presentation').getByText('Google Drive'))
.toBeVisible()
await page.getByRole('tab', { name: /google drive/i }).click()
await expect
.element(page.getByRole('heading', { name: /import from google drive/i }))
.toBeVisible()
const panel = page.getByRole('tabpanel')
await expect.element(panel.getByText('test-user@example.com')).toBeVisible()
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
await expect
.element(page.getByRole('checkbox', { name: 'workspace' }))
.toBeVisible()
await expect
.element(page.getByRole('checkbox', { name: 'readme.md' }))
.toBeVisible()
const searchInput = document.querySelector(
'.uppy-ProviderBrowser-searchFilterInput',
) as HTMLInputElement
expect(searchInput).toBeDefined()
await userEvent.type(searchInput, 'workspace')
await expect.element(page.getByText('workspace')).toBeVisible()
const visibleItems = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
)
expect(visibleItems.length).toBe(1)
const workspaceItem = visibleItems.find((item) =>
item.textContent?.includes('workspace'),
)
expect(workspaceItem).toBeTruthy()
const firstItem = visibleItems.find((item) => {
const button = item.querySelector('button.uppy-ProviderBrowserItem-inner')
return button?.textContent?.trim() === 'first'
})
const readmeItem = visibleItems.find((item) =>
item.textContent?.includes('readme.md'),
)
expect(firstItem).toBeUndefined()
expect(readmeItem).toBeUndefined()
await userEvent.clear(searchInput)
await expect
.element(page.getByRole('button', { name: 'first' }))
.toBeVisible()
const allItems = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
)
expect(allItems.length).toBe(3)
await userEvent.type(searchInput, 'readme')
await expect
.element(page.getByRole('checkbox', { name: 'readme.md' }))
.toBeVisible()
const filteredItems = Array.from(
document.querySelectorAll('.uppy-ProviderBrowserItem'),
)
expect(filteredItems.length).toBe(1)
const readmeFiltered = filteredItems.find((item) =>
item.textContent?.includes('readme.md'),
)
expect(readmeFiltered).toBeTruthy()
})
})

View file

@ -0,0 +1,447 @@
import { HttpResponse, http } from 'msw'
const COMPANION_URL = 'http://companion.test'
/**
* Mocked Folder structure :
*
root/ (Dropbox)
first/
second/
third/
nested-target.pdf
new-file.pdf
deep-file.txt
target.pdf
workspace.pdf
intermediate.doc
workspace/
project/
code.js
readme.md
*/
export const handlers = [
// Mock pre-auth token
http.get(`${COMPANION_URL}/:provider/preauth`, () => {
return HttpResponse.json({ token: 'mock-preauth-token' })
}),
// Mock authentication check
http.get(`${COMPANION_URL}/:provider/connect`, () => {
return HttpResponse.json({
authenticated: true,
username: 'test-user@example.com',
})
}),
// Mock folder listing endpoint
http.get(`${COMPANION_URL}/:provider/list/*`, ({ request }) => {
const url = new URL(request.url)
// Extract path: split by '/list/' and take everything after it
const [, afterList] = url.pathname.split('/list/')
const pathStr = afterList ? decodeURIComponent(afterList) : ''
// Root folder
if (!pathStr || pathStr === 'root') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: true,
icon: 'folder',
name: 'first',
mimeType: 'folder',
id: 'folder-first',
thumbnail: null,
requestPath: encodeURIComponent('/first'),
modifiedDate: '2024-01-01T00:00:00Z',
size: null,
},
{
isFolder: true,
icon: 'folder',
name: 'workspace',
mimeType: 'folder',
id: 'folder-workspace',
thumbnail: null,
requestPath: encodeURIComponent('/workspace'),
modifiedDate: '2024-01-02T00:00:00Z',
size: null,
},
{
isFolder: false,
icon: 'file',
name: 'readme.md',
mimeType: 'text/markdown',
id: 'file-readme',
thumbnail: null,
requestPath: encodeURIComponent('/readme.md'),
modifiedDate: '2024-01-03T00:00:00Z',
size: 1024,
},
],
nextPagePath: null,
})
}
// first folder
if (pathStr === '/first') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: true,
icon: 'folder',
name: 'second',
mimeType: 'folder',
id: 'folder-second',
thumbnail: null,
requestPath: encodeURIComponent('/first/second'),
modifiedDate: '2024-01-04T00:00:00Z',
size: null,
},
{
isFolder: false,
icon: 'file',
name: 'intermediate.doc',
mimeType: 'application/msword',
id: 'file-intermediate',
thumbnail: null,
requestPath: encodeURIComponent('/first/intermediate.doc'),
modifiedDate: '2024-01-05T00:00:00Z',
size: 2048,
},
],
nextPagePath: null,
})
}
// first/second folder (deep nested)
if (pathStr === '/first/second') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: true,
icon: 'folder',
name: 'third',
mimeType: 'folder',
id: 'folder-third',
thumbnail: null,
requestPath: encodeURIComponent('/first/second/third'),
modifiedDate: '2024-01-06T00:00:00Z',
size: null,
},
{
isFolder: false,
icon: 'file',
name: 'deep-file.txt',
mimeType: 'text/plain',
id: 'file-deep',
thumbnail: null,
requestPath: encodeURIComponent('/first/second/deep-file.txt'),
modifiedDate: '2024-01-07T00:00:00Z',
size: 512,
},
{
isFolder: false,
icon: 'file',
name: 'target.pdf',
mimeType: 'application/pdf',
id: 'file-target',
thumbnail: null,
requestPath: encodeURIComponent('/first/second/target.pdf'),
modifiedDate: '2024-01-08T00:00:00Z',
size: 4096,
},
{
isFolder: false,
icon: 'file',
name: 'workspace.pdf',
mimeType: 'application/pdf',
id: 'file-workspace-pdf',
thumbnail: null,
requestPath: encodeURIComponent('/first/second/workspace.pdf'),
modifiedDate: '2024-01-11T00:00:00Z',
size: 5120,
},
],
nextPagePath: null,
})
}
// first/second/third folder (deepest level)
if (pathStr === '/first/second/third') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: false,
icon: 'file',
name: 'nested-target.pdf',
mimeType: 'application/pdf',
id: 'file-nested-target',
thumbnail: null,
requestPath: encodeURIComponent(
'/first/second/third/nested-target.pdf',
),
modifiedDate: '2024-01-09T00:00:00Z',
size: 2048,
},
{
isFolder: false,
icon: 'file',
name: 'new-file.pdf',
mimeType: 'application/pdf',
id: 'file-new',
thumbnail: null,
requestPath: encodeURIComponent('/first/second/third/new-file.pdf'),
modifiedDate: '2024-01-10T00:00:00Z',
size: 3072,
},
],
nextPagePath: null,
})
}
// workspace folder
if (pathStr === '/workspace') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: true,
icon: 'folder',
name: 'project',
mimeType: 'folder',
id: 'folder-project',
thumbnail: null,
requestPath: encodeURIComponent('/workspace/project'),
modifiedDate: '2024-01-08T00:00:00Z',
size: null,
},
],
nextPagePath: null,
})
}
// workspace/project folder
if (pathStr === '/workspace/project') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: false,
icon: 'file',
name: 'code.js',
mimeType: 'application/javascript',
id: 'file-code',
thumbnail: null,
requestPath: encodeURIComponent('/workspace/project/code.js'),
modifiedDate: '2024-01-09T00:00:00Z',
size: 3072,
},
],
nextPagePath: null,
})
}
// Default empty folder
return HttpResponse.json({
username: 'test-user@example.com',
items: [],
nextPagePath: null,
})
}),
// Mock search endpoint
http.get(`${COMPANION_URL}/:provider/search`, ({ request, params }) => {
const url = new URL(request.url)
const provider = params.provider as string
const query = url.searchParams.get('q') || ''
const searchPath = url.searchParams.get('path')
if (provider === 'drive') {
return HttpResponse.json(
{ message: 'method not implemented' },
{ status: 500 },
)
}
// Search for "second" folder
if (query.toLowerCase() === 'second') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: true,
icon: 'folder',
name: 'second',
mimeType: 'folder',
id: 'folder-second',
thumbnail: null,
requestPath: encodeURIComponent('/first/second'),
modifiedDate: '2024-01-04T00:00:00Z',
size: null,
},
],
nextPagePath: null,
searchedFor: query,
})
}
// Search for "target" file (global search from root)
if (query.toLowerCase() === 'target' && !searchPath) {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: false,
icon: 'file',
name: 'target.pdf',
mimeType: 'application/pdf',
id: 'file-target',
thumbnail: null,
requestPath: encodeURIComponent('/first/second/target.pdf'),
modifiedDate: '2024-01-08T00:00:00Z',
size: 4096,
},
{
isFolder: false,
icon: 'file',
name: 'nested-target.pdf',
mimeType: 'application/pdf',
id: 'file-nested-target',
thumbnail: null,
requestPath: encodeURIComponent(
'/first/second/third/nested-target.pdf',
),
modifiedDate: '2024-01-09T00:00:00Z',
size: 2048,
},
],
nextPagePath: null,
searchedFor: query,
})
}
// Scoped search for "target" from /first directory
if (
query.toLowerCase() === 'target' &&
searchPath === encodeURIComponent('/first')
) {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: false,
icon: 'file',
name: 'target.pdf',
mimeType: 'application/pdf',
id: 'file-target',
thumbnail: null,
requestPath: encodeURIComponent('/first/second/target.pdf'),
modifiedDate: '2024-01-08T00:00:00Z',
size: 4096,
},
{
isFolder: false,
icon: 'file',
name: 'nested-target.pdf',
mimeType: 'application/pdf',
id: 'file-nested-target',
thumbnail: null,
requestPath: encodeURIComponent(
'/first/second/third/nested-target.pdf',
),
modifiedDate: '2024-01-09T00:00:00Z',
size: 2048,
},
],
nextPagePath: null,
searchedFor: query,
})
}
// Search for "deep" - deep nested file
if (query.toLowerCase() === 'deep') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: false,
icon: 'file',
name: 'deep-file.txt',
mimeType: 'text/plain',
id: 'file-deep',
thumbnail: null,
requestPath: encodeURIComponent('/first/second/deep-file.txt'),
modifiedDate: '2024-01-07T00:00:00Z',
size: 512,
},
],
nextPagePath: null,
searchedFor: query,
})
}
// Search from subpath - "code" in workspace
if (
query.toLowerCase() === 'code' &&
searchPath === encodeURIComponent('/workspace')
) {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: false,
icon: 'file',
name: 'code.js',
mimeType: 'application/javascript',
id: 'file-code',
thumbnail: null,
requestPath: encodeURIComponent('/workspace/project/code.js'),
modifiedDate: '2024-01-09T00:00:00Z',
size: 3072,
},
],
nextPagePath: null,
searchedFor: query,
})
}
// Search for "readme" file in root
if (query.toLowerCase() === 'readme') {
return HttpResponse.json({
username: 'test-user@example.com',
items: [
{
isFolder: false,
icon: 'file',
name: 'readme.md',
mimeType: 'text/markdown',
id: 'file-readme',
thumbnail: null,
requestPath: encodeURIComponent('/readme.md'),
modifiedDate: '2024-01-03T00:00:00Z',
size: 1024,
},
],
nextPagePath: null,
searchedFor: query,
})
}
// No results
return HttpResponse.json({
username: 'test-user@example.com',
items: [],
nextPagePath: null,
searchedFor: query,
})
}),
]

View file

@ -0,0 +1,4 @@
import { setupWorker } from 'msw/browser'
import { handlers } from '../src/mocks/CompanionHandler.js'
export const worker = setupWorker(...handlers)

View file

@ -110,6 +110,10 @@ type RenderOpts<M extends Meta, B extends Body> = Omit<
export default class ProviderView<M extends Meta, B extends Body> {
static VERSION = packageJson.version
// Test hook (mirrors GoldenRetriever pattern): allow tests to override debounce time
// @ts-expect-error test-only hook key
static [Symbol.for('uppy test: searchDebounceMs')]: number | undefined
plugin: UnknownProviderPlugin<M, B>
provider: UnknownProviderPlugin<M, B>['provider']
@ -119,6 +123,7 @@ export default class ProviderView<M extends Meta, B extends Body> {
isHandlingScroll: boolean = false
previousCheckbox: string | null = null
#searchDebounced: () => void
constructor(plugin: UnknownProviderPlugin<M, B>, opts: PassedOpts<M, B>) {
this.plugin = plugin
@ -157,6 +162,16 @@ export default class ProviderView<M extends Meta, B extends Body> {
this.provider.provider,
this.provider,
)
// Configure debounced search with test override
const testHookSymbol = Symbol.for('uppy test: searchDebounceMs')
const testWait = (
ProviderView as unknown as Record<symbol, number | undefined>
)[testHookSymbol]
const wait = testWait ?? 500
const debounceOpts =
testWait === 0 ? { leading: true, trailing: true } : undefined
this.#searchDebounced = debounce(this.#search, wait, debounceOpts)
}
resetPluginState(): void {
@ -333,11 +348,13 @@ export default class ProviderView<M extends Meta, B extends Body> {
this.setLoading(false)
}
#searchDebounced = debounce(this.#search, 500)
// debounced search function is initialized in the constructor
onSearchInput = (s: string): void => {
this.plugin.setPluginState({ searchString: s })
if (this.opts.supportsSearch) this.#searchDebounced()
if (this.opts.supportsSearch) {
this.#searchDebounced()
}
}
async openSearchResultFolder(folderId: PartialTreeId): Promise<void> {

View file

@ -10918,6 +10918,7 @@ __metadata:
dependencies:
"@transloadit/prettier-bytes": "npm:^0.3.4"
"@uppy/core": "workspace:^"
"@uppy/dropbox": "workspace:^"
"@uppy/google-drive": "workspace:^"
"@uppy/provider-views": "workspace:^"
"@uppy/thumbnail-generator": "workspace:^"