diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index eb42f0c..75ea944 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -8,6 +8,9 @@ on:
pull_request:
types: [opened, synchronize, reopened]
+permissions:
+ contents: read
+
jobs:
lint:
name: Lint, format & types
@@ -68,3 +71,25 @@ jobs:
uses: coverallsapp/github-action@v2
with:
file: ./coverage/lcov.info
+
+ e2e:
+ name: Run e2e (browser) tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Install Playwright Chromium
+ run: npx playwright install --with-deps chromium
+
+ - name: Run e2e tests
+ run: npm run test:e2e
diff --git a/.gitignore b/.gitignore
index 6f25dac..1a7ab3a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,7 @@ docs
dist
out-tsc
tmp
+
+## Playwright
+test-results
+playwright-report
diff --git a/e2e/drag-drop.e2e.ts b/e2e/drag-drop.e2e.ts
new file mode 100644
index 0000000..a01a36a
--- /dev/null
+++ b/e2e/drag-drop.e2e.ts
@@ -0,0 +1,27 @@
+import {expect, test} from '@playwright/test';
+import {cdpDrop, fixture, openFixture, readResult} from './helpers';
+
+test.beforeEach(async ({page}) => {
+ await openFixture(page);
+});
+
+test('drops a flat set of files and returns them', async ({page}) => {
+ await cdpDrop(page, [fixture('files/hello.json'), fixture('files/notes.txt')]);
+
+ const files = await readResult(page);
+ expect(files.every(f => f.isFile)).toBe(true);
+ expect(files.map(f => f.name).sort()).toEqual(['hello.json', 'notes.txt']);
+ expect(files.map(f => f.path).sort()).toEqual(['./hello.json', './notes.txt']);
+ // Secure context + Chromium => the File System Access path attaches a handle to each file.
+ expect(files.every(f => f.hasHandle)).toBe(true);
+});
+
+test('drops a directory and flattens it recursively', async ({page}) => {
+ await cdpDrop(page, [fixture('tree')]);
+
+ const files = await readResult(page);
+ // Traversed via getAsFileSystemHandle: paths rooted at the folder name, handles preserved.
+ expect(files.map(f => f.name).sort()).toEqual(['ping.json', 'pong.json']);
+ expect(files.map(f => f.path).sort()).toEqual(['/tree/nested/pong.json', '/tree/ping.json']);
+ expect(files.every(f => f.hasHandle)).toBe(true);
+});
diff --git a/e2e/fixture.html b/e2e/fixture.html
new file mode 100644
index 0000000..20027d2
--- /dev/null
+++ b/e2e/fixture.html
@@ -0,0 +1,51 @@
+
+
+
+
+ file-selector e2e
+
+
+
+
+
+
+
+
+
+
+
diff --git a/e2e/fixtures/files/hello.json b/e2e/fixtures/files/hello.json
new file mode 100644
index 0000000..1f04580
--- /dev/null
+++ b/e2e/fixtures/files/hello.json
@@ -0,0 +1 @@
+{"hello": true}
diff --git a/e2e/fixtures/files/notes.txt b/e2e/fixtures/files/notes.txt
new file mode 100644
index 0000000..3b18e51
--- /dev/null
+++ b/e2e/fixtures/files/notes.txt
@@ -0,0 +1 @@
+hello world
diff --git a/e2e/fixtures/tree/nested/pong.json b/e2e/fixtures/tree/nested/pong.json
new file mode 100644
index 0000000..daa5205
--- /dev/null
+++ b/e2e/fixtures/tree/nested/pong.json
@@ -0,0 +1 @@
+{"pong": true}
diff --git a/e2e/fixtures/tree/ping.json b/e2e/fixtures/tree/ping.json
new file mode 100644
index 0000000..372edd2
--- /dev/null
+++ b/e2e/fixtures/tree/ping.json
@@ -0,0 +1 @@
+{"ping": true}
diff --git a/e2e/helpers.ts b/e2e/helpers.ts
new file mode 100644
index 0000000..3817b9f
--- /dev/null
+++ b/e2e/helpers.ts
@@ -0,0 +1,74 @@
+import {readFile} from 'node:fs/promises';
+import {extname, join} from 'node:path';
+import {fileURLToPath} from 'node:url';
+import type {Page} from '@playwright/test';
+
+declare global {
+ interface Window {
+ __ready?: boolean;
+ __result: SelectedFile[] | null;
+ __error: string | null;
+ }
+}
+
+export const ROOT = fileURLToPath(new URL('..', import.meta.url));
+export const fixture = (rel: string): string => join(ROOT, 'e2e/fixtures', rel);
+
+export interface SelectedFile {
+ name: string;
+ type: string;
+ size: number;
+ path?: string;
+ relativePath?: string;
+ isFile: boolean;
+ hasHandle: boolean;
+}
+
+const MIME: Record = {
+ '.html': 'text/html',
+ '.js': 'text/javascript',
+ '.json': 'application/json',
+ '.map': 'application/json'
+};
+
+// Serve the repo from disk via request interception, so the page and built bundle load over
+// http://localhost (a secure context, required by the File System Access API) with no dev server.
+export async function openFixture(page: Page): Promise {
+ await page.route('**/*', async route => {
+ const {pathname} = new URL(route.request().url());
+ const rel = pathname === '/' ? '/e2e/fixture.html' : pathname;
+ try {
+ const body = await readFile(join(ROOT, rel));
+ await route.fulfill({body, contentType: MIME[extname(rel)] ?? 'application/octet-stream'});
+ } catch {
+ await route.fulfill({status: 404});
+ }
+ });
+ await page.goto('http://localhost/');
+ await page.waitForFunction(() => window.__ready === true);
+}
+
+export async function readResult(page: Page): Promise {
+ await page.waitForFunction(() => window.__result !== null || window.__error !== null, undefined, {timeout: 5000});
+ const err = await page.evaluate(() => window.__error);
+ if (err) throw new Error(`fromEvent threw: ${err}`);
+ return (await page.evaluate(() => window.__result)) ?? [];
+}
+
+// Drive a real drag through the CDP. Absolute `paths`; a directory path is what makes Chromium
+// build the webkitGetAsEntry / getAsFileSystemHandle objects the recursion relies on.
+export async function cdpDrop(page: Page, paths: string[]): Promise {
+ const client = await page.context().newCDPSession(page);
+ const box = await page.locator('#drop').boundingBox();
+ if (!box) throw new Error('drop target not found');
+ const x = box.x + box.width / 2;
+ const y = box.y + box.height / 2;
+ const data = {
+ items: paths.map(() => ({mimeType: 'application/octet-stream', data: ''})),
+ files: paths,
+ dragOperationsMask: 1
+ };
+ await client.send('Input.dispatchDragEvent', {type: 'dragEnter', x, y, data});
+ await client.send('Input.dispatchDragEvent', {type: 'dragOver', x, y, data});
+ await client.send('Input.dispatchDragEvent', {type: 'drop', x, y, data});
+}
diff --git a/e2e/input.e2e.ts b/e2e/input.e2e.ts
new file mode 100644
index 0000000..29d1796
--- /dev/null
+++ b/e2e/input.e2e.ts
@@ -0,0 +1,25 @@
+import {expect, test} from '@playwright/test';
+import {fixture, openFixture, readResult} from './helpers';
+
+test.beforeEach(async ({page}) => {
+ await openFixture(page);
+});
+
+test('reads a flat FileList from ', async ({page}) => {
+ await page.locator('#input').setInputFiles([fixture('files/hello.json'), fixture('files/notes.txt')]);
+
+ const files = await readResult(page);
+ expect(files.every(f => f.isFile)).toBe(true);
+ expect(files.map(f => f.name).sort()).toEqual(['hello.json', 'notes.txt']);
+ expect(files.map(f => f.path).sort()).toEqual(['./hello.json', './notes.txt']);
+ // The input path never resolves a FileSystemHandle.
+ expect(files.every(f => !f.hasHandle)).toBe(true);
+});
+
+test('keeps webkitRelativePath from ', async ({page}) => {
+ await page.locator('#input-dir').setInputFiles(fixture('tree'));
+
+ const files = await readResult(page);
+ expect(files.map(f => f.name).sort()).toEqual(['ping.json', 'pong.json']);
+ expect(files.map(f => f.path).sort()).toEqual(['tree/nested/pong.json', 'tree/ping.json']);
+});
diff --git a/package-lock.json b/package-lock.json
index 3322c6c..7949b50 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.0.0-development",
"license": "MIT",
"devDependencies": {
+ "@playwright/test": "^1.61.1",
"@types/node": "^26.1.1",
"@vitest/coverage-v8": "^4.1.10",
"jsdom": "^29.1.1",
@@ -1174,6 +1175,22 @@
"node": "^20.19.0 || >=22.12.0"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
+ "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@quansync/fs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
@@ -3240,6 +3257,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
diff --git a/package.json b/package.json
index c77aec2..0858f53 100644
--- a/package.json
+++ b/package.json
@@ -49,13 +49,16 @@
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"lint:type-aware": "oxlint --type-aware",
- "format": "oxfmt \"src/**/*.ts\" \"*.ts\"",
- "format:check": "oxfmt --check \"src/**/*.ts\" \"*.ts\"",
+ "format": "oxfmt \"src/**/*.ts\" \"e2e/**/*.ts\" \"*.ts\"",
+ "format:check": "oxfmt --check \"src/**/*.ts\" \"e2e/**/*.ts\" \"*.ts\"",
"pretest:cov": "npm run type-check && npm run lint && npm run format:check",
"test": "vitest",
- "test:cov": "vitest run --coverage"
+ "test:cov": "vitest run --coverage",
+ "pretest:e2e": "npm run build",
+ "test:e2e": "playwright test"
},
"devDependencies": {
+ "@playwright/test": "^1.61.1",
"@types/node": "^26.1.1",
"@vitest/coverage-v8": "^4.1.10",
"jsdom": "^29.1.1",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..60291a6
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,15 @@
+import {defineConfig} from '@playwright/test';
+
+// End-to-end tests in real Chromium: file-input selection and CDP-driven drag-and-drop.
+// The fixture page imports the built dist/index.js, so `npm run build` must run first (pretest:e2e).
+export default defineConfig({
+ testDir: './e2e',
+ testMatch: '**/*.e2e.ts',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ reporter: 'list',
+ use: {
+ browserName: 'chromium',
+ headless: true
+ }
+});