From 27ab82b285c8ce43bdfd6002be69b63c2e3f028b Mon Sep 17 00:00:00 2001 From: Roland Groza Date: Sat, 11 Jul 2026 21:45:00 +0900 Subject: [PATCH] test: add browser e2e (input selection + CDP drag-drop) via Playwright --- .github/workflows/test.yml | 25 ++++++++++ .gitignore | 4 ++ e2e/drag-drop.e2e.ts | 27 +++++++++++ e2e/fixture.html | 51 ++++++++++++++++++++ e2e/fixtures/files/hello.json | 1 + e2e/fixtures/files/notes.txt | 1 + e2e/fixtures/tree/nested/pong.json | 1 + e2e/fixtures/tree/ping.json | 1 + e2e/helpers.ts | 74 ++++++++++++++++++++++++++++++ e2e/input.e2e.ts | 25 ++++++++++ package-lock.json | 64 ++++++++++++++++++++++++++ package.json | 9 ++-- playwright.config.ts | 15 ++++++ 13 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 e2e/drag-drop.e2e.ts create mode 100644 e2e/fixture.html create mode 100644 e2e/fixtures/files/hello.json create mode 100644 e2e/fixtures/files/notes.txt create mode 100644 e2e/fixtures/tree/nested/pong.json create mode 100644 e2e/fixtures/tree/ping.json create mode 100644 e2e/helpers.ts create mode 100644 e2e/input.e2e.ts create mode 100644 playwright.config.ts 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 + } +});