Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ on:
pull_request:
types: [opened, synchronize, reopened]

permissions:
contents: read

jobs:
lint:
name: Lint, format & types
Expand Down Expand Up @@ -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
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ docs
dist
out-tsc
tmp

## Playwright
test-results
playwright-report
27 changes: 27 additions & 0 deletions e2e/drag-drop.e2e.ts
Original file line number Diff line number Diff line change
@@ -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);
});
51 changes: 51 additions & 0 deletions e2e/fixture.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>file-selector e2e</title>
</head>
<body style="margin: 0">
<!-- Full-viewport drop target so CDP drag coordinates always land on it. -->
<div id="drop" style="width: 100vw; height: 100vh"></div>
<input id="input" type="file" multiple />
<input id="input-dir" type="file" webkitdirectory />

<!-- Import the built bundle, so these tests exercise the shipped artifact. -->
<script type="module">
import {fromEvent} from '/dist/index.js';

window.__result = null;
window.__error = null;

async function handle(evt) {
try {
const files = await fromEvent(evt);
window.__result = files.map(file => ({
name: file.name,
type: file.type,
size: file.size,
path: file.path,
relativePath: file.relativePath,
isFile: file instanceof File,
hasHandle: typeof file.handle?.getFile === 'function'
}));
} catch (err) {
window.__error = String((err && err.message) || err);
}
}

const drop = document.getElementById('drop');
for (const type of ['dragenter', 'dragover']) {
drop.addEventListener(type, evt => evt.preventDefault());
}
drop.addEventListener('drop', evt => {
evt.preventDefault();
handle(evt);
});
document.getElementById('input').addEventListener('change', handle);
document.getElementById('input-dir').addEventListener('change', handle);

window.__ready = true;
</script>
</body>
</html>
1 change: 1 addition & 0 deletions e2e/fixtures/files/hello.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"hello": true}
1 change: 1 addition & 0 deletions e2e/fixtures/files/notes.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
hello world
1 change: 1 addition & 0 deletions e2e/fixtures/tree/nested/pong.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"pong": true}
1 change: 1 addition & 0 deletions e2e/fixtures/tree/ping.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"ping": true}
74 changes: 74 additions & 0 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'.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<void> {
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<SelectedFile[]> {
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<void> {
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});
}
25 changes: 25 additions & 0 deletions e2e/input.e2e.ts
Original file line number Diff line number Diff line change
@@ -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 <input type="file">', 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 <input webkitdirectory>', 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']);
});
64 changes: 64 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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
}
});