import assert from 'node:assert/strict'; import process from 'node:process'; const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright'); const { chromium } = playwright.default ?? playwright; const browser = await chromium.launch({ channel: 'chrome', headless: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 1200 }, hasTouch: true, }); page.setDefaultTimeout(10000); const failures = []; const requests = []; page.on('pageerror', (error) => failures.push(error.message)); page.on('request', (request) => { if (['POST', 'PATCH'].includes(request.method())) { requests.push(request); } }); const base = 'http://127.0.0.1:4181/projects/7/board'; const handle = () => page.getByRole('button', { name: 'Drag ENG-42 to change status', exact: true, }); const card = () => page.locator('article').filter({ hasText: 'ENG-42' }); const visit = async (query = '') => { await page.goto(base + query); await page.getByRole('heading', { level: 1 }).waitFor(); requests.length = 0; }; const center = async (locator) => { const box = await locator.boundingBox(); assert.ok(box); return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; }; const begin = async () => { await handle().scrollIntoViewIfNeeded(); const from = await center(handle()); await page.mouse.move(from.x, from.y); await page.mouse.down(); await page.mouse.move(from.x + 12, from.y, { steps: 3 }); }; const moveToColumn = async (id) => { const to = await center(page.locator(`#board-column-title-${id}`)); await page.mouse.move(to.x, to.y, { steps: 10 }); }; const dragToColumn = async (id) => { await begin(); await moveToColumn(id); await page.mouse.up(); }; const originalCard = async () => { assert.equal( await page .locator('#board-column-1 article') .filter({ hasText: 'ENG-42' }) .count(), 1, ); }; try { for (const theme of ['light', 'dark']) { await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce', }); await visit(); assert.equal( await page .getByRole('button', { name: 'Drag ENG-43 to change status' }) .isDisabled(), true, ); await begin(); assert.equal( await page .locator('#board-column-2') .evaluate((el) => el.classList.contains('border-primary')), true, ); assert.equal( await page .locator('#board-column-3') .evaluate((el) => el.classList.contains('opacity-40')), true, ); await moveToColumn(3); await page.mouse.up(); await originalCard(); assert.equal(requests.length, 0); await begin(); await moveToColumn(2); await page.keyboard.press('Escape'); await page.mouse.up(); assert.equal(requests.length, 0); await originalCard(); // Pointer capture cancellation must never become a drop. await begin(); await handle().dispatchEvent('pointercancel', { pointerId: 1 }); await page.mouse.up(); assert.equal(requests.length, 0); await visit('?multiple'); await dragToColumn(2); const dialog = page.getByRole('dialog'); await dialog.waitFor(); assert.equal(requests.length, 0); assert.deepEqual( await dialog .locator('option') .evaluateAll((options) => options.map((option) => option.value), ), ['20', '21'], ); await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); assert.equal( await handle().evaluate((el) => el === document.activeElement), true, ); assert.equal(requests.length, 0); await originalCard(); await dragToColumn(2); const select = dialog.getByLabel('Destination status'); await select.focus(); await select.selectOption('21'); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); assert.equal(await page.locator(':focus').innerText(), 'Update status'); await page.keyboard.press('Enter'); await page .getByText('ENG-42 moved successfully.', { exact: true }) .waitFor(); assert.equal(requests.length, 1); assert.deepEqual(requests[0].postDataJSON(), { ticket_status_id: 21, redirect_to: 'board', }); assert.equal( await page .locator('#board-column-2 article') .filter({ hasText: 'ENG-42' }) .count(), 1, ); await visit('?reject'); await dragToColumn(2); await page .getByText('This transition is no longer allowed.', { exact: true }) .waitFor(); await originalCard(); assert.equal(requests.length, 1); await visit('?slow'); await dragToColumn(2); await page.locator('[data-form-state="loading"]').waitFor(); assert.equal(await handle().isDisabled(), true); assert.equal( await page.getByLabel('Move ENG-42', { exact: true }).isDisabled(), true, ); // Attempt a competing form submit even though its controls are disabled. await card() .locator('form') .evaluate((form) => form.requestSubmit()); await page .getByText('ENG-42 moved successfully.', { exact: true }) .waitFor(); assert.equal(requests.length, 1); await visit('?slow'); await card() .getByRole('button', { name: 'Update status', exact: true }) .click(); assert.equal(await handle().isDisabled(), true); await page .getByText('ENG-42 moved successfully.', { exact: true }) .waitFor(); assert.equal(requests.length, 1); await visit(); await page.route('**/tickets/42/status', (route) => route.abort('failed'), ); await dragToColumn(2); await page.locator('[data-form-state="network"]').waitFor(); await originalCard(); assert.equal(await handle().isEnabled(), true); await page.unroute('**/tickets/42/status'); await page .getByRole('button', { name: 'Try again', exact: true }) .click(); await page .getByText('ENG-42 moved successfully.', { exact: true }) .waitFor(); assert.equal(requests.length, 2); await visit(); await page.route('**/tickets/42/status', (route) => route.fulfill({ status: 500, body: 'Unavailable' }), ); await dragToColumn(2); await page.locator('[data-form-state="server"]').waitFor(); await originalCard(); await page.unroute('**/tickets/42/status'); // Normal touch gestures still scroll; only the explicit handle captures a drag. await page.setViewportSize({ width: 390, height: 1000 }); await visit('?multiple'); await handle().scrollIntoViewIfNeeded(); assert.equal( await handle().evaluate((el) => getComputedStyle(el).touchAction), 'none', ); assert.notEqual( await card().evaluate((el) => getComputedStyle(el).touchAction), 'none', ); const cdp = await page.context().newCDPSession(page); const from = await center(handle()); await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ ...from, id: 1 }], }); await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: from.x + 16, y: from.y, id: 1 }], }); const tray = page.locator('[data-board-mobile-targets]'); await tray.waitFor(); const to = await center(tray.locator('[data-board-drop-column="2"]')); await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ ...to, id: 1 }], }); await page.screenshot({ path: `/private/tmp/kan28-mobile-drag-${theme}.png`, fullPage: true, }); assert.equal( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), true, ); await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [], }); await page.getByRole('dialog').waitFor(); await page.getByLabel('Destination status').selectOption('20'); await page .getByRole('dialog') .getByRole('button', { name: 'Update status' }) .click(); await page .getByText('ENG-42 moved successfully.', { exact: true }) .waitFor(); assert.equal( await page.getByLabel('Choose a column').inputValue(), '2', ); assert.equal(requests.length, 1); await cdp.detach(); await page.screenshot({ path: `/private/tmp/kan28-mobile-result-${theme}.png`, fullPage: true, }); await visit('?readonly'); assert.equal( await page.getByRole('button', { name: /Drag ENG/ }).count(), 0, ); await page.setViewportSize({ width: 1440, height: 1200 }); } assert.deepEqual(failures, []); console.log( 'Board drag checks passed: mouse, touch, workflow restrictions, selection, cancellation, focus, request guards and recovery in both themes.', ); } finally { await browser.close(); }