feat: add shared decks and multi-account Mastodon OAuth

This commit is contained in:
2026-09-24 16:52:55 +09:00
parent d2cbf4dbd3
commit c47f58f065
100 changed files with 9215 additions and 1027 deletions
+63
View File
@@ -0,0 +1,63 @@
import { expect, test } from '../fixtures'
test('owner can open the app but missing or foreign identity is forbidden', async ({
request,
}) => {
expect((await request.get('/')).status()).toBe(200)
expect(
(
await request.get('/', { headers: { 'Tailscale-User-Login': '' } })
).status(),
).toBe(403)
expect(
(
await request.get('/', {
headers: { 'Tailscale-User-Login': '[email protected]' },
})
).status(),
).toBe(403)
})
test('mutations require exact Origin even with same-origin Fetch Metadata', async ({
request,
}) => {
expect((await request.post('/')).status()).toBe(403)
expect(
(
await request.post('/', {
headers: {
Origin: 'https://other.invalid',
'Sec-Fetch-Site': 'same-origin',
},
})
).status(),
).toBe(403)
})
test('server functions also reject foreign identity and cross-site calls', async ({
page,
request,
}) => {
const serverRequest = page.waitForRequest((request) =>
request.url().includes('/_serverFn/'),
)
await page.goto('/')
const url = (await serverRequest).url()
expect(
(
await request.get(url, {
headers: {
'Tailscale-User-Login': '[email protected]',
'Sec-Fetch-Site': 'same-origin',
},
})
).status(),
).toBe(403)
expect(
(
await request.get(url, {
headers: { 'Sec-Fetch-Site': 'cross-site' },
})
).status(),
).toBe(403)
})
+34
View File
@@ -0,0 +1,34 @@
import { expect, test } from '../fixtures'
test('opens account management after hydration without inheriting real OAuth configuration', async ({
page,
}) => {
await page.goto('/')
const manage = page.getByRole('button', { name: '接続アカウントを管理' })
await expect(manage).toBeEnabled()
await manage.click()
const dialog = page.getByRole('dialog', { name: '接続アカウント' })
await expect(dialog).toBeVisible()
await expect(
dialog.getByText('利用するMastodonサーバーがまだ設定されていません。'),
).toBeVisible()
await expect(
dialog.getByRole('button', { name: 'Mastodonで認可する' }),
).toHaveCount(0)
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(manage).toBeFocused()
})
test('rejects an unbound OAuth callback without contacting an instance', async ({
page,
}) => {
await page.goto('/oauth/mastodon/callback?state=invalid&code=not-a-real-code')
await expect(page).toHaveURL(/\/\?mastodon=failed$/)
await expect(page.getByRole('alert')).toContainText(
'Mastodonの認可を完了できませんでした。',
)
await expect(
page.getByRole('button', { name: '接続アカウントを管理' }),
).toBeEnabled()
})
+111 -3
View File
@@ -13,7 +13,7 @@ async function addColumn(
await editor.getByLabel('カラム名', { exact: true }).fill(title)
await editor
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.selectOption(profile)
.selectOption({ label: profile })
await editor
.getByRole('combobox', { name: 'カラムの種類', exact: true })
.selectOption(kind)
@@ -39,6 +39,10 @@ async function addColumn(
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
await page
.getByRole('button', { name: 'デッキとして保存', exact: true })
.click()
await expect(page.getByText('サーバーに保存・端末間で共有')).toHaveCount(1)
})
test('binds identical searches to independent profiles through paging, editing and reload', async ({
@@ -65,7 +69,7 @@ test('binds identical searches to independent profiles through paging, editing a
await second.getByRole('button', { name: '編集', exact: true }).click()
await page
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.selectOption('e2e')
.selectOption({ label: 'e2e' })
await page.getByRole('button', { name: '変更を保存' }).click()
await expect(
second.getByText('Latest · all page 1', { exact: true }),
@@ -240,6 +244,9 @@ test('selects a list from the column profile catalog and refreshes it on profile
}) => {
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
await page.getByLabel('カラム名', { exact: true }).fill('購読リスト')
await page
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.selectOption({ label: 'e2e' })
await page
.getByRole('combobox', { name: 'カラムの種類', exact: true })
.selectOption('list')
@@ -248,7 +255,7 @@ test('selects a list from the column profile catalog and refreshes it on profile
).toBeVisible()
await page
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.selectOption('alternate')
.selectOption({ label: 'alternate' })
await expect(
page.getByRole('button', { name: 'My E2E List', exact: true }),
).toHaveCount(0)
@@ -265,3 +272,104 @@ test('selects a list from the column profile catalog and refreshes it on profile
.getByText('list page 1', { exact: true }),
).toBeVisible()
})
test('keeps an open draft through remote edits and rejects its stale save', async ({
page,
browser,
baseURL,
}) => {
const other = await browser.newContext({
baseURL,
extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' },
})
try {
const device = await other.newPage()
await device.goto('/')
await page.getByRole('button', { name: '名前を変更' }).click()
await page.getByLabel('調査テーマ・デッキ名').fill('編集中の名前')
await device.getByRole('button', { name: '名前を変更' }).click()
await device.getByLabel('調査テーマ・デッキ名').fill('別の端末の変更')
await device.getByRole('button', { name: '名前を保存' }).click()
await expect(device.getByRole('heading', { level: 1 })).toHaveText(
'別の端末の変更',
)
await page.evaluate(() => window.dispatchEvent(new Event('focus')))
await expect(page.getByLabel('調査テーマ・デッキ名')).toHaveValue(
'編集中の名前',
)
await page.getByRole('button', { name: '名前を保存' }).click()
await expect(page.getByRole('alert')).toContainText(
'別の端末で変更されました',
)
await expect(page.getByLabel('調査テーマ・デッキ名')).toHaveValue(
'編集中の名前',
)
await page.keyboard.press('Escape')
await page.getByRole('button', { name: '最新のデッキを再読み込み' }).click()
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'別の端末の変更',
)
} finally {
await other.close()
}
})
test('imports old browser decks explicitly and avoids duplicate imports after retry', async ({
page,
}) => {
const legacy = {
version: 2,
activeDeckId: 'old',
decks: [
{
id: 'old',
title: '以前の調査',
columns: [
{
id: 'a',
title: '以前の観点',
profileName: 'e2e',
source: {
platform: 'twitter',
kind: 'search',
query: 'WebMCP',
product: 'Latest',
following: false,
},
},
],
},
],
}
await page.evaluate(
(legacy) =>
localStorage.setItem(
'twitter-lite-research-deck',
JSON.stringify(legacy),
),
legacy,
)
await page.reload()
const selector = page.getByLabel('デッキプロファイル', { exact: true })
await expect(selector.locator('option')).toHaveCount(1)
await page.getByRole('button', { name: '旧デッキを取り込む' }).click()
await expect(selector.locator('option')).toHaveCount(2)
await selector.selectOption({ label: '以前の調査' })
await expect(
page.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
await page.evaluate(
(legacy) =>
localStorage.setItem(
'twitter-lite-research-deck',
JSON.stringify(legacy),
),
legacy,
)
await page.reload()
await page.getByRole('button', { name: '旧デッキを取り込む' }).click()
await expect(
page.getByRole('button', { name: '旧デッキを取り込む' }),
).toHaveCount(0)
await expect(selector.locator('option')).toHaveCount(2)
})
+198 -141
View File
@@ -1,4 +1,5 @@
import type { Page } from '@playwright/test'
import Database from 'better-sqlite3'
import { expect, test } from '../fixtures'
type NativeTesting = {
@@ -9,14 +10,12 @@ 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(() =>
(
@@ -39,212 +38,286 @@ async function executeTool(
).modelContextTesting.executeTool(name, JSON.stringify(input)),
{ name, input },
)
expect(result, `${name} returned a result`).not.toBeNull()
if (result === null) throw new Error(`${name} returned no result`)
return JSON.parse(result)
expect(result).not.toBeNull()
return JSON.parse(result ?? '{}')
}
const column = (title: string, profileName = 'e2e') => ({
function decoded(result: ToolResult) {
return JSON.parse(result.content[0]?.text ?? '{}')
}
const column = (title: string, connectionId: string) => ({
title,
profileName,
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<string, string>
let pageErrors: string[]
test.beforeEach(async ({ page }) => {
pageErrors = []
page.on('pageerror', (error) => pageErrors.push(error.message))
await page.goto('/deck')
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, 'uncaught browser errors').toEqual([]))
test.afterEach(() => expect(pageErrors).toEqual([]))
test('creates, replaces, switches and deletes persisted decks through native WebMCP', async ({
test('creates temporary research, edits it, persists explicitly and reopens it on another device', async ({
page,
browser,
baseURL,
}) => {
const inventory = await executeTool(page, 'list_decks')
expect(JSON.parse(inventory.content[0]?.text ?? '').profiles).toEqual([
'e2e',
'alternate',
])
const created = await executeTool(page, 'set_deck', {
title: 'WebMCPの反応',
columns: [
{ ...column('日本語'), id: 'new' },
column('別の接続', 'alternate'),
{ ...column('日本語', accounts.e2e ?? ''), id: 'new' },
column('別の接続', accounts.alternate ?? ''),
],
})
expect(created.isError).not.toBe(true)
const { deck } = JSON.parse(created.content[0]?.text ?? '')
await expect(page.locator('.deck-column h2')).toHaveText([
'日本語',
'別の接続',
])
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 })
.locator('summary[aria-label="日本語の操作"]')
.locator('summary')
.click()
await page
.getByRole('region', { name: '日本語', exact: true })
.getByRole('button', { name: '編集', exact: true })
.click()
await expect(page.getByLabel('Twitterの検索条件')).toHaveValue('WebMCP')
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('form', { name: 'カラムを編集' })).toHaveCount(0)
await expect(page.locator('.deck-column h2')).toHaveText(['別の接続'])
const another = await executeTool(page, 'set_deck', {
title: '別の調査',
columns: [],
})
const anotherId = JSON.parse(another.content[0]?.text ?? '').deck.id
await expect(page.getByRole('dialog')).toHaveCount(0)
expect(savedCount()).toEqual({ count: 0 })
expect(
(await executeTool(page, 'select_deck', { deckId: deck.id })).isError,
(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': '[email protected]' },
})
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.poll(() => toolNames(page)).toContain('get_deck')
const restored = JSON.parse(
(await executeTool(page, 'get_deck')).content[0]?.text ?? '',
).deck
expect(restored).toEqual({
...deck,
title: '更新した調査',
columns: [deck.columns[1]],
})
expect(
(await executeTool(page, 'delete_deck', { deckId: anotherId })).isError,
).not.toBe(true)
const after = JSON.parse(
(await executeTool(page, 'list_decks')).content[0]?.text ?? '',
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'更新した調査',
)
expect(
after.decks.some((item: { id: string }) => item.id === anotherId),
).toBe(false)
expect(after.activeDeckId).toBe(deck.id)
})
test('rejects duplicate IDs, unavailable profiles and invalid sources without mutations', async ({
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('残す観点')],
columns: [column('残す観点', accounts.e2e ?? '')],
})
const saved = await page.evaluate(() =>
localStorage.getItem('twitter-lite-research-deck'),
)
const duplicate = { ...column('不正'), id: 'duplicate' }
const duplicate = { ...column('不正', accounts.e2e ?? ''), id: 'duplicate' }
for (const columns of [
[duplicate, duplicate],
[column('不明', 'missing')],
[{ ...column('不正'), source: { kind: 'list', target: 'not-a-list' } }],
[
{
...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(
await page.evaluate(() =>
localStorage.getItem('twitter-lite-research-deck'),
),
).toBe(saved)
}
expect(savedCount()).toEqual({ count: 0 })
})
test('reports persistence failure while keeping the applied deck visible', async ({
test('keeps failed temporary saves visible and allows retry without duplicate decks', async ({
page,
}) => {
await page.evaluate(() => {
const setItem = Storage.prototype.setItem
Storage.prototype.setItem = function (key, value) {
if (this === localStorage) throw new Error('QuotaExceededError')
setItem.call(this, key, value)
}
})
const result = await executeTool(page, 'set_deck', {
title: '未保存の調査',
columns: [],
})
expect(result.isError).toBe(true)
expect(result.content[0]?.text).toContain(
'applied in this tab but could not be saved',
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(
'未保存の調査',
)
await expect(page.getByRole('alert')).toContainText('保存できません')
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('keeps manual deck creation functional when WebMCP is disabled', async ({
playwright,
baseURL,
}) => {
const browser = await playwright.chromium.launch({
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
args: ['--disable-blink-features=WebMCP,WebMCPTesting'],
})
try {
const page = await browser.newPage({ baseURL })
await page.goto('/')
await page
.getByRole('button', { name: 'カラムを追加', exact: true })
.click()
await page.getByLabel('カラム名', { exact: true }).fill('手動で調査')
await page
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.selectOption('e2e')
await page.getByLabel('Twitterの検索条件').fill('ordinary-search')
await page.getByRole('button', { name: '追加して検索' }).click()
await expect(
page.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
} finally {
await browser.close()
}
})
test('reads and pages the bound column profile without mixing sibling feeds', async ({
test('reads and pages bound accounts without mixing sibling columns', async ({
page,
}) => {
const result = await executeTool(page, 'set_deck', {
title: '接続を比較',
columns: [column('通常'), column('別の接続', 'alternate')],
})
const { deck } = JSON.parse(result.content[0]?.text ?? '')
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 boundId = deck.columns[1].id
const loaded = JSON.parse(
(await executeTool(page, 'get_column_posts', { columnId: boundId }))
.content[0]?.text ?? '',
const id = deck.columns[1].id
const loaded = decoded(
await executeTool(page, 'get_column_posts', { columnId: id }),
)
expect(loaded.column.profileName).toBe('alternate')
expect(loaded.column.connectionId).toBe(accounts.alternate)
expect(loaded.posts.map((post: { text: string }) => post.text)).toEqual([
'alternate · Latest · all page 1',
])
const moreResult = await executeTool(page, 'load_more_column', {
columnId: boundId,
})
expect(moreResult.isError).not.toBe(true)
const more = JSON.parse(moreResult.content[0]?.text ?? '')
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',
@@ -252,24 +325,8 @@ test('reads and pages the bound column profile without mixing sibling feeds', as
await expect(
page.getByRole('region', { name: '通常', exact: true }).locator('article'),
).toHaveCount(1)
await expect(
page
.getByRole('region', { name: '別の接続', exact: true })
.locator('article'),
).toHaveCount(2)
const slice = JSON.parse(
(
await executeTool(page, 'get_column_posts', {
columnId: boundId,
offset: 1,
limit: 1,
})
).content[0]?.text ?? '',
)
expect(slice.posts).toEqual(more.posts)
await executeTool(page, 'set_deck', { title: '別デッキ', columns: [] })
await executeTool(page, 'set_deck', { title: '別の一時ビュー', columns: [] })
expect(
(await executeTool(page, 'get_column_posts', { columnId: boundId }))
.isError,
(await executeTool(page, 'get_column_posts', { columnId: id })).isError,
).toBe(true)
})