import { chromium } from '@playwright/test'; import assert from 'node:assert/strict'; import { mkdir } from 'node:fs/promises'; await mkdir('artifacts', { recursive: true }); const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || (process.platform === 'darwin' ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : undefined), headless: true, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'], }); const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); const errors = []; page.on('pageerror', (e) => { errors.push(String(e)); console.log('PAGE ERROR', String(e)); }); page.on('console', (m) => { if (m.type() === 'error') console.log('CONSOLE ERROR', m.text()); }); await page.goto('http://127.0.0.1:5173/'); try { await page.getByRole('button', { name: '接受挑战' }).waitFor({ timeout: 10000 }); } catch (e) { await page.screenshot({ path: 'artifacts/startup-error.png' }); console.log(await page.locator('body').innerText()); await browser.close(); throw e; } await page.screenshot({ path: 'artifacts/blind-selection.png' }); await page.getByRole('button', { name: '接受挑战' }).click(); await page.keyboard.press('1'); await page.keyboard.press('2'); assert.equal(await page.locator('.sr-only button[aria-pressed=true]').count(), 2); await page.screenshot({ path: 'artifacts/playing.png' }); await page.keyboard.press('d'); assert.equal(await page.locator('.counters strong').nth(1).textContent(), '2'); const before = await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1'))); await page.reload(); await page.getByRole('button', { name: '出牌', exact: false }).waitFor(); const after = await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1'))); assert.deepEqual(after, before); await page.mouse.move(474, 665); await page.mouse.down(); await page.mouse.move(1120, 665, { steps: 15 }); await page.mouse.up(); const moved = await page.evaluate( () => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).hand, ); if (JSON.stringify(moved) === JSON.stringify(before.hand)) { await page.screenshot({ path: 'artifacts/drag-error.png' }); console.log('DRAG MODAL', await page.locator('#modal').innerText()); } assert.notDeepEqual(moved, before.hand); await page.keyboard.press('1'); await page.keyboard.press('Enter'); await page.waitForFunction(() => !document.querySelector('#scoring-source')); assert.equal(await page.locator('.counters strong').first().textContent(), '3'); await page.getByRole('button', { name: '牌型图鉴' }).click(); assert.equal(await page.locator('.hand-table tbody tr').count(), 12); await page.keyboard.press('Escape'); // Deterministic fixtures exercise full UI flows; they do not assert natural win rate. await page.evaluate(async () => { const { createRun, dispatch } = await import('/src/core/run.ts'); let r = createRun('BROWSER'); r = dispatch(r, { type: 'enter' }).state; r.cards.slice(0, 5).forEach((c, i) => { c.rank = i === 4 ? 9 : 13; }); r.hand = r.cards.slice(0, 8).map((c) => c.id); r.draw = r.cards.slice(8).map((c) => c.id); r.money = 30; r.target = 300; r.jokers = [ { uid: 200, id: 'smile', value: 0 }, { uid: 201, id: 'pair', value: 0 }, { uid: 202, id: 'double', value: 0 }, ]; r.consumables = [{ uid: 203, kind: 'planet', ref: 1, price: 3 }]; r.nextId = 300; localStorage.setItem('velvet-joker-run-v1', JSON.stringify(r)); localStorage.setItem('velvet-speed', '4'); }); await page.reload(); await page.getByRole('button', { name: '出牌', exact: false }).waitFor(); await page.mouse.click(421, 240); await page.getByRole('button', { name: '右移' }).click(); assert.equal( await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).jokers[0].id), 'pair', ); await page.waitForFunction(() => !document.querySelector('.dialog')); await page.screenshot({ path: 'artifacts/before-consumable.png' }); await page.mouse.click(1232, 228); try { await page.getByRole('button', { name: '使用', exact: true }).click({ timeout: 6000 }); } catch (e) { await page.screenshot({ path: 'artifacts/consumable-error.png' }); console.log('DOM', await page.locator('#modal').innerText()); await browser.close(); throw e; } assert.equal( await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).levels[1]), 2, ); await page.keyboard.press('1'); await page.keyboard.press('2'); await page.screenshot({ path: 'artifacts/joker-table.png' }); await page.keyboard.press('Enter'); await page.getByRole('button', { name: '前往商店' }).waitFor({ timeout: 10000 }); const paid = await page.evaluate( () => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).money, ); await page.reload(); await page.getByRole('button', { name: '前往商店' }).click(); assert.equal( await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).money), paid, ); await page.screenshot({ path: 'artifacts/shop.png' }); await page.locator('[data-action=buy]').first().click(); await page.locator('[data-action=reroll]').click(); await page.locator('[data-action=buy]').last().click(); await page.getByRole('button', { name: '选择此牌' }).first().click(); await page.getByRole('button', { name: '下一盲注' }).click(); await page.getByRole('button', { name: '跳过' }).click(); assert.match(await page.locator('.blind-head').textContent(), /BOSS/); await page.setViewportSize({ width: 1280, height: 720 }); await page.screenshot({ path: 'artifacts/desktop-1280.png' }); await page.setViewportSize({ width: 932, height: 430 }); await page.screenshot({ path: 'artifacts/mobile-landscape.png' }); await page.getByRole('button', { name: '接受挑战' }).click(); assert.equal(await page.locator('canvas').count(), 1); assert.deepEqual(errors, []); const mobile = await browser.newPage({ viewport: { width: 932, height: 430 }, hasTouch: true, isMobile: true, }); mobile.on('pageerror', (e) => errors.push(String(e))); await mobile.goto('http://127.0.0.1:5173/'); await mobile.getByRole('button', { name: '接受挑战' }).click(); const bounds = await mobile.locator('canvas').boundingBox(); const point = (x, y) => ({ x: bounds.x + (x * bounds.width) / 1440, y: bounds.y + (y * bounds.height) / 900, }); await mobile.touchscreen.tap(point(474, 665).x, point(474, 665).y); assert.equal(await mobile.locator('.sr-only button[aria-pressed=true]').count(), 1); const touch = await mobile.context().newCDPSession(mobile); await touch.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point(573, 665)], }); await mobile.getByRole('dialog').waitFor({ timeout: 4000 }); await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); assert.match(await mobile.locator('.dialog').innerText(), /基础筹码/); await mobile.getByRole('button', { name: '关闭', exact: true }).click(); const mobileBefore = await mobile.evaluate( () => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).hand, ); await touch.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point(474, 665)], }); await touch.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [point(1100, 665)], }); await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); assert.notDeepEqual( await mobile.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).hand), mobileBefore, ); assert.deepEqual(errors, []); console.log( 'Browser smoke passed: Canvas selection/drag, play/discard, reload, joker order, consumable, reward, shop, pack, skip, responsive sizes, emulated touch tap/long-press/drag.', ); await browser.close();