import type { Page } from '@playwright/test' import Database from 'better-sqlite3' import { expect, test } from '../fixtures' type NativeTesting = { listTools(): { name: string }[] executeTool(name: string, input: string): Promise } type ToolResult = { content: { type: string; text: string }[] isError?: boolean } test.use({ launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, args: ['--enable-blink-features=WebMCP,WebMCPTesting'], }, }) async function toolNames(page: Page) { return page.evaluate(() => ( navigator as Navigator & { modelContextTesting: NativeTesting } ).modelContextTesting .listTools() .map((tool) => tool.name) .sort(), ) } async function executeTool( page: Page, name: string, input: Record = {}, ): Promise { const result = await page.evaluate( ({ name, input }) => ( navigator as Navigator & { modelContextTesting: NativeTesting } ).modelContextTesting.executeTool(name, JSON.stringify(input)), { name, input }, ) expect(result).not.toBeNull() return JSON.parse(result ?? '{}') } function decoded(result: ToolResult) { return JSON.parse(result.content[0]?.text ?? '{}') } const column = (title: string, connectionId: string) => ({ title, connectionId, source: { kind: 'search', query: 'WebMCP' }, }) function savedCount() { const db = new Database(process.env.TWITTER_LITE_E2E_DB_PATH ?? '', { readonly: true, }) try { return db.prepare('SELECT count(*) AS count FROM decks').get() } finally { db.close() } } let accounts: Record let pageErrors: string[] test.beforeEach(async ({ page }) => { pageErrors = [] page.on('pageerror', (error) => pageErrors.push(error.message)) await page.goto('/') await expect .poll(() => toolNames(page)) .toEqual([ 'delete_deck', 'get_column_posts', 'get_deck', 'list_connections', 'list_decks', 'load_more_column', 'save_deck', 'select_deck', 'set_deck', ]) await expect .poll( async () => decoded(await executeTool(page, 'list_connections')).connections ?.length, ) .toBe(2) accounts = Object.fromEntries( decoded(await executeTool(page, 'list_connections')).connections.map( (connection: { displayName: string; id: string }) => [ connection.displayName, connection.id, ], ), ) }) test.afterEach(() => expect(pageErrors).toEqual([])) test('creates temporary research, edits it, persists explicitly and reopens it on another device', async ({ page, browser, baseURL, }) => { const created = await executeTool(page, 'set_deck', { title: 'WebMCPの反応', columns: [ { ...column('日本語', accounts.e2e ?? ''), id: 'new' }, column('別の接続', accounts.alternate ?? ''), ], }) expect(created.isError).not.toBe(true) const deck = decoded(created).deck expect(deck.persisted).toBe(false) expect(savedCount()).toEqual({ count: 0 }) await expect( page.getByText('alternate · Latest · all page 1', { exact: true }), ).toBeVisible() await page .getByRole('region', { name: '日本語', exact: true }) .getByRole('button', { name: '日本語の操作' }) .click() await page.getByRole('menuitem', { name: '編集', exact: true }).click() const replaced = await executeTool(page, 'set_deck', { deckId: deck.id, title: '更新した調査', columns: [deck.columns[1]], }) expect(replaced.isError).not.toBe(true) await expect(page.getByRole('dialog')).toHaveCount(0) expect(savedCount()).toEqual({ count: 0 }) expect( (await executeTool(page, 'save_deck', { deckId: deck.id })).isError, ).not.toBe(true) expect( (await executeTool(page, 'save_deck', { deckId: deck.id })).isError, ).not.toBe(true) expect(savedCount()).toEqual({ count: 1 }) const other = await browser.newContext({ baseURL, extraHTTPHeaders: { 'Tailscale-User-Login': 'owner@twitter-lite.invalid' }, }) try { const device = await other.newPage() await device.goto('/') await expect(device.getByRole('heading', { level: 1 })).toHaveText( '更新した調査', ) await expect( device.getByText('alternate · Latest · all page 1', { exact: true }), ).toBeVisible() } finally { await other.close() } await page.reload() await expect(page.getByRole('heading', { level: 1 })).toHaveText( '更新した調査', ) }) test('requires revisions for saved mutations and rejects stale overwrites', async ({ page, }) => { const deck = decoded( await executeTool(page, 'set_deck', { title: '保存済み', columns: [] }), ).deck await executeTool(page, 'save_deck', { deckId: deck.id }) expect( ( await executeTool(page, 'set_deck', { deckId: deck.id, title: '変更', columns: [], }) ).isError, ).toBe(true) expect( ( await executeTool(page, 'set_deck', { deckId: deck.id, expectedRevision: 1, title: '変更', columns: [], }) ).isError, ).not.toBe(true) expect( ( await executeTool(page, 'set_deck', { deckId: deck.id, expectedRevision: 1, title: '古い変更', columns: [], }) ).isError, ).toBe(true) await expect(page.getByRole('heading', { level: 1 })).toHaveText('変更') expect( ( await executeTool(page, 'delete_deck', { deckId: deck.id, expectedRevision: 1, }) ).isError, ).toBe(true) expect( ( await executeTool(page, 'delete_deck', { deckId: deck.id, expectedRevision: 2, }) ).isError, ).not.toBe(true) expect(savedCount()).toEqual({ count: 0 }) }) test('keeps temporary views independent and loses only unsaved views on reload', async ({ page, }) => { const first = decoded( await executeTool(page, 'set_deck', { title: '一時の調査A', columns: [] }), ).deck const second = decoded( await executeTool(page, 'set_deck', { title: '一時の調査B', columns: [] }), ).deck await executeTool(page, 'select_deck', { deckId: first.id }) await expect(page.getByRole('heading', { level: 1 })).toHaveText( '一時の調査A', ) await executeTool(page, 'delete_deck', { deckId: second.id }) expect( decoded(await executeTool(page, 'list_decks')).decks.some( (deck: { id: string }) => deck.id === second.id, ), ).toBe(false) expect(savedCount()).toEqual({ count: 0 }) await page.reload() await expect(page.getByRole('heading', { level: 1 })).toHaveText('新しい調査') }) test('rejects invalid input without changing the active view', async ({ page, }) => { await executeTool(page, 'set_deck', { title: '残す調査', columns: [column('残す観点', accounts.e2e ?? '')], }) const duplicate = { ...column('不正', accounts.e2e ?? ''), id: 'duplicate' } for (const columns of [ [duplicate, duplicate], [column('不明', 'missing')], [ { ...column('不正', accounts.e2e ?? ''), source: { kind: 'list', target: 'invalid' }, }, ], ]) { expect( (await executeTool(page, 'set_deck', { title: '変更しない', columns })) .isError, ).toBe(true) await expect(page.locator('.deck-column h2')).toHaveText(['残す観点']) } expect(savedCount()).toEqual({ count: 0 }) }) test('keeps failed temporary saves visible and allows retry without duplicate decks', async ({ page, }) => { const deck = decoded( await executeTool(page, 'set_deck', { title: '未保存の調査', columns: [] }), ).deck await page.route('**/_serverFn/**', (route) => route.request().method() === 'POST' ? route.abort() : route.continue(), ) expect( (await executeTool(page, 'save_deck', { deckId: deck.id })).isError, ).toBe(true) await expect(page.getByRole('heading', { level: 1 })).toHaveText( '未保存の調査', ) expect(decoded(await executeTool(page, 'get_deck')).deck.persisted).toBe( false, ) await page.unroute('**/_serverFn/**') expect( (await executeTool(page, 'save_deck', { deckId: deck.id })).isError, ).not.toBe(true) expect(savedCount()).toEqual({ count: 1 }) }) test('reads and pages bound accounts without mixing sibling columns', async ({ page, }) => { const deck = decoded( await executeTool(page, 'set_deck', { title: '接続を比較', columns: [ column('通常', accounts.e2e ?? ''), column('別の接続', accounts.alternate ?? ''), ], }), ).deck await expect( page.getByText('alternate · Latest · all page 1', { exact: true }), ).toBeVisible() const id = deck.columns[1].id const loaded = decoded( await executeTool(page, 'get_column_posts', { columnId: id }), ) expect(loaded.column.connectionId).toBe(accounts.alternate) expect(loaded.posts.map((post: { text: string }) => post.text)).toEqual([ 'alternate · Latest · all page 1', ]) const more = decoded( await executeTool(page, 'load_more_column', { columnId: id }), ) expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false }) expect(more.posts.map((post: { text: string }) => post.text)).toEqual([ 'alternate · Latest · all page 2', ]) await expect( page.getByRole('region', { name: '通常', exact: true }).locator('article'), ).toHaveCount(1) await executeTool(page, 'set_deck', { title: '別の一時ビュー', columns: [] }) expect( (await executeTool(page, 'get_column_posts', { columnId: id })).isError, ).toBe(true) })