diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e552a159..e8867cfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile --ignore-scripts + - name: Install Playwright Chromium + run: bunx playwright install --with-deps chromium + - name: Run Vitest with coverage run: bun run test:coverage diff --git a/bun.lock b/bun.lock index 48d221c7..2bd16b01 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "clsx": "2.1.1", "date-fns": "4.1.0", "drizzle-orm": "0.45.2", + "libsql": "0.5.29", "lucide-react": "0.554.0", "nanoid": "5.1.6", "next": "16.2.6", diff --git a/e2e/ai-rename-real-backend.spec.ts b/e2e/ai-rename-real-backend.spec.ts new file mode 100644 index 00000000..1a4a0bba --- /dev/null +++ b/e2e/ai-rename-real-backend.spec.ts @@ -0,0 +1,389 @@ +import path from "node:path"; +import { existsSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { createClient } from "@libsql/client"; +import { expect, test, type Page, type TestInfo } from "@playwright/test"; +import { ensureSignedIn, putJsonWithRetry } from "./helpers/auth"; + +const PLAYWRIGHT_USER_EMAIL = "playwright-admin@example.com"; +const RECORDING_PREFIX = "e2e-ai-rename-real-"; +const UNAVAILABLE_RECORDING_ID = `${RECORDING_PREFIX}unavailable`; +const PROVIDER_ERROR_RECORDING_ID = `${RECORDING_PREFIX}provider-error`; +const E2E_ROOT_MARKER = ".betterainote-e2e-root"; + +function requireIsolatedE2ERoot() { + const configuredRoot = process.env.PLAYWRIGHT_E2E_ROOT?.trim(); + if (!configuredRoot) { + throw new Error( + "AI rename E2E requires PLAYWRIGHT_E2E_ROOT from scripts/e2e-setup.mjs", + ); + } + + const root = path.resolve(configuredRoot); + const worktree = path.resolve(process.cwd()); + if (root === worktree) { + throw new Error( + "AI rename E2E requires a disposable root outside the worktree", + ); + } + + return root; +} + +const E2E_ROOT = requireIsolatedE2ERoot(); + +function assertMarkedDisposableE2ERoot() { + if (!existsSync(path.join(E2E_ROOT, E2E_ROOT_MARKER))) { + throw new Error( + "AI rename E2E requires scripts/e2e-setup.mjs to initialize its marked root", + ); + } +} + +function assertE2EDataPath(filePath: string) { + const dataDirectory = path.join(E2E_ROOT, "data"); + const relativePath = path.relative(dataDirectory, path.resolve(filePath)); + if ( + relativePath.length === 0 || + relativePath === ".." || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + throw new Error(`Refusing non-E2E database path: ${filePath}`); + } +} + +function resolveDatabasePath() { + const configuredDatabasePath = process.env.DATABASE_PATH?.trim(); + if (!configuredDatabasePath) { + throw new Error( + "AI rename E2E requires DATABASE_PATH from scripts/e2e-setup.mjs", + ); + } + + const databasePath = path.resolve(configuredDatabasePath); + assertE2EDataPath(databasePath); + return databasePath; +} + +function deriveSiblingDatabasePath(databasePath: string, suffix: string) { + const parsed = path.parse(databasePath); + return path.resolve( + parsed.dir, + `${parsed.name}-${suffix}${parsed.ext || ".db"}`, + ); +} + +function databaseUrl(filePath: string) { + assertE2EDataPath(filePath); + return pathToFileURL(filePath).href; +} + +const CORE_DB = resolveDatabasePath(); +const LIBRARY_DB = deriveSiblingDatabasePath(CORE_DB, "library"); +const TRANSCRIPTS_DB = deriveSiblingDatabasePath(CORE_DB, "transcripts"); + +for (const databasePath of [CORE_DB, LIBRARY_DB, TRANSCRIPTS_DB]) { + assertE2EDataPath(databasePath); +} + +function reportIsolatedDatabasePaths(testInfo: TestInfo) { + console.info( + [ + "AI rename E2E isolated SQLite", + `worker=${testInfo.workerIndex}`, + `root=${E2E_ROOT}`, + `core=${CORE_DB}`, + `library=${LIBRARY_DB}`, + `transcripts=${TRANSCRIPTS_DB}`, + ].join(" | "), + ); +} + +function assertInitializedE2EDatabaseLayout() { + for (const databasePath of [CORE_DB, LIBRARY_DB, TRANSCRIPTS_DB]) { + if (!existsSync(databasePath)) { + throw new Error( + `AI rename E2E database was not initialized: ${databasePath}`, + ); + } + } +} + +async function executeWithBusyRetry(operation: () => Promise): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt < 8; attempt += 1) { + try { + return await operation(); + } catch (error) { + lastError = error; + if ( + !(error instanceof Error) || + !error.message.includes("SQLITE_BUSY") || + attempt === 7 + ) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 80 * (attempt + 1))); + } + } + + throw lastError; +} + +async function getPlaywrightUserId() { + const client = createClient({ url: databaseUrl(CORE_DB) }); + try { + const result = await client.execute({ + sql: "SELECT id FROM users WHERE email = ? LIMIT 1", + args: [PLAYWRIGHT_USER_EMAIL], + }); + const userId = result.rows[0]?.id; + if (typeof userId !== "string") { + throw new Error("Playwright user not found"); + } + return userId; + } finally { + await client.close(); + } +} + +async function cleanupAiRenameSeeds() { + const library = createClient({ url: databaseUrl(LIBRARY_DB) }); + const transcripts = createClient({ url: databaseUrl(TRANSCRIPTS_DB) }); + + try { + await executeWithBusyRetry(() => + transcripts.execute({ + sql: "DELETE FROM transcript_segments WHERE recording_id LIKE ?", + args: [`${RECORDING_PREFIX}%`], + }), + ); + await executeWithBusyRetry(() => + transcripts.execute({ + sql: "DELETE FROM transcriptions WHERE recording_id LIKE ?", + args: [`${RECORDING_PREFIX}%`], + }), + ); + await executeWithBusyRetry(() => + library.execute({ + sql: "DELETE FROM recordings WHERE id LIKE ?", + args: [`${RECORDING_PREFIX}%`], + }), + ); + } finally { + await library.close(); + await transcripts.close(); + } +} + +async function seedRecording(userId: string, id: string, filename: string) { + const now = Date.now(); + const library = createClient({ url: databaseUrl(LIBRARY_DB) }); + const transcripts = createClient({ url: databaseUrl(TRANSCRIPTS_DB) }); + + try { + await executeWithBusyRetry(() => + library.execute({ + sql: ` + INSERT OR REPLACE INTO recordings ( + id, user_id, source_provider, source_recording_id, source_version, + source_metadata, provider_device_id, filename, duration, start_time, + end_time, filesize, file_md5, storage_type, storage_path, + downloaded_at, upstream_trashed, upstream_deleted, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + args: [ + id, + userId, + "ticnote", + `${id}-source`, + "1", + "{}", + "e2e-ai-rename-device", + filename, + 60_000, + now - 60_000, + now, + 0, + `${id}-md5`, + "local", + "", + null, + 0, + 0, + now, + now, + ], + }), + ); + await executeWithBusyRetry(() => + transcripts.execute({ + sql: ` + INSERT OR REPLACE INTO transcriptions ( + id, recording_id, user_id, text, detected_language, + transcription_type, provider, model, provider_job_id, + speaker_map, provider_payload, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + args: [ + `${id}-transcript`, + id, + userId, + "Speaker 1: AI rename failure states must preserve real API feedback.", + "en", + "server", + "voice-transcribe", + "e2e", + `${id}-job`, + "{}", + "{}", + now, + ], + }), + ); + } finally { + await library.close(); + await transcripts.close(); + } +} + +async function clearTitleGenerationProvider(page: Page) { + const response = await putJsonWithRetry(page, "/api/settings/title-generation", { + titleGenerationApiKey: null, + titleGenerationBaseUrl: null, + titleGenerationModel: null, + }); + expect(response.ok()).toBe(true); +} + +async function saveUnreachableTitleGenerationProvider(page: Page) { + const response = await putJsonWithRetry(page, "/api/settings/title-generation", { + titleGenerationApiKey: "e2e-invalid-title-provider-key", + titleGenerationBaseUrl: "http://127.0.0.1:9/v1", + titleGenerationModel: "e2e-unreachable-model", + }); + expect(response.ok()).toBe(true); +} + +function aiRenameTrigger(page: Page) { + return page.getByRole("button", { name: "AI 重命名", exact: true }); +} + +function aiRenamePanel(page: Page) { + return page.locator('[data-control="ai-rename-preview"]'); +} + +async function openRecording(page: Page, recordingId: string) { + await page.goto(`/recordings/${recordingId}`, { waitUntil: "domcontentloaded" }); + await expect( + page.locator('[data-surface="recording-workstation"]'), + ).toHaveAttribute("data-state", "ready"); +} + +test.beforeEach(async ({}, testInfo) => { + assertMarkedDisposableE2ERoot(); + assertInitializedE2EDatabaseLayout(); + reportIsolatedDatabasePaths(testInfo); +}); + +test.afterEach(async ({ page }) => { + await clearTitleGenerationProvider(page); + await cleanupAiRenameSeeds(); +}); + +test("AI rename exposes a keyboard-dismissible unavailable panel without a configured provider", async ({ + page, +}) => { + await ensureSignedIn(page); + const userId = await getPlaywrightUserId(); + await clearTitleGenerationProvider(page); + await cleanupAiRenameSeeds(); + await seedRecording(userId, UNAVAILABLE_RECORDING_ID, "E2E unavailable AI rename"); + + await openRecording(page, UNAVAILABLE_RECORDING_ID); + const trigger = aiRenameTrigger(page); + await expect(trigger).toHaveAttribute("data-state", "unavailable"); + await trigger.focus(); + await page.keyboard.press("Enter"); + + const panel = aiRenamePanel(page); + await expect(panel).toHaveAttribute("data-state", "unavailable"); + await expect(panel.getByRole("alert")).toContainText( + "AI 重命名服务尚未配置或暂时不可用。", + ); + await expect(panel.getByRole("button", { name: "应用", exact: true })).toBeDisabled(); + await expect( + panel.getByRole("button", { name: "重新生成", exact: true }), + ).toBeDisabled(); + + await panel.getByRole("button", { name: "关闭预览", exact: true }).focus(); + await page.keyboard.press("Escape"); + await expect(panel).toHaveCount(0); + await expect(trigger).toBeFocused(); +}); + +test("AI rename surfaces a real unreachable-provider error and retries through the API", async ({ + page, +}) => { + await ensureSignedIn(page); + const userId = await getPlaywrightUserId(); + await clearTitleGenerationProvider(page); + await cleanupAiRenameSeeds(); + await saveUnreachableTitleGenerationProvider(page); + await seedRecording( + userId, + PROVIDER_ERROR_RECORDING_ID, + "E2E unreachable AI rename provider", + ); + + await openRecording(page, PROVIDER_ERROR_RECORDING_ID); + const trigger = aiRenameTrigger(page); + await expect(trigger).toHaveAttribute("data-state", "idle"); + + let autoRenameRequestCount = 0; + page.on("request", (request) => { + if ( + new URL(request.url()).pathname === + `/api/recordings/${PROVIDER_ERROR_RECORDING_ID}/rename/auto` && + request.method() === "POST" + ) { + autoRenameRequestCount += 1; + } + }); + + const firstResponse = page.waitForResponse((response) => { + const request = response.request(); + return ( + new URL(response.url()).pathname === + `/api/recordings/${PROVIDER_ERROR_RECORDING_ID}/rename/auto` && + request.method() === "POST" + ); + }); + await trigger.click(); + expect((await firstResponse).status()).toBe(500); + + const panel = aiRenamePanel(page); + await expect(panel).toHaveAttribute("data-state", "error"); + await expect(panel.getByRole("alert")).toContainText( + "Failed to generate filename", + ); + await expect( + panel.getByRole("button", { name: "应用", exact: true }), + ).toBeDisabled(); + await expect(panel.getByRole("button", { name: "重试", exact: true })).toBeEnabled(); + + const retryResponse = page.waitForResponse((response) => { + const request = response.request(); + return ( + new URL(response.url()).pathname === + `/api/recordings/${PROVIDER_ERROR_RECORDING_ID}/rename/auto` && + request.method() === "POST" + ); + }); + await panel.getByRole("button", { name: "重试", exact: true }).click(); + expect((await retryResponse).status()).toBe(500); + await expect(panel).toHaveAttribute("data-state", "error"); + expect(autoRenameRequestCount).toBe(2); +}); diff --git a/e2e/app-route-shell-navigation.spec.ts b/e2e/app-route-shell-navigation.spec.ts new file mode 100644 index 00000000..c0a153a3 --- /dev/null +++ b/e2e/app-route-shell-navigation.spec.ts @@ -0,0 +1,327 @@ +import { + expect, + type Page, + type Request, + type Response, + test, +} from "@playwright/test"; +import { ensureSignedIn } from "./helpers/auth"; + +const DISPLAY_SETTINGS_ENDPOINT = "/api/settings/display"; +const THEME_NETWORK_LATENCY_MS = 3_000; +const THEMES = ["system", "light", "dark"] as const; +type Theme = (typeof THEMES)[number]; + +function isTheme(value: unknown): value is Theme { + return ( + typeof value === "string" && + (THEMES as readonly string[]).includes(value) + ); +} + +function isDisplaySettingsRequest(request: Request, method: "GET" | "PUT") { + return ( + request.url().endsWith(DISPLAY_SETTINGS_ENDPOINT) && + request.method() === method + ); +} + +function isThemePutRequest(request: Request, theme: Theme) { + return ( + isDisplaySettingsRequest(request, "PUT") && + request.postDataJSON()?.theme === theme + ); +} + +async function readPersistedTheme(page: Page): Promise { + const response = await page.request.get(DISPLAY_SETTINGS_ENDPOINT); + expect(response.ok()).toBe(true); + + const payload: unknown = await response.json(); + const theme = + typeof payload === "object" && payload !== null + ? (payload as { theme?: unknown }).theme + : undefined; + + if (!isTheme(theme)) { + throw new Error("Display settings API returned an unsupported theme."); + } + + return theme; +} + +async function writePersistedTheme(page: Page, theme: Theme) { + const response = await page.request.put(DISPLAY_SETTINGS_ENDPOINT, { + data: { theme }, + }); + + expect(response.ok()).toBe(true); + expect(await readPersistedTheme(page)).toBe(theme); +} + +async function setNetworkLatency(page: Page, latency: number) { + const session = await page.context().newCDPSession(page); + await session.send("Network.enable"); + await session.send("Network.emulateNetworkConditions", { + downloadThroughput: -1, + latency, + offline: false, + uploadThroughput: -1, + }); + return session; +} + +async function verifyPersistedThemeToggle(page: Page, targetTheme: Theme) { + await page.setViewportSize({ width: 1440, height: 900 }); + const signedInDisplayGet = page.waitForResponse( + (response) => + isDisplaySettingsRequest(response.request(), "GET") && + response.ok(), + ); + await ensureSignedIn(page); + await signedInDisplayGet; + + const originalTheme = await readPersistedTheme(page); + const initialTheme: Theme = targetTheme === "dark" ? "light" : "dark"; + const events: string[] = []; + let initialGetCompleted = false; + let networkSession: Awaited> | null = + null; + + const onRequest = (request: Request) => { + if (isDisplaySettingsRequest(request, "GET")) { + events.push("GET request"); + } + if (isThemePutRequest(request, targetTheme)) { + events.push("PUT request"); + } + }; + const onResponse = (response: Response) => { + if (isDisplaySettingsRequest(response.request(), "GET")) { + initialGetCompleted = true; + events.push("GET response"); + } + if (isThemePutRequest(response.request(), targetTheme)) { + events.push("PUT response"); + } + }; + + let primaryError: unknown; + page.on("request", onRequest); + page.on("response", onResponse); + + try { + await writePersistedTheme(page, initialTheme); + networkSession = await setNetworkLatency( + page, + THEME_NETWORK_LATENCY_MS, + ); + + const initialGetRequest = page.waitForRequest((request) => + isDisplaySettingsRequest(request, "GET"), + ); + const initialGetResponse = page.waitForResponse( + (response) => + isDisplaySettingsRequest(response.request(), "GET") && + response.ok(), + ); + const themePutResponse = page.waitForResponse( + (response) => + isThemePutRequest(response.request(), targetTheme) && + response.ok(), + ); + + await page.goto("/onboarding", { waitUntil: "domcontentloaded" }); + await initialGetRequest; + + const themeToggle = page.locator('[data-control="app-theme-toggle"]'); + await expect(themeToggle).toBeEnabled({ timeout: 1_000 }); + expect(initialGetCompleted).toBe(false); + + events.push("theme click"); + await themeToggle.click(); + expect(initialGetCompleted).toBe(false); + + await initialGetResponse; + await themePutResponse; + expect(events).toEqual([ + "GET request", + "theme click", + "GET response", + "PUT request", + "PUT response", + ]); + await expect(themeToggle).toHaveAttribute("data-state", targetTheme); + await expect(page.locator("html")).toHaveAttribute( + "data-theme", + targetTheme, + ); + expect(await readPersistedTheme(page)).toBe(targetTheme); + + await networkSession.send("Network.emulateNetworkConditions", { + downloadThroughput: -1, + latency: 0, + offline: false, + uploadThroughput: -1, + }); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(themeToggle).toHaveAttribute("data-state", targetTheme); + await expect(page.locator("html")).toHaveAttribute( + "data-theme", + targetTheme, + ); + expect(await readPersistedTheme(page)).toBe(targetTheme); + } catch (error) { + primaryError = error; + } + + page.off("request", onRequest); + page.off("response", onResponse); + + const cleanupErrors: unknown[] = []; + if (networkSession) { + try { + await networkSession.send("Network.emulateNetworkConditions", { + downloadThroughput: -1, + latency: 0, + offline: false, + uploadThroughput: -1, + }); + await networkSession.detach(); + } catch (error) { + cleanupErrors.push(error); + } + } + + try { + await writePersistedTheme(page, originalTheme); + } catch (error) { + cleanupErrors.push(error); + } + + if (primaryError || cleanupErrors.length > 0) { + throw new AggregateError( + [primaryError, ...cleanupErrors].filter(Boolean), + `Theme ${targetTheme} scenario or persisted-setting cleanup failed.`, + ); + } +} + +test("app route shell supports desktop navigation and a persisted collapsed sidebar", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await ensureSignedIn(page); + await page.goto("/onboarding", { waitUntil: "domcontentloaded" }); + + const shell = page.locator('[data-control="app-route-shell"]'); + const collapse = page.locator('[data-control="app-sidebar-collapse"]'); + + await expect(shell).toBeVisible(); + await expect(shell).toHaveAttribute("data-hydrated", "true"); + await expect( + page.getByRole("navigation", { name: "主导航" }), + ).toBeVisible(); + await collapse.click(); + await expect(shell).toHaveAttribute("data-state", "collapsed"); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(shell).toHaveAttribute("data-state", "collapsed"); + + await page.getByRole("link", { exact: true, name: "录音库" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); +}); + +for (const targetTheme of ["light", "dark"] as const) { + test(`app route shell persists ${targetTheme} after a click during the initial real GET`, async ({ + page, + }) => { + await verifyPersistedThemeToggle(page, targetTheme); + }); +} + +test("app route shell mobile Sheet traps focus and restores its trigger on Escape", async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + await ensureSignedIn(page); + await page.goto("/onboarding", { waitUntil: "domcontentloaded" }); + + const trigger = page.locator( + '[data-control="app-mobile-navigation-trigger"]', + ); + const sheet = page.locator('[data-control="app-mobile-navigation-sheet"]'); + + await expect( + page.locator('[data-control="app-route-shell"]'), + ).toHaveAttribute("data-hydrated", "true"); + await trigger.focus(); + await trigger.press("Enter"); + await expect(sheet).toBeVisible(); + await expect(sheet).toHaveAttribute("data-state", "open"); + await expect(sheet.locator(":focus")).toHaveCount(1); + await expect(sheet.getByRole("link", { name: "设置" })).toBeVisible(); + + const focusable = sheet.locator( + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + const focusableCount = await focusable.count(); + expect(focusableCount).toBeGreaterThan(1); + for (let index = 0; index <= focusableCount; index += 1) { + await page.keyboard.press("Tab"); + await expect(sheet.locator(":focus")).toHaveCount(1); + } + await page.keyboard.press("Shift+Tab"); + await expect(sheet.locator(":focus")).toHaveCount(1); + + const [sheetBox, settingsLinkBox, titleBox] = await Promise.all([ + sheet.boundingBox(), + sheet.getByRole("link", { name: "设置" }).boundingBox(), + page.locator('[data-control="app-route-title"]').boundingBox(), + ]); + + expect(sheetBox).not.toBeNull(); + expect(settingsLinkBox).not.toBeNull(); + expect(titleBox).not.toBeNull(); + expect(settingsLinkBox?.x).toBeGreaterThanOrEqual(sheetBox?.x ?? 0); + expect( + (settingsLinkBox?.x ?? 0) + (settingsLinkBox?.width ?? 0), + ).toBeLessThanOrEqual((sheetBox?.x ?? 0) + (sheetBox?.width ?? 0)); + expect((titleBox?.x ?? 0) + (titleBox?.width ?? 0)).toBeLessThanOrEqual( + 390, + ); + + await page.keyboard.press("Escape"); + await expect(sheet).toHaveCount(0); + await expect(trigger).toBeFocused(); +}); + +test("app route shell uses the mobile navigation layout through the tablet breakpoint", async ({ + page, +}) => { + await page.setViewportSize({ width: 900, height: 900 }); + await ensureSignedIn(page); + await page.goto("/onboarding", { waitUntil: "domcontentloaded" }); + + const trigger = page.locator( + '[data-control="app-mobile-navigation-trigger"]', + ); + const collapse = page.locator('[data-control="app-sidebar-collapse"]'); + const title = page.locator('[data-control="app-route-title"]'); + + await expect( + page.locator('[data-control="app-route-shell"]'), + ).toHaveAttribute("data-hydrated", "true"); + await expect(trigger).toBeVisible(); + await expect(collapse).toBeHidden(); + + const titleBox = await title.boundingBox(); + expect(titleBox).not.toBeNull(); + expect(titleBox?.x).toBeLessThan(120); + + await trigger.click(); + const sheet = page.locator('[data-control="app-mobile-navigation-sheet"]'); + await expect(sheet).toBeVisible(); + await expect(sheet.locator(":focus")).toHaveCount(1); + await page.keyboard.press("Escape"); + await expect(trigger).toBeFocused(); +}); diff --git a/e2e/auth-onboarding-ui.spec.ts b/e2e/auth-onboarding-ui.spec.ts index b7242869..7c0bc983 100644 --- a/e2e/auth-onboarding-ui.spec.ts +++ b/e2e/auth-onboarding-ui.spec.ts @@ -24,7 +24,9 @@ const E2E_ENCRYPTION_KEY = const PLAYWRIGHT_EMAIL = "playwright-admin@example.com"; const ONBOARDING_BACKEND_TICNOTE_TOKEN = "playwright-onboarding-ticnote-token"; +const ONBOARDING_SOT_DINGTALK_TOKEN = "playwright-onboarding-dingtalk-token"; const ONBOARDING_BACKEND_TICNOTE_BASE_URL = "https://voice-api.ticnote.cn"; +const ONBOARDING_BACKEND_TICNOTE_TIMEZONE = "Asia/Taipei"; const ONBOARDING_BACKEND_PERSISTENCE_SPEAKER = "林梅 Backend Readback"; const ONBOARDING_BACKEND_PERSISTENCE_VOICEPRINT = @@ -49,6 +51,8 @@ const ROW_119_WEB_KIT_INDEX_REL = const ELECTRON_ONBOARDING_REFERENCE_REL = "tmp/betterainote-design-evidence/run-20260605-sot-1to1/auth-onboarding-20260611/electron-onboarding-artboard.png"; +test.use({ timezoneId: ONBOARDING_BACKEND_TICNOTE_TIMEZONE }); + interface SotPixelDiff { alphaDiffPixels: number; bounds: { @@ -279,6 +283,25 @@ async function withVoiceprintsClient( } } +async function withExclusiveVoiceprintsWriteLock(callback: () => Promise) { + const client = createClient({ url: databaseUrl(VOICEPRINTS_DB) }); + let transactionOpen = false; + + try { + await client.execute("BEGIN IMMEDIATE"); + transactionOpen = true; + return await callback(); + } finally { + try { + if (transactionOpen) { + await client.execute("ROLLBACK"); + } + } finally { + client.close(); + } + } +} + async function getPlaywrightUserId() { return withCoreClient(async (client) => { const result = await client.execute({ @@ -349,7 +372,7 @@ async function seedTicnoteFallbackConnectionForOnboarding( orgId: "e2e-onboarding-org", region: "cn", syncTitleToSource: false, - timezone: "Asia/Shanghai", + timezone: ONBOARDING_BACKEND_TICNOTE_TIMEZONE, }), encryptWithE2EKey( JSON.stringify({ @@ -363,6 +386,42 @@ async function seedTicnoteFallbackConnectionForOnboarding( }); } +async function seedSotDefaultSourceState(page: Page, userId: string) { + const now = Date.now(); + await seedTicnoteFallbackConnectionForOnboarding(userId, true); + await withCoreClient(async (client) => { + await client.execute({ + sql: ` + INSERT INTO source_connections ( + id, user_id, provider, enabled, auth_mode, base_url, + config, secret_config, created_at, updated_at + ) VALUES (?, ?, 'dingtalk-a1', 1, 'device-signin', ?, '{}', ?, ?, ?) + `, + args: [ + `onboarding-dingtalk-sot-${now}`, + userId, + "https://meeting-ai-tingji.dingtalk.com", + encryptWithE2EKey( + JSON.stringify({ + deviceCredential: ONBOARDING_SOT_DINGTALK_TOKEN, + }), + ), + now, + now, + ], + }); + }); + + const response = await page.request.put("/api/settings/transcription", { + data: { + autoTranscribe: true, + defaultTranscriptionLanguage: "zh", + defaultTranscriptionProvider: "dingtalk-a1", + }, + }); + expect(response.ok()).toBe(true); +} + async function readOnboardingBackendPersistenceRows( userId: string, provider: string, @@ -490,9 +549,13 @@ function sotControl(page: Page, control: string) { case "auth-email": return page.getByRole("textbox", { name: "邮箱" }); case "local-only": - return page.getByRole("button", { name: "仅本地使用" }); + return page.getByRole("button", { + name: /^(仅本地使用|启动中\.\.\.)$/, + }); case "send-login-link": - return page.getByRole("button", { name: "发送登录链接" }); + return page.getByRole("button", { + name: /^(发送登录链接|发送中\.\.\.)$/, + }); case "speaker-name": return page.getByRole("textbox", { name: "显示名称" }); case "speaker-voiceprint": @@ -505,7 +568,7 @@ function sotControl(page: Page, control: string) { } function onboardingProvider(page: Page, name: string | RegExp) { - return sotList(page, "provider-cards").getByRole("radio", { name }); + return sotList(page, "provider-cards").getByRole("button", { name }); } function onboardingDefaultSource(page: Page, name: string | RegExp) { @@ -527,7 +590,7 @@ function sotList(page: Page, list: string) { case "finish-summary": return page.getByRole("region", { name: "配置摘要" }); case "provider-cards": - return page.getByRole("radiogroup", { name: "来源" }); + return page.getByRole("group", { name: "来源选项" }); default: throw new Error(`No semantic locator is defined for ${list}`); } @@ -563,16 +626,50 @@ async function expectOnboardingState( page: Page, state: keyof typeof ONBOARDING_STEPS, ) { - await expect(onboardingStep(page, state)).toHaveAttribute( - "aria-current", - "step", - ); + const step = ONBOARDING_STEPS[state]; + const heading = page.getByRole("heading", { + exact: true, + level: 2, + name: `第 ${step.index} 步 · ${step.title}`, + }); + await expect(heading).toBeVisible(); } function authForm(page: Page) { return page.getByRole("main").locator("form"); } +function createDeferredSignal() { + let resolveSignal: (() => void) | undefined; + const promise = new Promise((resolve) => { + resolveSignal = resolve; + }); + if (!resolveSignal) { + throw new Error("Deferred signal resolver was not initialized"); + } + return { promise, resolve: resolveSignal }; +} + +async function delayNextAuthRequest(page: Page, requestPath: string) { + const reachedBackendBoundary = createDeferredSignal(); + const releaseRequest = createDeferredSignal(); + + await page.route( + `**${requestPath}`, + async (route) => { + reachedBackendBoundary.resolve(); + await releaseRequest.promise; + await route.continue(); + }, + { times: 1 }, + ); + + return { + reachedBackendBoundary: reachedBackendBoundary.promise, + release: releaseRequest.resolve, + }; +} + async function gotoAuthPage(page: Page, path: "/login" | "/register") { await page.goto(path, { waitUntil: "domcontentloaded" }); await expect( @@ -625,19 +722,12 @@ async function goToOnboardingState( page: Page, state: keyof typeof ONBOARDING_STEPS, ) { - const nextButton = page.getByRole("button", { - exact: true, - name: "下一步", - }); - - for (let attempt = 0; attempt < 4; attempt += 1) { - await nextButton.click(); - if (await onboardingStep(page, state).getAttribute("aria-current").then((value) => value === "step")) { - return; - } - await page.waitForTimeout(250); - } - + const activeHeading = page.locator("#onboarding-step-title"); + const actionName = + (await activeHeading.textContent())?.includes("说话人档案") + ? "保存并继续" + : "下一步"; + await page.getByRole("button", { exact: true, name: actionName }).click(); await expectOnboardingState(page, state); } @@ -736,7 +826,13 @@ async function readOnboardingDefaultSourceSotEquivalentHtml( ), ).map((source) => ({ label: normalizeText(source.textContent), - state: source.getAttribute("aria-checked") === "true" ? "selected" : source.getAttribute("disabled") !== null ? "disabled" : "idle", + state: + (source.getAttribute("aria-pressed") ?? + source.getAttribute("aria-checked")) === "true" + ? "selected" + : source.getAttribute("disabled") !== null + ? "disabled" + : "idle", })); if (steps.length !== 4) { @@ -1584,13 +1680,16 @@ test("SOT onboarding default source card matches §09 pixels", async ({ document.body.dataset.theme = "dark"; }); await ensureSignedIn(page); - await resetOnboardingConnections(await getPlaywrightUserId()); + const userId = await getPlaywrightUserId(); + await resetOnboardingConnections(userId); + await seedSotDefaultSourceState(page, userId); await gotoOnboardingPage(page); await page.evaluate(() => { document.documentElement.dataset.theme = "dark"; document.body.dataset.theme = "dark"; }); - await goToOnboardingState(page, "transcription"); + await page.getByRole("button", { exact: true, name: "返回" }).click(); + await expectOnboardingState(page, "transcription"); await expectOnboardingDefaultSourcePixelsMatch( page, @@ -1690,12 +1789,15 @@ test("row 119 auth/onboarding visual matrix evidence", async ({ }); await ensureSignedIn(page); - await resetOnboardingConnections(await getPlaywrightUserId()); + const userId = await getPlaywrightUserId(); + await resetOnboardingConnections(userId); + await seedSotDefaultSourceState(page, userId); await page.setViewportSize({ width: 1280, height: 900 }); await gotoOnboardingPage(page); await forceDarkTheme(page); - await goToOnboardingState(page, "transcription"); + await page.getByRole("button", { exact: true, name: "返回" }).click(); + await expectOnboardingState(page, "transcription"); const onboardingCard = currentOnboardingPanel(page); const onboardingPanel = currentOnboardingPanel(page); await expectOnboardingState(page, "transcription"); @@ -1738,7 +1840,8 @@ test("row 119 auth/onboarding visual matrix evidence", async ({ await page.setViewportSize({ width: 390, height: 780 }); await gotoOnboardingPage(page); await forceDarkTheme(page); - await goToOnboardingState(page, "transcription"); + await page.getByRole("button", { exact: true, name: "返回" }).click(); + await expectOnboardingState(page, "transcription"); const mobileOnboardingCard = currentOnboardingPanel(page); await expectOnboardingState(page, "transcription"); await expect( @@ -1768,8 +1871,7 @@ test("row 119 auth/onboarding visual matrix evidence", async ({ "No separate mobile onboarding artboard exists in §09; this is runtime structural evidence, not a mobile pixel claim.", }); - await gotoOnboardingPage(page); - await forceDarkTheme(page); + await page.getByRole("button", { exact: true, name: "返回" }).click(); await expectOnboardingState(page, "source"); await expect(sotList(page, "provider-cards")).toBeVisible(); frames.push({ @@ -1797,7 +1899,7 @@ test("row 119 auth/onboarding visual matrix evidence", async ({ await page.setViewportSize({ width: 1280, height: 900 }); await gotoOnboardingPage(page); await forceDarkTheme(page); - await goToOnboardingState(page, "speakers"); + await expectOnboardingState(page, "speakers"); await sotControl(page, "speaker-name").fill("林梅"); await expectOnboardingState(page, "speakers"); frames.push({ @@ -1970,6 +2072,64 @@ test("row 119 auth/onboarding visual matrix evidence", async ({ ); }); +test("SOT onboarding remains usable across desktop and mobile light and dark quadrants", async ({ + page, +}, testInfo) => { + await ensureSignedIn(page); + const userId = await getPlaywrightUserId(); + await resetOnboardingBackendPersistenceState(userId); + await gotoOnboardingPage(page); + + for (const quadrant of [ + { + id: "desktop-dark", + theme: "dark", + viewport: { width: 1280, height: 900 }, + }, + { + id: "desktop-light", + theme: "light", + viewport: { width: 1280, height: 900 }, + }, + { + id: "mobile-dark", + theme: "dark", + viewport: { width: 390, height: 780 }, + }, + { + id: "mobile-light", + theme: "light", + viewport: { width: 390, height: 780 }, + }, + ] as const) { + await page.setViewportSize(quadrant.viewport); + await page.evaluate((theme) => { + document.documentElement.dataset.theme = theme; + document.body.dataset.theme = theme; + }, quadrant.theme); + + await expect(currentOnboardingPanel(page)).toBeVisible(); + await expectOnboardingState(page, "source"); + await expect(sotList(page, "provider-cards")).toBeVisible(); + const viewportMetrics = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + expect( + viewportMetrics.scrollWidth, + `${quadrant.id} must not overflow horizontally`, + ).toBeLessThanOrEqual(viewportMetrics.clientWidth); + + await testInfo.attach(`onboarding-${quadrant.id}-structural.png`, { + body: await page.screenshot({ + animations: "disabled", + fullPage: true, + }), + contentType: "image/png", + }); + } +}); + test("SOT auth sends a magic link and never exposes the old password form", async ({ page, }) => { @@ -1995,7 +2155,28 @@ test("SOT auth sends a magic link and never exposes the old password form", asyn await expect(page.locator("#name")).toHaveCount(0); await emailInput.fill("magic-ui@example.com"); - await sendLoginLink(page); + const delayedRequest = await delayNextAuthRequest( + page, + "/api/auth/sign-in/magic-link", + ); + const responsePromise = page.waitForResponse( + (response) => + response.url().includes("/api/auth/sign-in/magic-link") && + response.request().method() === "POST", + ); + await sotControl(page, "send-login-link").click(); + await delayedRequest.reachedBackendBoundary; + + await expect(form).toHaveAttribute("aria-busy", "true"); + await expect( + form.getByRole("button", { name: "发送中..." }), + ).toBeDisabled(); + await expect(sotControl(page, "local-only")).toBeDisabled(); + await expect(emailInput).toBeDisabled(); + + delayedRequest.release(); + const response = await responsePromise; + expect(response.ok()).toBe(true); const successMessage = form.getByRole("status"); await expect(successMessage).toHaveAttribute("id", "auth-form-message"); @@ -2003,7 +2184,7 @@ test("SOT auth sends a magic link and never exposes the old password form", asyn expect(await readMagicLinkVerification("magic-ui@example.com")).toBe(true); }); -test("SOT auth blocks second-user magic links and supports local-only session", async ({ +test("SOT auth recovers from a rejected email and supports local-only session", async ({ page, }) => { await resetAuthUsers(); @@ -2025,18 +2206,54 @@ test("SOT auth blocks second-user magic links and supports local-only session", await expect( form.getByRole("alert"), ).toContainText( - /Registration is disabled|登录链接发送失败/, + "此工作空间已完成注册,请使用已注册的邮箱登录", ); expect(await readMagicLinkVerification("other-admin@example.com")).toBe( false, ); + await sotControl(page, "auth-email").fill(PLAYWRIGHT_EMAIL); + await expect(sotControl(page, "auth-email")).toHaveAttribute( + "aria-invalid", + "false", + ); + await expect(sotControl(page, "auth-email")).not.toHaveAttribute( + "aria-describedby", + ); + await expect(form.getByRole("alert")).toHaveCount(0); + + await sendLoginLink(page); + await expect(form.getByRole("status")).toContainText("登录链接已发送"); + expect(await readMagicLinkVerification(PLAYWRIGHT_EMAIL)).toBe(true); + await resetAuthUsers(); await gotoAuthPage(page, "/login"); - await Promise.all([ + const delayedRequest = await delayNextAuthRequest( + page, + "/api/auth/sign-in/anonymous", + ); + const anonymousResponsePromise = page.waitForResponse( + (response) => + response.url().includes("/api/auth/sign-in/anonymous") && + response.request().method() === "POST", + ); + await sotControl(page, "local-only").click(); + await delayedRequest.reachedBackendBoundary; + + const localForm = authForm(page); + await expect(localForm).toHaveAttribute("aria-busy", "true"); + await expect( + localForm.getByRole("button", { name: "启动中..." }), + ).toBeDisabled(); + await expect(sotControl(page, "send-login-link")).toBeDisabled(); + await expect(sotControl(page, "auth-email")).toBeDisabled(); + + delayedRequest.release(); + const [anonymousResponse] = await Promise.all([ + anonymousResponsePromise, page.waitForURL("**/dashboard", { waitUntil: "commit" }), - sotControl(page, "local-only").click(), ]); + expect(anonymousResponse.ok()).toBe(true); expect(await countAnonymousUsers()).toBe(1); }); @@ -2047,14 +2264,42 @@ test("SOT register route reuses the email-link setup surface without legacy acco await resetAuthUsers(); await gotoAuthPage(page, "/register"); - await expect(authForm(page)).toBeVisible(); + const form = authForm(page); + await expect(form).toBeVisible(); await expect( - authForm(page).getByText("上手 / Sign in", { exact: true }), + form.getByText("上手 / Sign in", { exact: true }), ).toBeVisible(); await expect(sotControl(page, "auth-email")).toBeEditable(); await expect(page.locator("#password")).toHaveCount(0); await expect(page.locator("#name")).toHaveCount(0); await expect(sotControl(page, "local-only")).toBeVisible(); + + await sotControl(page, "auth-email").fill("register-ui@example.com"); + const delayedRequest = await delayNextAuthRequest( + page, + "/api/auth/sign-in/magic-link", + ); + const responsePromise = page.waitForResponse( + (response) => + response.url().includes("/api/auth/sign-in/magic-link") && + response.request().method() === "POST", + ); + await sotControl(page, "send-login-link").click(); + await delayedRequest.reachedBackendBoundary; + + await expect(form).toHaveAttribute("aria-busy", "true"); + await expect( + form.getByRole("button", { name: "发送中..." }), + ).toBeDisabled(); + await expect(sotControl(page, "local-only")).toBeDisabled(); + + delayedRequest.release(); + const response = await responsePromise; + expect(response.ok()).toBe(true); + await expect(form.getByRole("status")).toContainText("登录链接已发送"); + expect(await readMagicLinkVerification("register-ui@example.com")).toBe( + true, + ); }); test("SOT onboarding exposes source, default transcription, speaker, and finish states", async ({ @@ -2062,7 +2307,9 @@ test("SOT onboarding exposes source, default transcription, speaker, and finish }) => { await page.setViewportSize({ width: 390, height: 780 }); await ensureSignedIn(page); - await resetOnboardingConnections(await getPlaywrightUserId()); + const userId = await getPlaywrightUserId(); + await resetOnboardingBackendPersistenceState(userId); + await seedTicnoteFallbackConnectionForOnboarding(userId); await gotoOnboardingPage(page); @@ -2078,7 +2325,7 @@ test("SOT onboarding exposes source, default transcription, speaker, and finish await expect(sotList(page, "provider-cards")).toBeVisible(); await expect( onboardingProvider(page, "Plaud"), - ).toHaveAttribute("aria-checked", "true"); + ).toHaveAttribute("aria-pressed", "true"); await expect( onboardingMatrixRow(page, "当前来源"), ).toContainText("Plaud"); @@ -2086,22 +2333,28 @@ test("SOT onboarding exposes source, default transcription, speaker, and finish const feishuProvider = onboardingProvider(page, "飞书妙记"); await feishuProvider.click(); - await expect(feishuProvider).toHaveAttribute("aria-checked", "true"); + await expect(feishuProvider).toHaveAttribute("aria-pressed", "true"); await expect(onboardingMatrixRow(page, "当前来源")).toContainText("飞书妙记"); + const ticnoteProvider = onboardingProvider(page, "TicNote"); + await ticnoteProvider.click(); + await expect(ticnoteProvider).toHaveAttribute("aria-pressed", "true"); + await page.locator("#source-secret").fill(ONBOARDING_BACKEND_TICNOTE_TOKEN); + await goToOnboardingState(page, "transcription"); await expect(sotList(page, "onboarding-default-sources")).toBeVisible(); await expect( sotPanel(page, "onboarding-default-source-step"), ).toBeVisible(); - const feishuDefaultSource = onboardingDefaultSource(page, /飞书妙记/); const ticnoteDefaultSource = onboardingDefaultSource(page, /TicNote/); - await expect(feishuDefaultSource).toHaveAttribute("aria-checked", "false"); - await expect(feishuDefaultSource).toBeEnabled(); - await expect(ticnoteDefaultSource).toBeDisabled(); - await feishuDefaultSource.click(); + const feishuDefaultSource = onboardingDefaultSource(page, /飞书妙记/); + await expect(ticnoteDefaultSource).toBeFocused(); + await expect(ticnoteDefaultSource).toHaveAttribute("aria-checked", "false"); + await expect(ticnoteDefaultSource).toBeEnabled(); + await expect(feishuDefaultSource).toBeDisabled(); + await ticnoteDefaultSource.click(); await expect( - feishuDefaultSource, + ticnoteDefaultSource, ).toHaveAttribute("aria-checked", "true"); const transcriptionBack = page.getByRole("button", { @@ -2112,12 +2365,12 @@ test("SOT onboarding exposes source, default transcription, speaker, and finish await expect(transcriptionBack).toBeFocused(); await page.keyboard.press("Enter"); await expectOnboardingState(page, "source"); - await expect(feishuProvider).toHaveAttribute("aria-checked", "true"); - await expect(onboardingMatrixRow(page, "当前来源")).toContainText("飞书妙记"); + await expect(ticnoteProvider).toHaveAttribute("aria-pressed", "true"); + await expect(onboardingMatrixRow(page, "当前来源")).toContainText("TicNote"); await goToOnboardingState(page, "transcription"); await expect( - feishuDefaultSource, + ticnoteDefaultSource, ).toHaveAttribute("aria-checked", "true"); await goToOnboardingState(page, "speakers"); @@ -2137,105 +2390,91 @@ test("SOT onboarding save connects source, transcription defaults, and speaker p page, }) => { await ensureSignedIn(page); - await resetOnboardingConnections(await getPlaywrightUserId()); + const userId = await getPlaywrightUserId(); + await resetOnboardingBackendPersistenceState(userId); + await seedTicnoteFallbackConnectionForOnboarding(userId); let dataSourcePayload: Record | null = null; let transcriptionPayload: Record | null = null; let speakerPayload: Record | null = null; - await page.route("**/api/data-sources", async (route) => { - if (route.request().method() === "PUT") { - dataSourcePayload = route.request().postDataJSON(); - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ success: true }), - }); - return; + page.on("request", (request) => { + const pathname = new URL(request.url()).pathname; + if (pathname === "/api/data-sources" && request.method() === "PUT") { + dataSourcePayload = request.postDataJSON(); } - - await route.continue(); - }); - await page.route("**/api/settings/transcription", async (route) => { - if (route.request().method() === "PUT") { - transcriptionPayload = route.request().postDataJSON(); - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ success: true }), - }); - return; + if ( + pathname === "/api/settings/transcription" && + request.method() === "PUT" + ) { + transcriptionPayload = request.postDataJSON(); } - - await route.continue(); - }); - await page.route("**/api/speakers/profiles", async (route) => { - if (route.request().method() === "POST") { - speakerPayload = route.request().postDataJSON(); - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - profile: { - id: "profile-e2e", - displayName: "林梅", - voiceprintRef: "voiceprint-playwright", - assignmentCount: 0, - }, - }), - }); - return; + if ( + pathname === "/api/speakers/profiles" && + request.method() === "POST" + ) { + speakerPayload = request.postDataJSON(); } - - await route.continue(); }); await gotoOnboardingPage(page); + await onboardingProvider(page, "TicNote").click(); const authorizationInput = page.locator("#source-secret"); await expect(authorizationInput).toBeEditable(); - await authorizationInput.fill("Bearer playwright-onboarding-token"); - await expect(authorizationInput).toHaveValue( - "Bearer playwright-onboarding-token", - ); + await authorizationInput.fill(ONBOARDING_BACKEND_TICNOTE_TOKEN); + await expect(authorizationInput).toHaveValue(ONBOARDING_BACKEND_TICNOTE_TOKEN); + const sourceResponsePromise = page.waitForResponse( + (response) => + response.url().includes("/api/data-sources") && + response.request().method() === "PUT", + ); await goToOnboardingState(page, "transcription"); + expect((await sourceResponsePromise).ok()).toBe(true); + const ticnoteDefaultSource = onboardingDefaultSource(page, /TicNote/); + await expect(ticnoteDefaultSource).toBeEnabled(); + await ticnoteDefaultSource.click(); + await expect(ticnoteDefaultSource).toHaveAttribute("aria-checked", "true"); + + const transcriptionResponsePromise = page.waitForResponse( + (response) => + response.url().includes("/api/settings/transcription") && + response.request().method() === "PUT", + ); await goToOnboardingState(page, "speakers"); + expect((await transcriptionResponsePromise).ok()).toBe(true); await sotControl(page, "speaker-name").fill("林梅"); await sotControl(page, "speaker-voiceprint").fill("voiceprint-playwright"); + await goToOnboardingState(page, "finish"); + const speakerReadback = await page.request.get("/api/speakers/profiles"); + expect(speakerReadback.ok()).toBe(true); + await expect(speakerReadback.json()).resolves.toMatchObject({ + profiles: expect.arrayContaining([ + expect.objectContaining({ + displayName: "林梅", + voiceprintRef: "voiceprint-playwright", + }), + ]), + }); await Promise.all([ - page.waitForResponse( - (response) => - response.url().includes("/api/data-sources") && - response.request().method() === "PUT", - ), - page.waitForResponse( - (response) => - response.url().includes("/api/settings/transcription") && - response.request().method() === "PUT", - ), - page.waitForResponse( - (response) => - response.url().includes("/api/speakers/profiles") && - response.request().method() === "POST", - ), page.waitForURL("**/dashboard", { waitUntil: "commit" }), sotControl(page, "save-enter").click(), ]); expect(dataSourcePayload).toMatchObject({ - provider: "plaud", + provider: "ticnote", enabled: true, secrets: { - bearerToken: "Bearer playwright-onboarding-token", + bearerToken: ONBOARDING_BACKEND_TICNOTE_TOKEN, }, }); expect(transcriptionPayload).toMatchObject({ autoTranscribe: true, defaultTranscriptionLanguage: "zh", - defaultTranscriptionProvider: null, + defaultTranscriptionProvider: "ticnote", }); expect(speakerPayload).toMatchObject({ displayName: "林梅", @@ -2243,23 +2482,31 @@ test("SOT onboarding save connects source, transcription defaults, and speaker p }); }); -test("SOT onboarding starts without a default transcription source until a compatible source is selected", async ({ +test("SOT onboarding starts without a default and exposes only connected transcription sources", async ({ page, }) => { await ensureSignedIn(page); - await resetOnboardingBackendPersistenceState(await getPlaywrightUserId()); + const userId = await getPlaywrightUserId(); + await resetOnboardingBackendPersistenceState(userId); + const initialSettings = await page.request.get( + "/api/settings/transcription", + ); + expect(initialSettings.ok()).toBe(true); + await expect(initialSettings.json()).resolves.toMatchObject({ + defaultTranscriptionProvider: null, + }); + await seedTicnoteFallbackConnectionForOnboarding(userId); await gotoOnboardingPage(page); + await onboardingProvider(page, "TicNote").click(); + await page.locator("#source-secret").fill(ONBOARDING_BACKEND_TICNOTE_TOKEN); await goToOnboardingState(page, "transcription"); - for (const defaultSource of [ - onboardingDefaultSource(page, /钉钉 闪记/), - onboardingDefaultSource(page, /TicNote/), - onboardingDefaultSource(page, /飞书妙记/), - ]) { - await expect(defaultSource).toHaveAttribute("aria-checked", "false"); - await expect(defaultSource).toBeDisabled(); - } + const ticnoteSource = onboardingDefaultSource(page, /TicNote/); + await expect(ticnoteSource).toHaveAttribute("aria-checked", "false"); + await expect(ticnoteSource).toBeEnabled(); + await expect(onboardingDefaultSource(page, /钉钉 闪记/)).toBeDisabled(); + await expect(onboardingDefaultSource(page, /飞书妙记/)).toBeDisabled(); }); test("SOT onboarding first connection saves a current-draft default through API, SQLite, and reload", async ({ @@ -2280,6 +2527,22 @@ test("SOT onboarding first connection saves a current-draft default through API, auth_mode: "bearer", base_url: ONBOARDING_BACKEND_TICNOTE_BASE_URL, }); + const seededConfig = JSON.parse( + String(seededRows.source?.config), + ) as Record; + expect(seededConfig).toEqual({ + language: "zh", + orgId: "e2e-onboarding-org", + region: "cn", + syncTitleToSource: false, + timezone: ONBOARDING_BACKEND_TICNOTE_TIMEZONE, + }); + expect(String(seededRows.source?.secret_config)).toMatch( + /^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/, + ); + expect(String(seededRows.source?.secret_config)).not.toContain( + ONBOARDING_BACKEND_TICNOTE_TOKEN, + ); await gotoOnboardingPage(page); @@ -2289,11 +2552,25 @@ test("SOT onboarding first connection saves a current-draft default through API, await expect(authorizationInput).toBeEditable(); await authorizationInput.fill(ONBOARDING_BACKEND_TICNOTE_TOKEN); + const dataSourceResponsePromise = page.waitForResponse( + (response) => + response.url().includes("/api/data-sources") && + response.request().method() === "PUT", + ); await goToOnboardingState(page, "transcription"); + const dataSourceResponse = await dataSourceResponsePromise; + expect(dataSourceResponse.ok()).toBe(true); + expect(dataSourceResponse.request().postDataJSON()).toMatchObject({ + provider: "ticnote", + enabled: true, + secrets: { + bearerToken: ONBOARDING_BACKEND_TICNOTE_TOKEN, + }, + }); const ticnoteDefaultSource = onboardingDefaultSource(page, /TicNote/); await expect(ticnoteDefaultSource).toHaveAttribute( "aria-label", - "TicNote · 当前草稿(未连接)", + "TicNote · 已连接", ); await expect(ticnoteDefaultSource).toHaveAttribute("aria-checked", "false"); await expect(onboardingDefaultSource(page, /钉钉 闪记/)).toBeDisabled(); @@ -2303,49 +2580,37 @@ test("SOT onboarding first connection saves a current-draft default through API, ticnoteDefaultSource, ).toHaveAttribute("aria-checked", "true"); + const transcriptionResponsePromise = page.waitForResponse( + (response) => + response.url().includes("/api/settings/transcription") && + response.request().method() === "PUT", + ); await goToOnboardingState(page, "speakers"); + const transcriptionResponse = await transcriptionResponsePromise; + expect(transcriptionResponse.ok()).toBe(true); + expect(transcriptionResponse.request().postDataJSON()).toMatchObject({ + defaultTranscriptionProvider: "ticnote", + }); await sotControl(page, "speaker-name").fill( ONBOARDING_BACKEND_PERSISTENCE_SPEAKER, ); await sotControl(page, "speaker-voiceprint").fill( ONBOARDING_BACKEND_PERSISTENCE_VOICEPRINT, ); + const speakerResponsePromise = page.waitForResponse( + (response) => + response.url().includes("/api/speakers/profiles") && + response.request().method() === "POST", + ); await goToOnboardingState(page, "finish"); - - const [dataSourceResponse, transcriptionResponse, speakerResponse] = - await Promise.all([ - page.waitForResponse( - (response) => - response.url().includes("/api/data-sources") && - response.request().method() === "PUT", - ), - page.waitForResponse( - (response) => - response.url().includes("/api/settings/transcription") && - response.request().method() === "PUT", - ), - page.waitForResponse( - (response) => - response.url().includes("/api/speakers/profiles") && - response.request().method() === "POST", - ), - page.waitForURL("**/dashboard", { waitUntil: "commit" }), - sotControl(page, "save-enter").click(), - ]); - - expect(dataSourceResponse.ok()).toBe(true); - expect(transcriptionResponse.ok()).toBe(true); + const speakerResponse = await speakerResponsePromise; expect(speakerResponse.ok()).toBe(true); - expect(dataSourceResponse.request().postDataJSON()).toMatchObject({ - provider: "ticnote", - enabled: true, - secrets: { - bearerToken: ONBOARDING_BACKEND_TICNOTE_TOKEN, - }, - }); - expect(transcriptionResponse.request().postDataJSON()).toMatchObject({ - defaultTranscriptionProvider: "ticnote", + + const dashboardPromise = page.waitForURL("**/dashboard", { + waitUntil: "commit", }); + await sotControl(page, "save-enter").click(); + await dashboardPromise; const dataSourcesReadback = await page.request.get("/api/data-sources"); expect(dataSourcesReadback.ok()).toBe(true); @@ -2361,6 +2626,7 @@ test("SOT onboarding first connection saves a current-draft default through API, connected: true, authMode: "bearer", baseUrl: ONBOARDING_BACKEND_TICNOTE_BASE_URL, + config: seededConfig, secretsConfigured: { bearerToken: true, }, @@ -2405,12 +2671,7 @@ test("SOT onboarding first connection saves a current-draft default through API, expect(String(persistedSource.secret_config)).not.toContain( ONBOARDING_BACKEND_TICNOTE_TOKEN, ); - expect(JSON.parse(String(persistedSource.config))).toMatchObject({ - language: "zh", - region: "cn", - syncTitleToSource: false, - timezone: expect.any(String), - }); + expect(JSON.parse(String(persistedSource.config))).toEqual(seededConfig); expect(Number(persistedSettings.auto_transcribe)).toBe(1); expect(persistedSettings.default_transcription_language).toBe("zh"); expect(persistedSettings.default_transcription_provider).toBe("ticnote"); @@ -2447,21 +2708,106 @@ test("SOT onboarding first connection saves a current-draft default through API, }); }); -test("SOT onboarding finish/save surfaces a real backend data-source failure without route mocks", async ({ +test("SOT onboarding speaker draft recovers the same Next server after a real SQLite lock", async ({ page, }) => { await ensureSignedIn(page); const userId = await getPlaywrightUserId(); await resetOnboardingBackendPersistenceState(userId); await seedTicnoteFallbackConnectionForOnboarding(userId, true); + + await gotoOnboardingPage(page); + await expectOnboardingState(page, "transcription"); + await page.getByRole("button", { exact: true, name: "跳过" }).click(); + await expectOnboardingState(page, "speakers"); + await sotControl(page, "speaker-name").fill( + "Locked onboarding speaker", + ); + await sotControl(page, "speaker-voiceprint").fill( + "voiceprint-onboarding-lock-retry", + ); + + const lockedResponse = await withExclusiveVoiceprintsWriteLock(() => + page.request.post("/api/speakers/profiles", { + data: { + displayName: "Locked onboarding speaker", + voiceprintRef: "voiceprint-onboarding-lock-retry", + }, + }), + ); + expect(lockedResponse.status()).toBe(500); + expect(await lockedResponse.text()).not.toContain( + "SQL statements in progress", + ); + await expect(page).toHaveURL(/\/onboarding/); + await expect(sotControl(page, "speaker-name")).toHaveValue( + "Locked onboarding speaker", + ); + + const retriedResponse = await page.request.post("/api/speakers/profiles", { + data: { + displayName: "Locked onboarding speaker", + voiceprintRef: "voiceprint-onboarding-lock-retry", + }, + }); + expect(retriedResponse.status()).toBe(200); + expect(await retriedResponse.text()).not.toContain( + "SQL statements in progress", + ); + + const retriedProfile = (await retriedResponse.json()) as { + profile?: { id?: string }; + }; + if (typeof retriedProfile.profile?.id !== "string") { + throw new Error("Speaker retry did not return a profile id"); + } + + const updateResponse = await page.request.patch( + `/api/speakers/profiles/${retriedProfile.profile.id}`, + { + data: { displayName: "Recovered onboarding speaker" }, + }, + ); + expect(updateResponse.status()).toBe(200); + expect(await updateResponse.text()).not.toContain( + "SQL statements in progress", + ); + + const deleteResponse = await page.request.delete( + `/api/speakers/profiles/${retriedProfile.profile.id}`, + ); + expect(deleteResponse.status()).toBe(200); + expect(await deleteResponse.text()).not.toContain( + "SQL statements in progress", + ); + + const speakerReadback = await page.request.get("/api/speakers/profiles"); + expect(speakerReadback.ok()).toBe(true); + await expect(speakerReadback.json()).resolves.toMatchObject({ profiles: [] }); +}); + +test("SOT onboarding finish/save surfaces a real backend data-source failure through local validation", async ({ + page, +}) => { + await ensureSignedIn(page); + const userId = await getPlaywrightUserId(); + await resetOnboardingBackendPersistenceState(userId); let transcriptionPutRequests = 0; + let speakerPostRequests = 0; page.on("request", (request) => { + const pathname = new URL(request.url()).pathname; if ( - new URL(request.url()).pathname === "/api/settings/transcription" && + pathname === "/api/settings/transcription" && request.method() === "PUT" ) { transcriptionPutRequests += 1; } + if ( + pathname === "/api/speakers/profiles" && + request.method() === "POST" + ) { + speakerPostRequests += 1; + } }); await gotoOnboardingPage(page); @@ -2474,18 +2820,13 @@ test("SOT onboarding finish/save surfaces a real backend data-source failure wit await page.locator("#source-custom-api-base").fill("https://example.com"); await page.locator("#source-secret").fill("Bearer invalid-custom-server"); - await goToOnboardingState(page, "transcription"); - await onboardingDefaultSource(page, /TicNote/).click(); - await goToOnboardingState(page, "speakers"); - await goToOnboardingState(page, "finish"); - const [dataSourceResponse] = await Promise.all([ page.waitForResponse( (response) => response.url().includes("/api/data-sources") && response.request().method() === "PUT", ), - sotControl(page, "save-enter").click(), + page.getByRole("button", { exact: true, name: "下一步" }).click(), ]); expect(dataSourceResponse.status()).toBe(400); @@ -2495,18 +2836,13 @@ test("SOT onboarding finish/save surfaces a real backend data-source failure wit await expect(page).toHaveURL(/\/onboarding/); await expectOnboardingState(page, "source"); await expect(currentOnboardingPanel(page).getByRole("alert")).toContainText( - "来源连接失败,请检查授权信息后重试。", + "来源连接失败,请检查必填信息后重试。", ); + await expect(page.locator("#source-secret")).toBeFocused(); expect(transcriptionPutRequests).toBe(0); + expect(speakerPostRequests).toBe(0); const rows = await readOnboardingBackendPersistenceRows(userId, "plaud"); expect(rows.settings).toBeNull(); expect(rows.source).toBeNull(); - const ticnoteRows = await readOnboardingBackendPersistenceRows( - userId, - "ticnote", - ); - expect(ticnoteRows.source).toMatchObject({ - provider: "ticnote", - enabled: 1, - }); + expect(rows.speakers).toEqual([]); }); diff --git a/e2e/dashboard-activity-system-banner-real-backend.spec.ts b/e2e/dashboard-activity-system-banner-real-backend.spec.ts new file mode 100644 index 00000000..0810e2d5 --- /dev/null +++ b/e2e/dashboard-activity-system-banner-real-backend.spec.ts @@ -0,0 +1,520 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createClient } from "@libsql/client"; +import { expect, test, type Page, type Response } from "@playwright/test"; +import { ensureSignedIn } from "./helpers/auth"; + +const E2E_ROOT = path.resolve( + process.env.PLAYWRIGHT_E2E_ROOT ?? path.join(process.cwd(), "tmp/e2e"), +); +const CORE_DB = path.join(E2E_ROOT, "data", "betterainote-e2e.db"); +const LIBRARY_DB = path.join(E2E_ROOT, "data", "betterainote-e2e-library.db"); +const PLAYWRIGHT_USER_EMAIL = "playwright-admin@example.com"; +const ACTIVITY_RECORDING_ID = "e2e-activity-system-banner-recording"; +const ACTIVITY_JOB_ID = "e2e-activity-system-banner-job"; + +type WorkerStateSnapshot = { + createdAt: number; + id: string; + isRunning: number; + lastError: string | null; + lastFinishedAt: number | null; + lastHeartbeatAt: number | null; + lastStartedAt: number | null; + lastSummary: string | null; + manualTriggerRequestedAt: number | null; + nextRunAt: number | null; + updatedAt: number; + userId: string; +} | null; + +function databaseUrl(databasePath: string) { + const resolved = path.resolve(databasePath); + if (resolved !== E2E_ROOT && !resolved.startsWith(`${E2E_ROOT}${path.sep}`)) { + throw new Error(`Refusing to access database outside E2E root: ${resolved}`); + } + return pathToFileURL(resolved).href; +} + +async function getPlaywrightUserId() { + const core = createClient({ url: databaseUrl(CORE_DB) }); + try { + const result = await core.execute({ + sql: "SELECT id FROM users WHERE email = ? LIMIT 1", + args: [PLAYWRIGHT_USER_EMAIL], + }); + const userId = result.rows[0]?.id; + if (typeof userId !== "string") { + throw new Error("Playwright user was not created"); + } + return userId; + } finally { + await core.close(); + } +} + +async function snapshotWorkerState(userId: string): Promise { + const core = createClient({ url: databaseUrl(CORE_DB) }); + try { + const result = await core.execute({ + sql: ` + SELECT id, user_id, last_heartbeat_at, last_started_at, + last_finished_at, next_run_at, manual_trigger_requested_at, + is_running, last_error, last_summary, created_at, updated_at + FROM sync_worker_state + WHERE user_id = ? + LIMIT 1 + `, + args: [userId], + }); + const row = result.rows[0]; + if (!row) return null; + return { + createdAt: Number(row.created_at), + id: String(row.id), + isRunning: Number(row.is_running), + lastError: row.last_error === null ? null : String(row.last_error), + lastFinishedAt: + row.last_finished_at === null + ? null + : Number(row.last_finished_at), + lastHeartbeatAt: + row.last_heartbeat_at === null + ? null + : Number(row.last_heartbeat_at), + lastStartedAt: + row.last_started_at === null ? null : Number(row.last_started_at), + lastSummary: + row.last_summary === null ? null : String(row.last_summary), + manualTriggerRequestedAt: + row.manual_trigger_requested_at === null + ? null + : Number(row.manual_trigger_requested_at), + nextRunAt: + row.next_run_at === null ? null : Number(row.next_run_at), + updatedAt: Number(row.updated_at), + userId: String(row.user_id), + }; + } finally { + await core.close(); + } +} + +async function restoreWorkerState(userId: string, snapshot: WorkerStateSnapshot) { + const core = createClient({ url: databaseUrl(CORE_DB) }); + try { + await core.execute({ + sql: "DELETE FROM sync_worker_state WHERE user_id = ?", + args: [userId], + }); + if (!snapshot) return; + await core.execute({ + sql: ` + INSERT INTO sync_worker_state ( + id, user_id, last_heartbeat_at, last_started_at, + last_finished_at, next_run_at, manual_trigger_requested_at, + is_running, last_error, last_summary, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + args: [ + snapshot.id, + snapshot.userId, + snapshot.lastHeartbeatAt, + snapshot.lastStartedAt, + snapshot.lastFinishedAt, + snapshot.nextRunAt, + snapshot.manualTriggerRequestedAt, + snapshot.isRunning, + snapshot.lastError, + snapshot.lastSummary, + snapshot.createdAt, + snapshot.updatedAt, + ], + }); + } finally { + await core.close(); + } +} + +async function seedWorkerState( + userId: string, + options: { + lastError?: string | null; + lastSummary?: { + errorCount: number; + newRecordings: number; + removedRecordings: number; + updatedRecordings: number; + } | null; + manualTriggerRequested?: boolean; + running?: boolean; + stale?: boolean; + }, +) { + const core = createClient({ url: databaseUrl(CORE_DB) }); + const now = Date.now(); + const heartbeatAt = options.stale ? now - 180_000 : now; + const running = options.running ?? false; + try { + await core.execute({ + sql: ` + INSERT INTO sync_worker_state ( + id, user_id, last_heartbeat_at, last_started_at, + last_finished_at, next_run_at, manual_trigger_requested_at, + is_running, last_error, last_summary, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + last_heartbeat_at = excluded.last_heartbeat_at, + last_started_at = excluded.last_started_at, + last_finished_at = excluded.last_finished_at, + next_run_at = excluded.next_run_at, + manual_trigger_requested_at = excluded.manual_trigger_requested_at, + is_running = excluded.is_running, + last_error = excluded.last_error, + last_summary = excluded.last_summary, + updated_at = excluded.updated_at + `, + args: [ + "e2e-activity-system-banner-worker", + userId, + heartbeatAt, + running ? now : now - 1_000, + running ? null : now, + now + 300_000, + options.manualTriggerRequested ? now : null, + running ? 1 : 0, + options.lastError ?? null, + options.lastSummary ? JSON.stringify(options.lastSummary) : null, + now, + now, + ], + }); + } finally { + await core.close(); + } +} + +async function seedActiveRecording(userId: string) { + const library = createClient({ url: databaseUrl(LIBRARY_DB) }); + const now = Date.now(); + try { + await library.execute({ + sql: "DELETE FROM transcription_jobs WHERE id = ? OR recording_id = ?", + args: [ACTIVITY_JOB_ID, ACTIVITY_RECORDING_ID], + }); + await library.execute({ + sql: "DELETE FROM recordings WHERE id = ?", + args: [ACTIVITY_RECORDING_ID], + }); + await library.execute({ + sql: ` + INSERT INTO recordings ( + id, user_id, source_provider, source_recording_id, + source_version, source_metadata, provider_device_id, + filename, duration, start_time, end_time, filesize, + file_md5, storage_type, storage_path, downloaded_at, + upstream_trashed, upstream_deleted, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + args: [ + ACTIVITY_RECORDING_ID, + userId, + "ticnote", + `${ACTIVITY_RECORDING_ID}-source`, + "1", + "{}", + "e2e-activity-device", + "E2E Activity progress", + 120_000, + now - 120_000, + now, + 2_048, + ACTIVITY_RECORDING_ID, + "local", + "", + null, + 0, + 0, + now, + now, + ], + }); + await library.execute({ + sql: ` + INSERT INTO transcription_jobs ( + id, user_id, recording_id, status, force, provider, + model, provider_job_id, remote_status, attempts, + last_error, requested_at, started_at, completed_at, + next_poll_at, created_at, updated_at + ) VALUES (?, ?, ?, 'processing', 0, 'voice-transcribe', + 'e2e', 'e2e-activity-remote-job', 'transcribing', 1, + NULL, ?, ?, NULL, ?, ?, ?) + `, + args: [ + ACTIVITY_JOB_ID, + userId, + ACTIVITY_RECORDING_ID, + now - 60_000, + now - 45_000, + now + 60_000, + now - 60_000, + now, + ], + }); + } finally { + await library.close(); + } +} + +async function clearActiveRecording() { + const library = createClient({ url: databaseUrl(LIBRARY_DB) }); + try { + await library.execute({ + sql: "DELETE FROM transcription_jobs WHERE id = ? OR recording_id = ?", + args: [ACTIVITY_JOB_ID, ACTIVITY_RECORDING_ID], + }); + await library.execute({ + sql: "DELETE FROM recordings WHERE id = ?", + args: [ACTIVITY_RECORDING_ID], + }); + } finally { + await library.close(); + } +} + +function systemBanner(page: Page, state: string) { + return page.locator(`[data-control="system-banner"][data-state="${state}"]`); +} + +function activityItem(page: Page, id: string) { + return page.locator( + `[data-item="dashboard-activity-item"][data-activity-id="${id}"]`, + ); +} + +async function openDashboard(page: Page) { + await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); + await expect( + page.locator('[data-surface="dashboard-workstation"]'), + ).toHaveAttribute("data-state", "ready"); +} + +async function openActivity(page: Page) { + await page.locator('[data-control="dashboard-activity"]').click(); + await expect(page.locator('[data-panel="dashboard-activity"]')).toBeVisible(); +} + +function isManualSyncPost(response: Response) { + return ( + new URL(response.url()).pathname === "/api/data-sources/sync" && + response.request().method() === "POST" + ); +} + +function waitForManualSyncPost(page: Page) { + return page.waitForResponse( + (response) => + isManualSyncPost(response) && response.request().postData() === null, + ); +} + +test("Activity and system banner render real SQLite worker states", async ({ + page, +}) => { + await ensureSignedIn(page); + const userId = await getPlaywrightUserId(); + const workerState = await snapshotWorkerState(userId); + + try { + await seedWorkerState(userId, { + lastError: "Worker runtime unavailable while starting source sync", + }); + await openDashboard(page); + await expect(systemBanner(page, "runtime-unavailable")).toBeVisible(); + + await seedWorkerState(userId, { lastError: "database is locked" }); + await openDashboard(page); + await expect(systemBanner(page, "db-locked")).toBeVisible(); + + await seedWorkerState(userId, { manualTriggerRequested: true }); + await openDashboard(page); + await openActivity(page); + await expect(activityItem(page, "source-sync-queued")).toBeVisible(); + await expect(activityItem(page, "source-sync-running")).toHaveCount(0); + + await seedWorkerState(userId, { running: true }); + await openDashboard(page); + await openActivity(page); + await expect(activityItem(page, "source-sync-running")).toBeVisible(); + + await seedWorkerState(userId, { + lastSummary: { + errorCount: 0, + newRecordings: 2, + removedRecordings: 1, + updatedRecordings: 3, + }, + }); + await openDashboard(page); + await openActivity(page); + await expect(activityItem(page, "source-sync-summary")).toContainText( + "新增 2,更新 3,移除 1。", + ); + + await page.context().setOffline(true); + await expect(systemBanner(page, "offline")).toBeVisible(); + await page.context().setOffline(false); + await expect(systemBanner(page, "offline")).toHaveCount(0); + } finally { + await page.context().setOffline(false); + await restoreWorkerState(userId, workerState); + } +}); + +test("Activity actions and banner actions use real dashboard manual-sync POSTs", async ({ + page, +}) => { + await ensureSignedIn(page); + const userId = await getPlaywrightUserId(); + const workerState = await snapshotWorkerState(userId); + const manualSyncResponses: Response[] = []; + const captureManualSyncPost = (response: Response) => { + if (isManualSyncPost(response)) { + manualSyncResponses.push(response); + } + }; + page.on("response", captureManualSyncPost); + + try { + await seedActiveRecording(userId); + await seedWorkerState(userId, { + lastError: "Worker runtime unavailable while starting source sync", + }); + await openDashboard(page); + await expect( + page.locator('[data-surface="dashboard-recording-player"]'), + ).toHaveAttribute("data-no-audio", "true"); + await expect(systemBanner(page, "runtime-unavailable")).toBeVisible(); + + const retrySyncResponse = waitForManualSyncPost(page); + await systemBanner(page, "runtime-unavailable") + .getByRole("button", { name: "重试同步", exact: true }) + .click(); + expect((await retrySyncResponse).status()).toBe(400); + + await openActivity(page); + const retryItem = activityItem(page, "source-sync-error"); + await expect(retryItem).toBeVisible(); + const immediateUpdateButton = page.locator( + '[data-control="dashboard-activity-sync"]', + ); + await expect(immediateUpdateButton).toBeVisible(); + await expect(immediateUpdateButton).toHaveText("更新"); + const immediateUpdateResponse = waitForManualSyncPost(page); + await immediateUpdateButton.click(); + expect((await immediateUpdateResponse).status()).toBe(400); + + const noAudioPlayer = page.locator( + '[data-surface="dashboard-recording-player"][data-no-audio="true"]', + ); + const retryButton = retryItem.getByRole("button", { + name: "重试", + exact: true, + }); + await expect(noAudioPlayer).toBeVisible(); + await expect(retryButton).toBeVisible(); + const [playerBox, retryButtonBox] = await Promise.all([ + noAudioPlayer.boundingBox(), + retryButton.boundingBox(), + ]); + if (!playerBox || !retryButtonBox) { + throw new Error( + `Expected visible Activity retry and no-audio player boxes; player=${JSON.stringify(playerBox)}, retry=${JSON.stringify(retryButtonBox)}`, + ); + } + const intersectionLeft = Math.max(playerBox.x, retryButtonBox.x); + const intersectionTop = Math.max(playerBox.y, retryButtonBox.y); + const intersectionRight = Math.min( + playerBox.x + playerBox.width, + retryButtonBox.x + retryButtonBox.width, + ); + const intersectionBottom = Math.min( + playerBox.y + playerBox.height, + retryButtonBox.y + retryButtonBox.height, + ); + if ( + intersectionRight <= intersectionLeft || + intersectionBottom <= intersectionTop + ) { + throw new Error( + `Expected Activity retry to overlap the no-audio player in both axes; player=${JSON.stringify(playerBox)}, retry=${JSON.stringify(retryButtonBox)}`, + ); + } + const retryResponse = waitForManualSyncPost(page); + await retryButton.click({ + position: { + x: (intersectionLeft + intersectionRight) / 2 - retryButtonBox.x, + y: (intersectionTop + intersectionBottom) / 2 - retryButtonBox.y, + }, + }); + expect((await retryResponse).status()).toBe(400); + + await seedWorkerState(userId, { lastError: "database is locked" }); + await openDashboard(page); + const reconnectResponse = waitForManualSyncPost(page); + await systemBanner(page, "db-locked") + .getByRole("button", { name: "重新连接", exact: true }) + .click(); + expect((await reconnectResponse).status()).toBe(400); + expect(manualSyncResponses).toHaveLength(4); + for (const response of manualSyncResponses) { + expect(response.request().postData()).toBeNull(); + expect(response.status()).toBe(400); + } + + await openActivity(page); + const progressItem = activityItem( + page, + `transcription-active-${ACTIVITY_RECORDING_ID}`, + ); + await expect(progressItem).toBeVisible(); + await progressItem + .getByRole("button", { name: "查看", exact: true }) + .click(); + await expect(page.locator('[data-panel="dashboard-activity"]')).toHaveCount(0); + await expect( + page.getByRole("heading", { name: "E2E Activity progress" }), + ).toBeVisible(); + + await seedWorkerState(userId, { stale: true }); + await openDashboard(page); + await openActivity(page); + const settingsItem = activityItem(page, "worker-unavailable"); + await expect(settingsItem).toBeVisible(); + await settingsItem + .getByRole("button", { name: "前往数据源设置", exact: true }) + .click(); + await expect(page.getByRole("dialog", { name: "设置" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "数据源", exact: true }), + ).toHaveAttribute("aria-current", "page"); + + await seedWorkerState(userId, { + lastError: "Worker runtime unavailable while starting source sync", + }); + await openDashboard(page); + await page.context().setOffline(true); + await expect(systemBanner(page, "runtime-unavailable")).toBeVisible(); + await expect(systemBanner(page, "offline")).toBeVisible(); + await systemBanner(page, "runtime-unavailable") + .getByRole("button", { name: "收起", exact: true }) + .click(); + await expect(systemBanner(page, "runtime-unavailable")).toHaveCount(0); + await expect(systemBanner(page, "offline")).toBeVisible(); + } finally { + page.off("response", captureManualSyncPost); + await page.context().setOffline(false); + await clearActiveRecording(); + await restoreWorkerState(userId, workerState); + } +}); diff --git a/e2e/dashboard-list-states.spec.ts b/e2e/dashboard-list-states.spec.ts deleted file mode 100644 index 05ee1afb..00000000 --- a/e2e/dashboard-list-states.spec.ts +++ /dev/null @@ -1,4999 +0,0 @@ -import path from "node:path"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { pathToFileURL } from "node:url"; -import { createClient } from "@libsql/client"; -import { - type BrowserContext, - expect, - type Locator, - type Page, - test, - type TestInfo, -} from "@playwright/test"; -import { ensureSignedIn, putJsonWithRetry } from "./helpers/auth"; -import { - SOT_COMPONENT_LIBRARY_URL, - SOT_FIXTURE_WEB_ROOT, - SOT_SOURCE_ASSET_DIR, - SOT_WORKSTATION_URL, -} from "./helpers/sot-fixtures"; - -const E2E_DATA_DIR = path.resolve(process.cwd(), "tmp/e2e/data"); -const LIST_RECORDING_PREFIX = "e2e-list-state-"; - -function resolveDatabasePath() { - return process.env.DATABASE_PATH - ? path.resolve(process.cwd(), process.env.DATABASE_PATH) - : path.join(E2E_DATA_DIR, "betterainote-e2e.db"); -} - -function deriveSiblingDatabasePath(databasePath: string, suffix: string) { - const parsed = path.parse(databasePath); - return path.resolve( - parsed.dir || ".", - `${parsed.name || "betterainote"}-${suffix}${parsed.ext || ".db"}`, - ); -} - -function databaseUrl(filePath: string) { - assertE2EDatabasePath(filePath); - return pathToFileURL(filePath).href; -} - -const CORE_DB = resolveDatabasePath(); -const LIBRARY_DB = deriveSiblingDatabasePath(CORE_DB, "library"); -const TRANSCRIPTS_DB = deriveSiblingDatabasePath(CORE_DB, "transcripts"); -const LIST_FRAME_DEBUG_DIR = path.resolve( - process.cwd(), - "tmp/debug-list-frame", -); -const SOT_COLORS_AND_TYPE_CSS_PATH = path.resolve( - SOT_FIXTURE_WEB_ROOT, - "..", - "..", - "colors_and_type.css", -); -const SOT_KIT_CSS_PATH = path.join(SOT_FIXTURE_WEB_ROOT, "kit.css"); -let sotWorkstationCssCache: string | null = null; -const SOT_PIXEL_DEV_OVERLAY_HIDDEN_CSS = ` - nextjs-portal, - [data-nextjs-toast], - [data-nextjs-dialog-overlay], - [data-nextjs-dialog-backdrop], - [data-nextjs-dialog], - [data-nextjs-errors], - [data-nextjs-dev-tools-button], - button[aria-label="Open Next.js Dev Tools"], - .__nextjs-dev-overlay { - display: none !important; - visibility: hidden !important; - opacity: 0 !important; - pointer-events: none !important; - } -`; -const LIST_ROW_MIGRATION_FIXTURE_CSS = ` - .real-list { - display: flex; - flex-direction: column; - gap: 2px; - padding: 4px; - } - .real-list .day { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 6px 4px; - } - .real-list .day .d { - font: 700 11px var(--font-sans); - color: var(--fg-tertiary); - letter-spacing: 0.04em; - } - .real-list .day .c { - font: 500 11px var(--font-mono); - color: var(--fg-disabled); - } - .real-list .day .line { - flex: 1; - height: 1px; - background: var(--line-hairline); - margin-left: 4px; - } - .real-list .row { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - gap: 14px; - padding: 11px 12px; - border-radius: 10px; - cursor: pointer; - border: 1px solid transparent; - background: transparent; - width: 100%; - text-align: left; - font: 13.3333px var(--font-sans); - transition: - background var(--duration-fast) var(--ease-out), - border-color var(--duration-fast) var(--ease-out); - } - .real-list .row:hover { - background: var(--bg-recessed); - } - .real-list .row.active { - background: var(--accent-soft); - border-color: color-mix(in srgb, var(--accent) 38%, transparent); - } - .real-list .body { - min-width: 0; - display: flex; - flex-direction: column; - gap: 5px; - } - .real-list .title { - font: 600 13.5px var(--font-sans); - color: var(--fg-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - letter-spacing: -0.005em; - } - .real-list .meta { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - } - .real-list .meta .dur { - font: 500 11.5px var(--font-mono); - color: var(--fg-secondary); - letter-spacing: 0.02em; - } - .real-list .meta2 { - display: flex; - align-items: center; - gap: 8px; - font: 500 11px var(--font-mono); - color: var(--fg-tertiary); - } - .real-list .meta2 .ts { - letter-spacing: 0.015em; - } - .real-list .ts-abs { - display: none; - } - .real-list .ts-rel { - display: inline; - } - body[data-time-style="abs"] .real-list .ts-abs { - display: inline; - } - body[data-time-style="abs"] .real-list .ts-rel { - display: none; - } - .real-list .right, - .real-list [data-sot-part="dashboard-recording-row-actions"] { - display: flex; - align-items: center; - gap: 8px; - } - .b, - [data-sot-part="dashboard-recording-status"] { - display: inline-flex; - align-items: center; - gap: 5px; - height: 20px; - padding: 0 8px; - border-radius: 999px; - font: 600 11px var(--font-sans); - border: 1px solid transparent; - letter-spacing: 0.005em; - } - .b .dot, - [data-sot-part="dashboard-recording-status-dot"] { - display: inline-block; - width: 5px; - height: 5px; - border-radius: 50%; - background: currentColor; - margin-right: 4px; - } - .b .dot.status-dot-muted, - [data-sot-part="dashboard-recording-status"][data-sot-tone="neu"] [data-sot-part="dashboard-recording-status-dot"], - [data-sot-part="dashboard-recording-status-dot"][data-sot-tone="neu"] { - background: var(--fg-tertiary); - } - .b.ok, - [data-sot-part="dashboard-recording-status"][data-sot-tone="ok"] { - background: color-mix(in srgb, var(--signal-success) 10%, transparent); - color: var(--signal-success); - border-color: color-mix(in srgb, var(--signal-success) 36%, transparent); - } - .b.warn, - [data-sot-part="dashboard-recording-status"][data-sot-tone="warn"] { - background: color-mix(in srgb, var(--signal-warning) 16%, transparent); - color: var(--signal-warning-strong); - border-color: color-mix(in srgb, var(--signal-warning) 28%, transparent); - } - .b.err, - [data-sot-part="dashboard-recording-status"][data-sot-tone="err"] { - background: color-mix(in srgb, var(--signal-danger) 10%, transparent); - color: var(--signal-danger); - border-color: color-mix(in srgb, var(--signal-danger) 26%, transparent); - } - .b.info, - [data-sot-part="dashboard-recording-status"][data-sot-tone="info"] { - background: color-mix(in srgb, var(--signal-info) 14%, transparent); - color: var(--signal-info); - border-color: color-mix(in srgb, var(--signal-info) 22%, transparent); - } - .b.neu, - [data-sot-part="dashboard-recording-status"][data-sot-tone="neu"] { - background: var(--bg-recessed); - color: var(--fg-secondary); - border-color: var(--line-hairline); - } - .b.warn .dot, - [data-sot-part="dashboard-recording-status"][data-sot-tone="warn"] [data-sot-part="dashboard-recording-status-dot"] { - animation: bpulse 1.4s ease-in-out infinite; - } - [data-theme="dark"] .b.warn, - [data-theme="dark"] [data-sot-part="dashboard-recording-status"][data-sot-tone="warn"] { - color: oklch(0.78 0.14 80); - } - .src-mini { - width: 14px; - height: 14px; - border-radius: 3px; - flex: 0 0 14px; - display: inline-flex; - align-items: center; - justify-content: center; - overflow: hidden; - opacity: 0.55; - } - .src-mini img { - width: 14px !important; - height: 14px !important; - object-fit: contain; - display: block; - filter: grayscale(1) contrast(0.85); - } - .src-mini.cover img { - object-fit: cover; - } - .src-mini.src-mini-letter { - font: 700 9px var(--font-sans); - color: var(--fg-tertiary); - background: var(--bg-recessed); - border: 1px solid var(--line-hairline); - } - [data-theme="dark"] .src-mini { - opacity: 0.6; - } - [data-theme="dark"] .src-mini img { - filter: grayscale(1) brightness(1.4) contrast(0.85); - } - [data-theme="dark"] .src-mini.src-mini-letter { - background: rgb(255 255 255 / 0.06); - border-color: var(--glass-border); - } - .utag, - [data-recording-tag-chip] { - --tag-c: var(--graphite-500); - display: inline-flex; - align-items: center; - gap: 5px; - height: 22px; - padding: 0 9px 0 7px; - border-radius: 6px; - background: color-mix(in srgb, var(--tag-c) 12%, var(--bg-elevated)); - border: 1px solid color-mix(in srgb, var(--tag-c) 32%, transparent); - color: color-mix(in srgb, var(--tag-c) 72%, var(--fg-primary)); - font: 600 11.5px var(--font-sans); - box-shadow: var(--shadow-xs); - } - .utag svg, - [data-recording-tag-chip] svg { - width: 11px; - height: 11px; - flex: none; - stroke: currentColor; - stroke-width: 2; - fill: none; - stroke-linecap: round; - stroke-linejoin: round; - } - .utag.c-blue, - [data-recording-tag-chip][data-sot-tag-color="blue"] { - --tag-c: oklch(0.580 0.130 235); - } - .utag.c-violet, - [data-recording-tag-chip][data-sot-tag-color="purple"] { - --tag-c: oklch(0.560 0.150 285); - } - .utag.c-rose, - [data-recording-tag-chip][data-sot-tag-color="red"] { - --tag-c: oklch(0.595 0.165 18); - } - .utag.c-amber, - [data-recording-tag-chip][data-sot-tag-color="orange"] { - --tag-c: oklch(0.620 0.140 70); - } - .utag.c-green, - [data-recording-tag-chip][data-sot-tag-color="green"] { - --tag-c: oklch(0.560 0.130 158); - } - .utag.c-slate, - [data-recording-tag-chip][data-sot-tag-color="slate"] { - --tag-c: oklch(0.580 0.020 250); - } - [data-theme="dark"] .utag, - [data-theme="dark"] [data-recording-tag-chip] { - background: color-mix(in srgb, var(--tag-c) 18%, transparent); - color: color-mix(in srgb, var(--tag-c) 30%, var(--fg-primary)); - border-color: color-mix(in srgb, var(--tag-c) 36%, transparent); - } - .utag-plus { - display: inline-flex; - align-items: center; - gap: 4px; - height: 22px; - padding: 0 8px; - border-radius: 6px; - background: var(--bg-recessed); - border: 1px dashed var(--line-hairline); - font: 600 11px var(--font-sans); - color: var(--fg-tertiary); - } - .list-row-pixel-stage, - .list-row-pixel-stage *, - .list-panel-frame-stage, - .list-panel-frame-stage * { - box-sizing: border-box !important; - -webkit-font-smoothing: antialiased !important; - -moz-osx-font-smoothing: grayscale !important; - text-rendering: optimizeLegibility !important; - font-feature-settings: "ss01", "cv11", "rlig", "calt" !important; - } - .list-row-pixel-stage .real-list .row, - .list-panel-frame-stage .real-list .row { - display: grid !important; - grid-template-columns: minmax(0, 1fr) auto !important; - align-items: center !important; - gap: 14px !important; - padding: 11px 12px !important; - border: 1px solid transparent !important; - background: transparent !important; - width: 100% !important; - text-align: left !important; - font: 13.3333px var(--font-sans) !important; - } - .list-row-pixel-stage .real-list .body, - .list-panel-frame-stage .real-list .body { - min-width: 0 !important; - display: flex !important; - flex-direction: column !important; - gap: 5px !important; - } - .list-row-pixel-stage .real-list .title, - .list-panel-frame-stage .real-list .title { - font: 600 13.5px var(--font-sans) !important; - color: var(--fg-primary) !important; - white-space: nowrap !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - } - .list-row-pixel-stage .real-list .right, - .list-row-pixel-stage .real-list [data-sot-part="dashboard-recording-row-actions"], - .list-panel-frame-stage .real-list .right, - .list-panel-frame-stage .real-list [data-sot-part="dashboard-recording-row-actions"] { - display: flex !important; - align-items: center !important; - justify-content: flex-end !important; - justify-self: end !important; - gap: 8px !important; - flex: none !important; - min-width: max-content !important; - width: max-content !important; - } - .list-row-pixel-stage .src-mini, - .list-panel-frame-stage .src-mini { - display: inline-flex !important; - flex: 0 0 14px !important; - width: 14px !important; - height: 14px !important; - min-width: 14px !important; - max-width: 14px !important; - border-radius: 3px !important; - align-items: center !important; - justify-content: center !important; - overflow: hidden !important; - } - .list-row-pixel-stage .src-mini img, - .list-panel-frame-stage .src-mini img { - display: block !important; - width: 14px !important; - height: 14px !important; - min-width: 14px !important; - max-width: none !important; - object-fit: contain !important; - vertical-align: baseline !important; - } - .list-panel-frame-stage [data-sot-part="source-filter-icon"] img { - display: block !important; - width: 14px !important; - height: 14px !important; - max-width: none !important; - object-fit: contain !important; - } - .list-row-pixel-stage .b, - .list-row-pixel-stage .utag, - .list-row-pixel-stage .utag-plus, - .list-row-pixel-stage [data-sot-part="dashboard-recording-status"], - .list-row-pixel-stage [data-recording-tag-chip], - .list-panel-frame-stage .b, - .list-panel-frame-stage .utag, - .list-panel-frame-stage .utag-plus, - .list-panel-frame-stage [data-sot-part="dashboard-recording-status"], - .list-panel-frame-stage [data-recording-tag-chip] { - flex: none !important; - box-sizing: border-box !important; - } -`; -const LIST_COMPONENT_LIBRARY_SHADCN_TAG_FIXTURE_CSS = ` - #badge .utag { - box-sizing: border-box; - display: inline-flex; - align-items: center; - justify-content: flex-start; - gap: 5.625px; - height: auto; - padding: 1.875px 7.5px; - border-radius: calc(infinity * 1px); - background: oklch(0.165 0.004 250); - border: 1px solid transparent; - color: oklch(0.555 0.09 224); - font-family: var(--font-sans); - font-size: 11.25px; - font-weight: 500; - line-height: 15px; - box-shadow: 0 1px 1px rgb(0 0 0 / 0.3); - } - #badge .utag svg { - width: 11.25px; - height: 11.25px; - } -`; -const LIST_SKELETON_MIGRATION_FIXTURE_CSS = ` - @keyframes list-sot-skshimmer { - 0% { background-position: 200% 50%; } - 100% { background-position: -100% 50%; } - } - .list-skeleton-pixel-stage .sk { - display: inline-block; - vertical-align: middle; - background: linear-gradient( - 90deg, - rgb(255 255 255 / 0.05) 0%, - rgb(255 255 255 / 0.12) 50%, - rgb(255 255 255 / 0.05) 100% - ); - background-size: 220% 100%; - animation: list-sot-skshimmer 1.6s ease-in-out infinite; - border-radius: 6px; - height: 12px; - } - .list-skeleton-pixel-stage .sk-w-100 { width: 100%; } - .list-skeleton-pixel-stage .sk-w-90 { width: 90%; } - .list-skeleton-pixel-stage .sk-w-85 { width: 85%; } - .list-skeleton-pixel-stage .sk-w-80 { width: 80%; } - .list-skeleton-pixel-stage .sk-w-70 { width: 70%; } - .list-skeleton-pixel-stage .sk-w-60 { width: 60%; } - .list-skeleton-pixel-stage .sk-w-40 { width: 40%; } - .list-skeleton-pixel-stage .skel-list { - padding: 4px; - display: flex; - flex-direction: column; - gap: 2px; - } - .list-skeleton-pixel-stage .skel-list .skel-day { - padding: 14px 10px 6px; - display: flex; - align-items: center; - gap: 10px; - } - .list-skeleton-pixel-stage .skel-list .sk-day-l { - width: 100px; - height: 11px; - } - .list-skeleton-pixel-stage .skel-list .skel-day .line { - flex: 1; - height: 1px; - background: var(--line-hairline); - } - .list-skeleton-pixel-stage .skel-list .skel-row { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - gap: 14px; - padding: 11px 12px; - } - .list-skeleton-pixel-stage .skel-list .skel-row .body { - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; - } - .list-skeleton-pixel-stage .skel-list .skel-row .meta { - display: flex; - align-items: center; - gap: 8px; - } - .list-skeleton-pixel-stage .skel-list .sk-title { - width: 100%; - height: 13px; - } - .list-skeleton-pixel-stage .skel-list .sk-meta-t { - width: 80px; - height: 11px; - } - .list-skeleton-pixel-stage .skel-list .sk-meta-tag { - width: 64px; - height: 18px; - border-radius: 6px; - } - .list-skeleton-pixel-stage .skel-list .sk-meta-pill { - width: 64px; - height: 18px; - border-radius: 999px; - } - .list-skeleton-pixel-stage .skel-list .sk-utag { - width: 80px; - height: 22px; - border-radius: 6px; - } -`; -const LIST_STATE_BLOCK_MIGRATION_FIXTURE_CSS = ` - .list-state-block-pixel-stage, - .list-state-block-pixel-stage * { - box-sizing: border-box !important; - -webkit-font-smoothing: antialiased !important; - -moz-osx-font-smoothing: grayscale !important; - text-rendering: optimizeLegibility !important; - font-feature-settings: "ss01", "cv11", "rlig", "calt" !important; - } - .list-state-block-pixel-stage .list-state-block { - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - padding: 26px 18px; - text-align: center; - margin: 8px; - background: var(--bg-recessed); - border: 1px dashed var(--line-hairline); - border-radius: 10px; - } - .list-state-block-pixel-stage .list-state-block .lsb-ico, - .list-state-block-pixel-stage [data-sot-part="recording-list-state-icon"] { - display: inline-flex; - width: 34px; - height: 34px; - border-radius: 50%; - background: var(--bg-elevated); - border: 1px solid var(--line-hairline); - color: var(--fg-tertiary); - align-items: center; - justify-content: center; - margin-bottom: 2px; - } - .list-state-block-pixel-stage .list-state-block .lsb-ico svg, - .list-state-block-pixel-stage [data-sot-part="recording-list-state-icon"] svg { - width: 15px; - height: 15px; - stroke: currentColor; - fill: none; - stroke-width: 1.8; - stroke-linecap: round; - stroke-linejoin: round; - } - .list-state-block-pixel-stage .list-state-block .lsb-t, - .list-state-block-pixel-stage [data-sot-part="recording-list-state-title"] { - font: 600 13px var(--font-sans); - color: var(--fg-primary); - } - .list-state-block-pixel-stage .list-state-block .lsb-h, - .list-state-block-pixel-stage [data-sot-part="recording-list-state-description"] { - font: 500 12px / 1.5 var(--font-sans); - color: var(--fg-tertiary); - max-width: 300px; - } - .list-state-block-pixel-stage .list-state-block.list-state-pagination { - display: flex; - background: transparent; - border: 0; - padding: 14px; - align-items: stretch; - } - .list-state-block-pixel-stage .list-state-block.list-state-pagination .lsb-page-divider, - .list-state-block-pixel-stage [data-sot-part="recording-list-page-divider"] { - position: relative !important; - height: 1px; - background: var(--line-hairline); - margin: 6px 0 14px; - } - .list-state-block-pixel-stage .list-state-block.list-state-pagination .lsb-page-divider span, - .list-state-block-pixel-stage [data-sot-part="recording-list-page-status"] { - position: absolute !important; - top: -8px !important; - left: 50% !important; - background: var(--bg-elevated); - padding: 0 10px; - translate: none !important; - transform: translate(-50%, -50%) !important; - width: max-content; - font: 500 10.5px var(--font-mono); - color: var(--fg-tertiary); - } - .list-state-block-pixel-stage .list-state-block.list-state-pagination .lsb-page-nav, - .list-state-block-pixel-stage [data-sot-part="recording-list-page-nav"] { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - margin-top: 4px; - } - .list-state-block-pixel-stage .list-state-block.list-state-pagination .lsb-page-num, - .list-state-block-pixel-stage [data-sot-part="recording-list-page-number"] { - font: 500 11.5px var(--font-mono); - color: var(--fg-tertiary); - min-width: 56px; - text-align: center; - } - .list-state-block .btn { - display: inline-flex; - align-items: center; - gap: 7px; - height: 32px; - padding: 0 12px; - border-radius: 9px; - font: 600 12.5px var(--font-sans); - color: var(--fg-primary); - background: var(--bg-elevated); - border: 1px solid var(--line-hairline); - cursor: pointer; - box-shadow: var(--shadow-xs); - transition: - background var(--duration-fast) var(--ease-out), - transform var(--duration-fast) var(--ease-out); - } - .list-state-block .btn.ghost { - background: transparent; - border-color: transparent; - box-shadow: none; - color: var(--fg-secondary); - } - .list-state-block .btn.primary { - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--accent) 92%, white 18%), - var(--accent) - ); - border-color: color-mix(in srgb, var(--accent) 60%, black 8%); - color: white; - box-shadow: - 0 2px 6px color-mix(in srgb, var(--accent) 24%, transparent), - inset 0 1px 0 rgb(255 255 255 / 0.22); - } - .list-state-block .btn.btn-sm { - height: 26px; - padding: 0 10px; - font-size: 12px; - border-radius: 7px; - } - .list-state-block .btn[disabled], - .list-state-block .btn[aria-disabled="true"] { - opacity: 0.5; - cursor: not-allowed; - pointer-events: none; - } -`; -const DASHBOARD_LIST_STATE_OWNER_CLASS_CONTRACT = { - listStateAction: - "h-8 gap-1.5 rounded-md bg-transparent px-3 text-[var(--fg-secondary)] shadow-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 has-[>svg]:px-2.5", - listStateDescription: - "max-w-[300px] font-sans text-[12px] font-medium leading-[1.5] text-[var(--fg-tertiary)]", - listStateIcon: - "mb-0.5 inline-flex size-[34px] items-center justify-center rounded-full border border-[var(--line-hairline)] bg-[var(--bg-elevated)] text-[var(--fg-tertiary)] [&_svg]:size-[15px] [&_svg]:fill-none [&_svg]:stroke-current [&_svg]:stroke-[1.8] [&_svg]:[stroke-linecap:round] [&_svg]:[stroke-linejoin:round]", - listStatePrimary: - "h-8 gap-1.5 rounded-md bg-primary px-3 text-primary-foreground shadow-xs hover:bg-primary/90 has-[>svg]:px-2.5", - listStateRoot: - "m-2 flex flex-col items-center gap-1.5 rounded-[10px] border border-dashed border-[var(--line-hairline)] bg-[var(--bg-recessed)] px-[18px] py-[26px] text-center", - listStateTitle: - "font-sans text-[13px] font-semibold text-[var(--fg-primary)]", - paginationButton: - "h-8 gap-1.5 rounded-md bg-transparent px-3 text-[var(--fg-secondary)] shadow-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 has-[>svg]:px-2.5", - paginationDivider: - "relative mt-1.5 mb-[14px] h-px bg-[var(--line-hairline)]", - paginationNav: "mt-1 flex items-center justify-center gap-2.5", - paginationNumber: - "min-w-14 text-center font-mono text-[11.5px] font-medium text-[var(--fg-tertiary)]", - paginationRoot: - "m-2 flex flex-col items-stretch gap-1.5 border-0 bg-transparent p-[14px] text-center", - paginationStatus: - "absolute left-1/2 -top-2 -translate-x-1/2 -translate-y-1/2 bg-[var(--bg-elevated)] px-2.5 font-mono text-[10.5px] font-medium text-[var(--fg-tertiary)]", -} as const; -const SOT_PIXEL_DEV_OVERLAY_STYLE_ATTR = "data-sot-pixel-dev-overlay-fixture"; -const LIST_ROW_SOT_STATES = [ - "active-updated", - "transcribing", - "updated", - "local-only", - "failed", - "pending", -] as const; -const LIST_ROW_SELECTORS: Record = { - "active-updated": '.real-list .row[data-rec="rec-product-weekly"]', - failed: '.real-list .row[data-rec="rec-eng-handover"]', - "local-only": '.real-list .row[data-rec="rec-cs-training"]', - pending: '.real-list .row[data-rec="rec-market-0410"]', - transcribing: '.real-list .row[data-rec="rec-wenli-1on1"]', - updated: '.real-list .row[data-rec="rec-investor-0418"]', -}; -const LIST_ROW_SOURCE_ASSETS: Record = { - "../../assets/sources/dingtalk.svg": "dingtalk.svg", - "../../assets/sources/feishu.jpeg": "feishu.jpeg", - "../../assets/sources/plaud.png": "plaud.png", - "../../assets/sources/ticnote.png": "ticnote.png", -}; -const LIST_PANEL_FRAME_REQUIRED_RECORDING_IDS = [ - "rec-product-weekly", - "rec-wenli-1on1", - "rec-investor-0418", - "rec-cs-training", - "rec-eng-handover", - "rec-market-0410", -] as const; -const LIST_PANEL_FRAME_PRODUCT_ROW_CONTENT_OFFSET = { - x: 4, - y: 4, -} as const; -const LIST_PANEL_FRAME_DESKTOP_DIFF_BUDGET = { - differingPixels: 40, - maxChannelDelta: 20, -} as const; -const LIST_SKELETON_PIXEL_TOLERANCE = { - differingPixels: 2, - maxChannelDelta: 1, -} as const; -const TAG_FILTER_TRIGGER_SOT_STATES = ["all", "single", "untagged"] as const; -const TAG_FILTER_TRIGGER_LABELS: Record = { - all: "Trigger · all", - single: "Trigger · single tag", - untagged: "Trigger · untagged", -}; -const LIST_STATE_BLOCK_SOT_STATES = [ - "empty", - "no-match", - "timeline-empty", - "tag-empty", - "paginated", - "paginated-first", - "paginated-last", -] as const; -const SOT_TAG_COLOR_MATRIX = [ - { color: "purple", className: "utag c-violet" }, - { color: "blue", className: "utag c-blue" }, - { color: "red", className: "utag c-rose" }, - { color: "orange", className: "utag c-amber" }, - { color: "green", className: "utag c-green" }, -] as const; -const SOT_TAG_ICON_MATRIX = [ - "grid", - "user", - "heart", - "clock", - "tag", - "star", - "dialog", - "flag", - "book", - "bulb", - "file", - "mic", -] as const; -const LIST_ROW_STYLE_PROPS = [ - "display", - "align-items", - "gap", - "padding-top", - "padding-right", - "padding-bottom", - "padding-left", - "border-top-width", - "border-top-style", - "border-top-color", - "border-radius", - "background-color", - "color", - "box-shadow", - "outline-width", - "outline-style", - "outline-color", - "outline-offset", -] as const; -const LIST_ROW_SHADCN_FOCUS_CLASS_CONTRACT = [ - "focus:!border-ring", - "focus:!outline-none", - "focus:!ring-[3px]", - "focus:!ring-ring/50", - "focus-visible:!border-ring", - "focus-visible:!outline-none", - "focus-visible:!ring-[3px]", - "focus-visible:!ring-ring/50", -] as const; -const LIST_BADGE_STYLE_PROPS = [ - "display", - "align-items", - "gap", - "height", - "padding-top", - "padding-right", - "padding-bottom", - "padding-left", - "border-radius", - "background-color", - "color", - "font-family", - "font-size", - "font-weight", - "line-height", - "border-top-width", - "border-top-style", - "border-top-color", -] as const; -const LIST_TAG_STYLE_PROPS = [ - "display", - "align-items", - "gap", - "height", - "padding-top", - "padding-right", - "padding-bottom", - "padding-left", - "border-radius", - "background-color", - "color", - "font-family", - "font-size", - "font-weight", - "box-shadow", - "border-top-width", - "border-top-style", - "border-top-color", -] as const; - -type ListStyleProp = - | (typeof LIST_ROW_STYLE_PROPS)[number] - | (typeof LIST_BADGE_STYLE_PROPS)[number] - | (typeof LIST_TAG_STYLE_PROPS)[number]; -type ListRowSotState = (typeof LIST_ROW_SOT_STATES)[number]; -type TagFilterTriggerSotState = (typeof TAG_FILTER_TRIGGER_SOT_STATES)[number]; -type ListStateBlockSotState = (typeof LIST_STATE_BLOCK_SOT_STATES)[number]; -type SotTagIcon = (typeof SOT_TAG_ICON_MATRIX)[number]; -type SvgChildSignature = Array<{ - attributes: Array; - tagName: string; -}>; -type ListRowPixelDiff = { - bounds: { - maxX: number; - maxY: number; - minX: number; - minY: number; - } | null; - differingPixels: number; - dimensionsMatch: boolean; - expectedHeight: number; - expectedWidth: number; - maxChannelDelta: number; - productHeight: number; - productWidth: number; -}; -type ListPanelFrameCapture = Awaited< - ReturnType ->; - -function assertE2EDatabasePath(filePath: string) { - const e2eRoot = path.resolve( - process.env.PLAYWRIGHT_E2E_ROOT ?? - path.join(process.cwd(), "tmp/e2e"), - ); - const resolvedPath = path.resolve(filePath); - - if ( - resolvedPath !== e2eRoot && - !resolvedPath.startsWith(`${e2eRoot}${path.sep}`) - ) { - throw new Error( - `Refusing to touch non-E2E database path: ${resolvedPath}`, - ); - } -} - -async function getPlaywrightUserId() { - const client = createClient({ url: databaseUrl(CORE_DB) }); - try { - const result = await client.execute({ - sql: "SELECT id FROM `users` WHERE email = ? LIMIT 1", - args: ["playwright-admin@example.com"], - }); - const id = result.rows[0]?.id; - if (typeof id !== "string") { - throw new Error("Playwright user not found"); - } - return id; - } finally { - await client.close(); - } -} - -async function cleanupListSeeds(userId: string) { - const library = createClient({ url: databaseUrl(LIBRARY_DB) }); - const transcripts = createClient({ url: databaseUrl(TRANSCRIPTS_DB) }); - - try { - await transcripts.execute({ - sql: "DELETE FROM source_artifact_segments WHERE user_id = ? AND recording_id LIKE ?", - args: [userId, `${LIST_RECORDING_PREFIX}%`], - }); - await transcripts.execute({ - sql: "DELETE FROM transcript_segments WHERE user_id = ? AND recording_id LIKE ?", - args: [userId, `${LIST_RECORDING_PREFIX}%`], - }); - await transcripts.execute({ - sql: "DELETE FROM source_artifacts WHERE user_id = ? AND recording_id LIKE ?", - args: [userId, `${LIST_RECORDING_PREFIX}%`], - }); - await transcripts.execute({ - sql: "DELETE FROM transcriptions WHERE user_id = ? AND recording_id LIKE ?", - args: [userId, `${LIST_RECORDING_PREFIX}%`], - }); - await library.execute({ - sql: "DELETE FROM transcription_jobs WHERE user_id = ? AND recording_id LIKE ?", - args: [userId, `${LIST_RECORDING_PREFIX}%`], - }); - await library.execute({ - sql: "DELETE FROM recording_tag_assignments WHERE user_id = ? AND recording_id LIKE ?", - args: [userId, `${LIST_RECORDING_PREFIX}%`], - }); - await library.execute({ - sql: "DELETE FROM recording_tags WHERE user_id = ? AND id LIKE ?", - args: [userId, `${LIST_RECORDING_PREFIX}tag-%`], - }); - await library.execute({ - sql: "DELETE FROM recordings WHERE user_id = ? AND id LIKE ?", - args: [userId, `${LIST_RECORDING_PREFIX}%`], - }); - } finally { - await library.close(); - await transcripts.close(); - } -} - -async function cleanupAllUserRecordings(userId: string) { - const library = createClient({ url: databaseUrl(LIBRARY_DB) }); - const transcripts = createClient({ url: databaseUrl(TRANSCRIPTS_DB) }); - - try { - await transcripts.execute({ - sql: "DELETE FROM source_artifact_segments WHERE user_id = ?", - args: [userId], - }); - await transcripts.execute({ - sql: "DELETE FROM transcript_segments WHERE user_id = ?", - args: [userId], - }); - await transcripts.execute({ - sql: "DELETE FROM source_artifacts WHERE user_id = ?", - args: [userId], - }); - await transcripts.execute({ - sql: "DELETE FROM transcriptions WHERE user_id = ?", - args: [userId], - }); - await library.execute({ - sql: "DELETE FROM transcription_jobs WHERE user_id = ?", - args: [userId], - }); - await library.execute({ - sql: "DELETE FROM recording_tag_assignments WHERE user_id = ?", - args: [userId], - }); - await library.execute({ - sql: "DELETE FROM recording_tags WHERE user_id = ? AND id LIKE ?", - args: [userId, `${LIST_RECORDING_PREFIX}tag-%`], - }); - await library.execute({ - sql: "DELETE FROM recordings WHERE user_id = ?", - args: [userId], - }); - await library.execute({ - sql: "DELETE FROM source_devices WHERE user_id = ?", - args: [userId], - }); - } finally { - await library.close(); - await transcripts.close(); - } -} - -async function seedListRecordings(userId: string, count = 10) { - const library = createClient({ url: databaseUrl(LIBRARY_DB) }); - const now = Date.now(); - - try { - await cleanupListSeeds(userId); - for (let index = 0; index < count; index += 1) { - const suffix = String(index + 1).padStart(2, "0"); - const recordingId = `${LIST_RECORDING_PREFIX}${suffix}`; - const start = now - index * 90_000; - - await library.execute({ - sql: ` - INSERT OR REPLACE INTO recordings ( - id, user_id, source_provider, source_recording_id, source_version, - source_metadata, provider_device_id, filename, duration, start_time, - end_time, filesize, file_md5, storage_type, storage_path, - downloaded_at, upstream_trashed, upstream_deleted, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - args: [ - recordingId, - userId, - "ticnote", - `${recordingId}-source`, - "1", - "{}", - "e2e-list-device", - `E2E list pagination ${suffix}`, - 120_000 + index * 1000, - start, - start + 120_000, - 2048 + index, - recordingId, - "local", - "", - now, - 0, - 0, - now, - now, - ], - }); - } - } finally { - await library.close(); - } -} - -async function seedMultiTagRecordings(userId: string) { - const library = createClient({ url: databaseUrl(LIBRARY_DB) }); - const now = Date.now(); - const recordingId = `${LIST_RECORDING_PREFIX}multi-tag`; - const alphaTagId = `${LIST_RECORDING_PREFIX}tag-alpha`; - const betaTagId = `${LIST_RECORDING_PREFIX}tag-beta`; - - try { - await cleanupListSeeds(userId); - await library.batch([ - { - sql: ` - INSERT OR REPLACE INTO recordings ( - id, user_id, source_provider, source_recording_id, source_version, - source_metadata, provider_device_id, filename, duration, start_time, - end_time, filesize, file_md5, storage_type, storage_path, - downloaded_at, upstream_trashed, upstream_deleted, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - args: [ - recordingId, - userId, - "ticnote", - `${recordingId}-source`, - "1", - "{}", - "e2e-list-device", - "E2E multi tag recording", - 120_000, - now, - now + 120_000, - 2048, - recordingId, - "local", - "", - now, - 0, - 0, - now, - now, - ], - }, - { - sql: ` - INSERT OR REPLACE INTO recording_tags ( - id, user_id, name, color, icon, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?) - `, - args: [ - alphaTagId, - userId, - "Alpha", - "blue", - "tag", - now, - now, - betaTagId, - userId, - "Beta", - "purple", - "star", - now, - now, - ], - }, - { - sql: ` - INSERT OR REPLACE INTO recording_tag_assignments ( - id, user_id, recording_id, tag_id, created_at - ) VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?) - `, - args: [ - `${recordingId}-alpha`, - userId, - recordingId, - alphaTagId, - now, - `${recordingId}-beta`, - userId, - recordingId, - betaTagId, - now, - ], - }, - ]); - } finally { - await library.close(); - } -} - -async function seedTagMatrixRecordings(userId: string) { - const library = createClient({ url: databaseUrl(LIBRARY_DB) }); - const now = Date.now(); - - try { - await cleanupListSeeds(userId); - const statements = SOT_TAG_ICON_MATRIX.flatMap((icon, index) => { - const suffix = String(index + 1).padStart(2, "0"); - const recordingId = `${LIST_RECORDING_PREFIX}tag-matrix-${suffix}`; - const tagId = `${LIST_RECORDING_PREFIX}tag-matrix-tag-${suffix}`; - const color = - SOT_TAG_COLOR_MATRIX[index % SOT_TAG_COLOR_MATRIX.length] - .color; - const start = now - index * 90_000; - - return [ - { - sql: ` - INSERT OR REPLACE INTO recordings ( - id, user_id, source_provider, source_recording_id, source_version, - source_metadata, provider_device_id, filename, duration, start_time, - end_time, filesize, file_md5, storage_type, storage_path, - downloaded_at, upstream_trashed, upstream_deleted, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - args: [ - recordingId, - userId, - "ticnote", - `${recordingId}-source`, - "1", - "{}", - "e2e-list-device", - `E2E SOT tag ${icon}`, - 120_000 + index * 1000, - start, - start + 120_000, - 2048 + index, - recordingId, - "local", - "", - now, - 0, - 0, - now, - now, - ], - }, - { - sql: ` - INSERT OR REPLACE INTO recording_tags ( - id, user_id, name, color, icon, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?) - `, - args: [ - tagId, - userId, - `SOT ${icon}`, - color, - icon, - now, - now, - ], - }, - { - sql: ` - INSERT OR REPLACE INTO recording_tag_assignments ( - id, user_id, recording_id, tag_id, created_at - ) VALUES (?, ?, ?, ?, ?) - `, - args: [ - `${recordingId}-assignment`, - userId, - recordingId, - tagId, - now, - ], - }, - ]; - }); - - await library.batch(statements); - } finally { - await library.close(); - } -} - -async function seedRowStatusRecordings(userId: string) { - const library = createClient({ url: databaseUrl(LIBRARY_DB) }); - const transcripts = createClient({ url: databaseUrl(TRANSCRIPTS_DB) }); - const now = Date.now(); - const rows = [ - { - id: `${LIST_RECORDING_PREFIX}row-updated`, - filename: "E2E row status updated", - sourceProvider: "dingtalk-a1", - upstreamDeleted: 0, - }, - { - id: `${LIST_RECORDING_PREFIX}row-transcribing`, - filename: "E2E row status transcribing", - sourceProvider: "ticnote", - upstreamDeleted: 0, - }, - { - id: `${LIST_RECORDING_PREFIX}row-failed`, - filename: "E2E row status failed", - sourceProvider: "dingtalk-a1", - upstreamDeleted: 0, - }, - { - id: `${LIST_RECORDING_PREFIX}row-local-only`, - filename: "E2E row status local only", - sourceProvider: "ticnote", - upstreamDeleted: 1, - }, - { - id: `${LIST_RECORDING_PREFIX}row-pending`, - filename: "E2E row status pending", - sourceProvider: "iflyrec", - upstreamDeleted: 0, - }, - ]; - - try { - await cleanupListSeeds(userId); - for (const [index, row] of rows.entries()) { - const start = now - index * 60_000; - await library.execute({ - sql: ` - INSERT OR REPLACE INTO recordings ( - id, user_id, source_provider, source_recording_id, source_version, - source_metadata, provider_device_id, filename, duration, start_time, - end_time, filesize, file_md5, storage_type, storage_path, - downloaded_at, upstream_trashed, upstream_deleted, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - args: [ - row.id, - userId, - row.sourceProvider, - `${row.id}-source`, - "1", - "{}", - "e2e-row-status-device", - row.filename, - 180_000 + index * 1000, - start, - start + 180_000, - 4096 + index, - row.id, - "local", - "", - now, - 0, - row.upstreamDeleted, - now, - now, - ], - }); - } - - await library.batch([ - { - sql: ` - INSERT OR REPLACE INTO recording_tags ( - id, user_id, name, color, icon, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?) - `, - args: [ - `${LIST_RECORDING_PREFIX}tag-row-style`, - userId, - "Style", - "blue", - "tag", - now, - now, - ], - }, - { - sql: ` - INSERT OR REPLACE INTO recording_tag_assignments ( - id, user_id, recording_id, tag_id, created_at - ) VALUES (?, ?, ?, ?, ?) - `, - args: [ - `${LIST_RECORDING_PREFIX}row-style-assignment`, - userId, - `${LIST_RECORDING_PREFIX}row-updated`, - `${LIST_RECORDING_PREFIX}tag-row-style`, - now, - ], - }, - ]); - - await library.batch([ - { - sql: ` - INSERT OR REPLACE INTO transcription_jobs ( - id, user_id, recording_id, status, force, provider, model, - provider_job_id, remote_status, attempts, last_error, - requested_at, started_at, completed_at, next_poll_at, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - args: [ - `${LIST_RECORDING_PREFIX}job-transcribing`, - userId, - `${LIST_RECORDING_PREFIX}row-transcribing`, - "processing", - 0, - "voice-transcribe", - "e2e", - "remote-row-transcribing", - "transcribing", - 1, - null, - now - 60_000, - now - 55_000, - null, - now + 300_000, - now - 60_000, - now, - ], - }, - { - sql: ` - INSERT OR REPLACE INTO transcription_jobs ( - id, user_id, recording_id, status, force, provider, model, - provider_job_id, remote_status, attempts, last_error, - requested_at, started_at, completed_at, next_poll_at, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - args: [ - `${LIST_RECORDING_PREFIX}job-failed`, - userId, - `${LIST_RECORDING_PREFIX}row-failed`, - "failed", - 0, - "voice-transcribe", - "e2e", - "remote-row-failed", - "failed", - 1, - "row status failed", - now - 60_000, - now - 55_000, - now - 30_000, - null, - now - 60_000, - now, - ], - }, - ]); - - await transcripts.execute({ - sql: ` - INSERT OR REPLACE INTO transcriptions ( - id, recording_id, user_id, text, detected_language, - transcription_type, provider, model, provider_job_id, - speaker_map, provider_payload, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - args: [ - `${LIST_RECORDING_PREFIX}transcript-updated`, - `${LIST_RECORDING_PREFIX}row-updated`, - userId, - "这条录音已有转写。", - "zh", - "server", - "voice-transcribe", - "e2e", - "remote-row-updated", - "{}", - "{}", - now, - ], - }); - } finally { - await library.close(); - await transcripts.close(); - } -} - -async function seedTimelineFilterRecordings(userId: string) { - const library = createClient({ url: databaseUrl(LIBRARY_DB) }); - const now = Date.now(); - const localToday = new Date(); - localToday.setHours(0, 0, 0, 0); - const todayStart = Math.max(localToday.getTime(), now - 30 * 60_000); - const earlierStart = localToday.getTime() - 12 * 86_400_000; - - try { - await cleanupListSeeds(userId); - for (const recording of [ - { - id: `${LIST_RECORDING_PREFIX}today-ticnote`, - filename: "E2E timeline today TicNote", - sourceProvider: "ticnote", - start: todayStart, - }, - { - id: `${LIST_RECORDING_PREFIX}earlier-plaud`, - filename: "E2E timeline earlier Plaud", - sourceProvider: "plaud", - start: earlierStart, - }, - ]) { - await library.execute({ - sql: ` - INSERT OR REPLACE INTO recordings ( - id, user_id, source_provider, source_recording_id, source_version, - source_metadata, provider_device_id, filename, duration, start_time, - end_time, filesize, file_md5, storage_type, storage_path, - downloaded_at, upstream_trashed, upstream_deleted, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - args: [ - recording.id, - userId, - recording.sourceProvider, - `${recording.id}-source`, - "1", - "{}", - "e2e-list-device", - recording.filename, - 120_000, - recording.start, - recording.start + 120_000, - 2048, - recording.id, - "local", - "", - now, - 0, - 0, - now, - now, - ], - }); - } - } finally { - await library.close(); - } -} - -async function resetDisplay( - page: Page, - options: { - itemsPerPage?: number; - theme?: "system" | "light" | "dark"; - uiLanguage?: "zh-CN" | "en"; - } = {}, -) { - const resetResponse = await putJsonWithRetry(page, "/api/settings/display", { - dateTimeFormat: "relative", - itemsPerPage: options.itemsPerPage ?? 50, - recordingListSortOrder: "newest", - theme: options.theme ?? "dark", - uiLanguage: options.uiLanguage ?? "zh-CN", - }); - expect(resetResponse.ok()).toBe(true); -} - -async function mockConnectedDataSources(page: Page) { - await page.route("**/api/data-sources", async (route) => { - if (route.request().method() !== "GET") { - await route.continue(); - return; - } - - const capabilities = { - audioDownload: true, - localRename: true, - officialSummary: true, - officialTranscript: true, - privateTranscribe: true, - upstreamTitleWriteback: false, - workerSync: true, - }; - const labels: Record = { - "dingtalk-a1": "钉钉", - "feishu-minutes": "飞书妙记", - iflyrec: "讯飞听见", - plaud: "Plaud", - ticnote: "TicNote", - }; - const connectedProviders = new Set(["ticnote", "plaud"]); - - await route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ - sources: [ - "dingtalk-a1", - "ticnote", - "plaud", - "feishu-minutes", - "iflyrec", - ].map((provider) => ({ - authMode: "bearer", - authModes: ["bearer"], - baseUrl: "https://example.invalid", - capabilities, - config: {}, - connected: connectedProviders.has(provider), - connectionStatus: "ready", - displayName: labels[provider] ?? provider, - enabled: connectedProviders.has(provider), - lastSync: null, - provider, - runtimeStatus: "active", - secretsConfigured: connectedProviders.has(provider) - ? { bearerToken: true } - : {}, - })), - }), - }); - }); -} - -async function expectNoTweaksLeak(page: Page) { - await expect(page.locator("#tweaks-panel")).toHaveCount(0); - await expect(page.locator(".tw-scroll")).toHaveCount(0); - await expect(page.locator(".ds-only-mark, .ds-only-badge")).toHaveCount(0); - await expect(page.locator('[data-sot-control="ds-only-tweaks"]')).toHaveCount( - 0, - ); - await expect(page.getByText("Tweaks", { exact: true })).toHaveCount(0); - await expect(page.getByText("调试", { exact: true })).toHaveCount(0); -} - -function recordingListPanel(page: Page) { - return page.locator('[data-sot-surface="dashboard-recording-list"]'); -} - -function sotRecordingListPanel(page: Page) { - return page.locator( - '[data-sot-surface="dashboard-recording-list"], .list-panel', - ); -} - -function sotControl(page: Page, name: string) { - return page.locator(`[data-sot-control="${name}"]`); -} - -function recordingRow(page: Page, id: string) { - return page.locator(`[data-sot-recording-id="${id}"]`); -} - -function seededRecordingRows(panel: Locator) { - return panel.locator( - '[data-sot-control="dashboard-recording-row"][data-sot-recording-id^="e2e-list-state-"]', - ); -} - -function listStateBlock(panel: Locator, state: string) { - return panel.locator( - `[data-sot-part="recording-list-state"][data-sot-state="${state}"]`, - ); -} - -function sourceProvider(page: Page, provider: string) { - return page.locator( - `[data-sot-control="dashboard-source-provider"][data-sot-provider="${provider}"]`, - ); -} - -async function openSotComponentLibrary(page: Page) { - await page.goto(SOT_COMPONENT_LIBRARY_URL, { waitUntil: "load" }); - await page.evaluate(() => { - document.documentElement.dataset.theme = "dark"; - }); -} - -async function applyListRowMigrationFixtureCss(page: Page) { - await page.addStyleTag({ - content: `${LIST_ROW_MIGRATION_FIXTURE_CSS}\n${LIST_COMPONENT_LIBRARY_SHADCN_TAG_FIXTURE_CSS}`, - }); -} - -async function openSotWorkstation(page: Page) { - await page.goto(SOT_WORKSTATION_URL, { waitUntil: "load" }); - await page.evaluate(() => { - document.documentElement.dataset.theme = "dark"; - document.body.removeAttribute("data-time-style"); - }); -} - -async function openSotCssOnlyWorkstation(page: Page) { - sotWorkstationCssCache ??= ( - await Promise.all([ - readFile(SOT_COLORS_AND_TYPE_CSS_PATH, "utf8"), - readFile(SOT_KIT_CSS_PATH, "utf8"), - ]) - ).join("\n"); - await page.setContent( - ``, - { waitUntil: "load" }, - ); - await page.evaluate(() => { - document.documentElement.dataset.theme = "dark"; - document.body.removeAttribute("data-time-style"); - }); -} - -async function readSotComponentTagIconSignatures(page: Page) { - await openSotComponentLibrary(page); - const buttons = page.locator( - '#tagmgr .cl-card:has-text("Create") .tagm-icon-grid .tg-pick', - ); - await expect(buttons).toHaveCount(SOT_TAG_ICON_MATRIX.length); - - return buttons.evaluateAll((buttons, icons) => { - const svgSignature = (svg: SVGElement | null) => - Array.from(svg?.children ?? []).map((child) => ({ - attributes: Array.from(child.attributes).map((attribute) => [ - attribute.name, - attribute.value, - ]), - tagName: child.tagName.toLowerCase(), - })); - - return Object.fromEntries( - buttons.map((button, index) => [ - icons[index] ?? "", - svgSignature(button.querySelector("svg")), - ]), - ); - }, SOT_TAG_ICON_MATRIX) as Promise>; -} - -async function readSvgChildSignature(locator: Locator) { - return locator.locator("svg").first().evaluate((svg) => - Array.from(svg.children).map((child) => ({ - attributes: Array.from(child.attributes).map((attribute) => [ - attribute.name, - attribute.value, - ]), - tagName: child.tagName.toLowerCase(), - })), - ) as Promise; -} - -function sourceAssetMime(fileName: string) { - if (fileName.endsWith(".svg")) return "image/svg+xml"; - if (fileName.endsWith(".jpeg") || fileName.endsWith(".jpg")) { - return "image/jpeg"; - } - if (fileName.endsWith(".png")) return "image/png"; - throw new Error(`Unsupported SOT source asset type: ${fileName}`); -} - -async function readListRowSourceAssetDataUrls() { - const entries = await Promise.all( - Object.entries(LIST_ROW_SOURCE_ASSETS).map(async ([src, fileName]) => { - const bytes = await readFile(path.join(SOT_SOURCE_ASSET_DIR, fileName)); - return [ - src, - `data:${sourceAssetMime(fileName)};base64,${bytes.toString( - "base64", - )}`, - ] as const; - }), - ); - - return Object.fromEntries(entries) as Record; -} - -async function readSotListRowHtml(page: Page) { - const entries = await Promise.all( - LIST_ROW_SOT_STATES.map(async (state) => [ - state, - await page - .locator(LIST_ROW_SELECTORS[state]) - .first() - .evaluate((element) => element.outerHTML), - ]), - ); - - return Object.fromEntries(entries) as Record; -} - -async function readSotListSkeletonHtml(page: Page) { - return page - .locator(".skel-list") - .first() - .evaluate((element) => element.outerHTML); -} - -async function readSotTagFilterTriggerHtml(page: Page) { - const entries = await Promise.all( - TAG_FILTER_TRIGGER_SOT_STATES.map(async (state) => { - const card = page.locator("#tagsel .cl-card").filter({ - hasText: TAG_FILTER_TRIGGER_LABELS[state], - }); - return [ - state, - await card - .locator(".tag-filter-trigger") - .first() - .evaluate((element) => element.outerHTML), - ] as const; - }), - ); - - return Object.fromEntries(entries) as Record< - TagFilterTriggerSotState, - string - >; -} - -async function readSotOpenTagFilterHtml(page: Page) { - await page.evaluate(() => { - const filter = document.querySelector( - '[data-list-filter-row="tags"]', - ); - const trigger = document.querySelector( - "[data-tag-filter-trigger]", - ); - const list = document.querySelector( - "[data-tag-filter-list]", - ); - - filter?.removeAttribute("hidden"); - trigger?.setAttribute("aria-expanded", "true"); - list?.removeAttribute("hidden"); - }); - await expect( - page.locator("[data-tag-filter-list] .tag-filter-option"), - ).toHaveCount(7); - - return page - .locator('[data-list-filter-row="tags"]') - .first() - .evaluate((element) => element.outerHTML); -} - -async function readSotListStateBlockHtml(page: Page) { - const entries: Array = []; - - for (const state of LIST_STATE_BLOCK_SOT_STATES) { - await page.evaluate((targetState) => { - document - .querySelector( - `.tw-list-grid button[data-list-state="${targetState}"]`, - ) - ?.click(); - }, state); - const block = page.locator(`[data-list-state-block="${state}"]`).first(); - await expect(block).toBeVisible(); - entries.push([ - state, - await block.evaluate((element) => element.outerHTML), - ] as const); - } - - return Object.fromEntries(entries) as Record; -} - -async function readSotListPanelHtml(page: Page) { - const panel = sotRecordingListPanel(page).first(); - return panel.evaluate((element) => { - const clone = element.cloneNode(true) as HTMLElement; - clone - .querySelectorAll(".stack-banner, [hidden]") - .forEach((child) => child.remove()); - clone.querySelectorAll("._is-1").forEach((child) => { - child.classList.remove("_is-1"); - }); - clone.classList.remove("panel", "list-panel"); - clone.setAttribute("data-slot", "card"); - clone.setAttribute("data-sot-surface", "dashboard-recording-list"); - - const setAttributes = ( - selector: string, - attributes: Record, - ) => { - clone.querySelectorAll(selector).forEach((node) => { - for (const [name, value] of Object.entries(attributes)) { - node.setAttribute(name, value); - } - }); - }; - - setAttributes(".list-header", { - "data-sot-part": "dashboard-recording-list-header", - }); - setAttributes(".lh-titlebar", { - "data-sot-part": "dashboard-recording-list-titlebar", - }); - setAttributes(".lh-title", { - "data-sot-part": "dashboard-recording-list-title", - }); - setAttributes(".lh-count", { - "data-sot-part": "dashboard-recording-list-count", - }); - setAttributes(".stack-strip", { - "data-sot-panel": "dashboard-source-filter-stack", - "data-sot-state": "default", - }); - setAttributes(".stack-from", { - "data-sot-part": "source-filter-from", - }); - setAttributes(".stack-sep", { - "data-sot-part": "source-filter-separator", - }); - setAttributes(".stack-chip", { - "data-sot-part": "source-filter-chip", - }); - setAttributes(".stack-chip .ico", { - "data-sot-part": "source-filter-icon", - }); - clone - .querySelectorAll(".stack-chip .ico.cover") - .forEach((node) => { - node.setAttribute("data-sot-provider-cover", "true"); - }); - setAttributes(".stack-info", { - "data-sot-part": "source-filter-info", - }); - setAttributes(".stack-chip .x", { - "data-slot": "button", - "data-sot-control": "source-filter-clear", - }); - setAttributes(".list-mode-bar", { - "data-sot-panel": "dashboard-recording-list-mode", - }); - setAttributes(".list-mode-label", { - "data-sot-part": "dashboard-recording-list-mode-label", - }); - setAttributes(".list-mode-count", { - "data-sot-part": "dashboard-recording-list-mode-count", - }); - setAttributes(".list-mode-seg", { - "data-slot": "segmented-tabs", - "data-sot-control": "liquid-tabs", - "data-sot-part": "dashboard-recording-list-mode-segmented", - "data-sot-size": "sm", - }); - clone - .querySelectorAll(".list-mode-seg") - .forEach((node) => { - const activeIndex = Math.max( - 0, - Array.from(node.querySelectorAll(".lt-tab")).findIndex( - (tab) => tab.classList.contains("active"), - ), - ); - node.setAttribute("data-idx", String(activeIndex)); - node.setAttribute("data-active", String(activeIndex)); - node.setAttribute( - "data-tabs", - String(node.querySelectorAll(".lt-tab").length), - ); - }); - setAttributes(".lt-ind", { - "data-sot-part": "liquid-tabs-indicator", - }); - clone.querySelectorAll(".lt-tab").forEach((node) => { - const mode = node.dataset.mode ?? node.textContent?.trim() ?? ""; - const active = node.classList.contains("active"); - node.setAttribute("data-sot-control", "liquid-tab"); - node.setAttribute("data-sot-state", active ? "active" : "idle"); - node.setAttribute("data-tab-key", mode); - node.setAttribute("role", "tab"); - node.setAttribute("aria-selected", active ? "true" : "false"); - }); - setAttributes('.filter-row[data-list-filter-row="timeline"]', { - "data-slot": "toggle-group", - "data-sot-panel": "dashboard-recording-time-filter", - }); - clone.querySelectorAll(".chip-f").forEach((node) => { - const active = node.classList.contains("active"); - node.setAttribute("data-slot", "toggle-group-item"); - node.setAttribute( - "data-sot-control", - "dashboard-recording-time-filter", - ); - node.setAttribute("data-sot-filter", node.dataset.tf ?? ""); - node.setAttribute("data-sot-state", active ? "selected" : "idle"); - }); - setAttributes(".chip-c", { - "data-sot-part": "dashboard-recording-time-filter-count", - }); - setAttributes(".tag-filter", { - "data-sot-panel": "recording-list-tag-filter", - }); - setAttributes(".tag-filter-trigger", { - "data-slot": "button", - "data-sot-control": "recording-list-tag-filter-trigger", - }); - setAttributes(".tag-filter-label", { - "data-sot-part": "recording-list-tag-filter-label", - }); - setAttributes(".tag-filter-count", { - "data-sot-part": "recording-list-tag-filter-count", - }); - setAttributes(".tag-filter-caret", { - "data-sot-part": "recording-list-tag-filter-caret", - }); - setAttributes(".tag-filter-list", { - "data-sot-list": "recording-list-tag-filter-list", - }); - setAttributes(".tag-filter-option", { - "data-slot": "button", - "data-sot-control": "recording-list-tag-filter", - }); - setAttributes(".tag-filter-option-label", { - "data-sot-part": "recording-list-tag-filter-option-label", - }); - setAttributes(".tag-filter-option-count", { - "data-sot-part": "recording-list-tag-filter-option-count", - }); - setAttributes(".utag-plus", { - "data-sot-part": "recording-tag-overflow", - }); - setAttributes(".list-scroll", { - "data-sot-list": "dashboard-recording-list-scroll", - }); - - if ( - !clone.querySelector( - ':scope > [data-sot-part="dashboard-recording-list-content"][data-slot="card-content"]', - ) - ) { - const content = document.createElement("div"); - content.setAttribute( - "data-sot-part", - "dashboard-recording-list-content", - ); - content.setAttribute("data-slot", "card-content"); - while (clone.firstChild) { - content.appendChild(clone.firstChild); - } - clone.appendChild(content); - } - - return clone.outerHTML; - }); -} - -async function waitForListRowFixtureImages(page: Page, fixtureId: string) { - await page.locator(`#${fixtureId} img`).evaluateAll((images) => - Promise.all( - images.map( - (image) => - image.complete || - new Promise((resolve, reject) => { - image.addEventListener("load", () => resolve(), { - once: true, - }); - image.addEventListener( - "error", - () => - reject( - new Error( - `Failed to load fixture image ${image.getAttribute( - "src", - )}`, - ), - ), - { once: true }, - ); - }), - ), - ), - ); -} - -async function installSotPixelDevOverlaySuppression( - page: Page, - fixtureId: string, -) { - await page.evaluate( - ({ attribute, css, id }) => { - const selector = [ - "nextjs-portal", - "[data-nextjs-toast]", - "[data-nextjs-dialog-overlay]", - "[data-nextjs-dialog-backdrop]", - "[data-nextjs-dialog]", - "[data-nextjs-errors]", - "[data-nextjs-dev-tools-button]", - 'button[aria-label="Open Next.js Dev Tools"]', - ".__nextjs-dev-overlay", - ].join(", "); - const stateKey = "__sotPixelDevOverlaySuppressionTimers"; - const windowWithState = window as typeof window & { - [stateKey]?: Record; - }; - const timers = (windowWithState[stateKey] ??= {}); - - window.clearInterval(timers[id]); - document.querySelector(`style[${attribute}="${id}"]`)?.remove(); - - const devOverlayStyle = document.createElement("style"); - devOverlayStyle.setAttribute(attribute, id); - devOverlayStyle.textContent = css; - document.head.appendChild(devOverlayStyle); - - const hideDevOverlay = () => { - const fixture = document.getElementById(id); - const hideElement = (element: HTMLElement) => { - element.setAttribute("aria-hidden", "true"); - element.style.setProperty("display", "none", "important"); - element.style.setProperty( - "visibility", - "hidden", - "important", - ); - element.style.setProperty("opacity", "0", "important"); - element.style.setProperty( - "pointer-events", - "none", - "important", - ); - }; - const isDevOverlayCandidate = (element: HTMLElement) => { - if (fixture?.contains(element)) { - return false; - } - - const elementSignature = [ - element.tagName, - element.id, - String(element.className), - element.getAttribute("aria-label"), - element.getAttribute("title"), - ...Array.from(element.attributes) - .map((attribute) => attribute.name) - .filter((name) => name.startsWith("data-nextjs")), - ] - .join(" ") - .toLowerCase(); - const style = window.getComputedStyle(element); - const zIndex = Number.parseInt(style.zIndex, 10); - const fixedHighLayer = - style.position === "fixed" && - Number.isFinite(zIndex) && - zIndex >= 1_000; - const nextDevTools = - elementSignature.includes("next") && - (elementSignature.includes("dev") || - elementSignature.includes("tool")); - - return nextDevTools || fixedHighLayer; - }; - const hideInRoot = (root: Document | ShadowRoot) => { - for (const element of root.querySelectorAll( - selector, - )) { - hideElement(element); - } - for (const element of root.querySelectorAll( - "*", - )) { - if (isDevOverlayCandidate(element)) { - hideElement(element); - } - if (element.shadowRoot) { - hideInRoot(element.shadowRoot); - } - } - }; - - hideInRoot(document); - }; - - hideDevOverlay(); - timers[id] = window.setInterval(hideDevOverlay, 50); - }, - { - attribute: SOT_PIXEL_DEV_OVERLAY_STYLE_ATTR, - css: SOT_PIXEL_DEV_OVERLAY_HIDDEN_CSS, - id: fixtureId, - }, - ); -} - -async function removeSotPixelDevOverlaySuppression( - page: Page, - fixtureId: string, -) { - await page.evaluate( - ({ attribute, id }) => { - const stateKey = "__sotPixelDevOverlaySuppressionTimers"; - const windowWithState = window as typeof window & { - [stateKey]?: Record; - }; - const timers = windowWithState[stateKey]; - if (timers?.[id]) { - window.clearInterval(timers[id]); - delete timers[id]; - } - document.querySelector(`style[${attribute}="${id}"]`)?.remove(); - }, - { attribute: SOT_PIXEL_DEV_OVERLAY_STYLE_ATTR, id: fixtureId }, - ); -} - -async function captureListRowFixture( - page: Page, - rowHtml: string, - sourceAssetDataUrls: Record, -) { - const fixtureId = `sot-list-row-${Date.now()}-${Math.random() - .toString(16) - .slice(2)}`; - - await page.evaluate( - ({ - devOverlayCss, - fixtureId: id, - migrationFixtureCss, - rowHtml: html, - sourceAssetDataUrls: assetDataUrls, - }) => { - document.getElementById(id)?.remove(); - document.documentElement.dataset.theme = "dark"; - document.body.removeAttribute("data-time-style"); - const devOverlayStyle = document.createElement("style"); - devOverlayStyle.dataset.listPanelFrameFixture = id; - devOverlayStyle.textContent = `${devOverlayCss}\n${migrationFixtureCss}`; - document.head.appendChild(devOverlayStyle); - - const host = document.createElement("div"); - host.id = id; - host.style.position = "fixed"; - host.style.left = "32px"; - host.style.top = "32px"; - host.style.zIndex = "2147483647"; - host.style.pointerEvents = "none"; - host.style.background = "transparent"; - - const stage = document.createElement("div"); - stage.className = "list-row-pixel-stage"; - stage.style.boxSizing = "border-box"; - stage.style.background = "rgb(24, 29, 35)"; - stage.style.padding = "16px"; - stage.style.width = "420px"; - - const list = document.createElement("div"); - list.className = "real-list"; - list.style.width = "388px"; - list.innerHTML = html; - for (const mutedDot of list.querySelectorAll("._is-1")) { - mutedDot.classList.remove("_is-1"); - mutedDot.classList.add("status-dot-muted"); - } - for (const overflowChip of list.querySelectorAll( - ".utag-plus", - )) { - overflowChip.setAttribute( - "data-sot-part", - "recording-tag-overflow", - ); - } - - for (const image of list.querySelectorAll("img")) { - const src = image.getAttribute("src"); - if (src && assetDataUrls[src]) { - image.setAttribute("src", assetDataUrls[src]); - } - } - - stage.appendChild(list); - host.appendChild(stage); - document.body.appendChild(host); - }, - { - devOverlayCss: SOT_PIXEL_DEV_OVERLAY_HIDDEN_CSS, - fixtureId, - migrationFixtureCss: LIST_ROW_MIGRATION_FIXTURE_CSS, - rowHtml, - sourceAssetDataUrls, - }, - ); - - await waitForListRowFixtureImages(page, fixtureId); - const stage = page.locator(`#${fixtureId} > .list-row-pixel-stage`).first(); - const row = page.locator(`#${fixtureId} .real-list > .row`).first(); - await expect(row).toBeVisible(); - await page.waitForTimeout(250); - - const metrics = await row.evaluate((element) => { - const readStyle = (node: Element | null) => { - if (!node) return null; - const style = window.getComputedStyle(node); - const rect = node.getBoundingClientRect(); - return { - backgroundColor: style.backgroundColor, - borderColor: style.borderColor, - boxShadow: style.boxShadow, - color: style.color, - display: style.display, - font: style.font, - height: Math.round(rect.height * 1000) / 1000, - padding: style.padding, - width: Math.round(rect.width * 1000) / 1000, - }; - }; - - return { - badge: readStyle(element.querySelector(".b")), - imageCount: element.querySelectorAll("img").length, - right: readStyle(element.querySelector(".right")), - source: readStyle(element.querySelector(".src-mini")), - sourceImage: readStyle(element.querySelector(".src-mini img")), - row: readStyle(element), - tag: readStyle(element.querySelector(".utag")), - title: readStyle(element.querySelector(".title")), - }; - }); - const screenshot = await stage.screenshot({ - animations: "disabled", - omitBackground: false, - scale: "css", - }); - await page.evaluate((id) => { - document.getElementById(id)?.remove(); - document - .querySelector(`style[data-list-panel-frame-fixture="${id}"]`) - ?.remove(); - }, fixtureId); - - return { - dataUrl: `data:image/png;base64,${screenshot.toString("base64")}`, - metrics, - screenshot, - }; -} - -async function captureListSkeletonFixture(page: Page, skeletonHtml: string) { - const fixtureId = `sot-list-skeleton-${Date.now()}-${Math.random() - .toString(16) - .slice(2)}`; - - await installSotPixelDevOverlaySuppression(page, fixtureId); - try { - await page.evaluate( - ({ fixtureCss, fixtureId: id, skeletonHtml: html }) => { - document.getElementById(id)?.remove(); - document.documentElement.dataset.theme = "dark"; - document - .querySelector(`style[data-list-skeleton-fixture="${id}"]`) - ?.remove(); - const fixtureStyle = document.createElement("style"); - fixtureStyle.dataset.listSkeletonFixture = id; - fixtureStyle.textContent = fixtureCss; - document.head.appendChild(fixtureStyle); - - const host = document.createElement("div"); - host.id = id; - host.style.position = "fixed"; - host.style.left = "32px"; - host.style.top = "32px"; - host.style.zIndex = "2147483647"; - host.style.pointerEvents = "none"; - host.style.background = "transparent"; - - const stage = document.createElement("div"); - stage.className = "list-skeleton-pixel-stage"; - stage.style.boxSizing = "border-box"; - stage.style.background = "rgb(24, 29, 35)"; - stage.style.padding = "16px"; - stage.style.width = "420px"; - stage.innerHTML = html; - stage.querySelector(".skel-list")?.removeAttribute("hidden"); - - host.appendChild(stage); - document.body.appendChild(host); - }, - { - fixtureCss: LIST_SKELETON_MIGRATION_FIXTURE_CSS, - fixtureId, - skeletonHtml, - }, - ); - - const stage = page - .locator(`#${fixtureId} > .list-skeleton-pixel-stage`) - .first(); - const skeleton = page.locator(`#${fixtureId} .skel-list`).first(); - await expect(skeleton).toBeVisible(); - await page.waitForTimeout(250); - const screenshot = await stage.screenshot({ - animations: "disabled", - omitBackground: false, - scale: "css", - }); - - return { - dataUrl: `data:image/png;base64,${screenshot.toString("base64")}`, - screenshot, - }; - } finally { - try { - await page.evaluate((id) => { - document.getElementById(id)?.remove(); - document - .querySelector(`style[data-list-skeleton-fixture="${id}"]`) - ?.remove(); - }, fixtureId); - } finally { - await removeSotPixelDevOverlaySuppression(page, fixtureId); - } - } -} - -function tagFilterPixelFixtureCss(scope: string) { - return ` - ${scope} .tag-filter { - position: relative; - margin-top: 10px; - } - ${scope} .tag-filter-trigger { - display: flex; - align-items: center; - gap: 6px; - width: 100%; - height: 30px; - padding: 0 10px; - border-radius: 8px; - background: var(--bg-elevated); - border: 1px solid var(--line-hairline); - font: 600 12.5px var(--font-sans); - color: var(--fg-primary); - cursor: pointer; - text-align: left; - } - ${scope} .tag-filter-label { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - ${scope} .tag-filter-count { - font: 500 11px var(--font-mono); - color: var(--fg-tertiary); - } - ${scope} .tag-filter-caret { - width: 11px; - height: 11px; - stroke: currentColor; - fill: none; - stroke-width: 2; - color: var(--fg-tertiary); - } - ${scope} .tag-filter-list { - position: absolute; - left: 0; - right: 0; - top: calc(100% + 6px); - z-index: var(--z-popover-inline); - overflow-y: auto; - background: var(--bg-elevated); - border: 1px solid var(--line-hairline); - border-radius: 8px; - box-shadow: var(--shadow-lg); - padding: 4px; - max-height: 260px; - } - [data-theme="dark"] ${scope} .tag-filter-list { - background: color-mix(in srgb, var(--graphite-900) 92%, transparent); - border-color: var(--glass-border); - } - ${scope} .tag-filter-option { - display: flex; - align-items: center; - gap: 8px; - padding: 7px 8px; - border-radius: 6px; - background: transparent; - border: 0; - cursor: pointer; - width: 100%; - text-align: left; - font: 500 12.5px var(--font-sans); - color: var(--fg-primary); - } - ${scope} .tag-filter-option:hover { - background: var(--bg-recessed); - } - ${scope} .tag-filter-option .tg-ico { - width: 12px; - height: 12px; - stroke: currentColor; - fill: none; - stroke-width: 2; - } - ${scope} .tag-filter-option-label { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - ${scope} .tag-filter-option-count { - font: 500 11px var(--font-mono); - color: var(--fg-tertiary); - } - ${scope} .tag-filter-option[aria-selected="true"] { - background: var(--accent-soft); - color: var(--accent); - } - ${scope} .tag-filter[hidden] { - display: none !important; - } - `; -} - -async function captureTagFilterTriggerFixture(page: Page, triggerHtml: string) { - const fixtureId = `sot-tag-filter-trigger-${Date.now()}-${Math.random() - .toString(16) - .slice(2)}`; - - await page.evaluate( - ({ fixtureCss, fixtureId: id, triggerHtml: html }) => { - document.getElementById(id)?.remove(); - document.documentElement.dataset.theme = "dark"; - - const host = document.createElement("div"); - host.id = id; - host.style.position = "fixed"; - host.style.left = "32px"; - host.style.top = "32px"; - host.style.zIndex = "2147483647"; - host.style.pointerEvents = "none"; - host.style.background = "transparent"; - - const stage = document.createElement("div"); - stage.className = "tag-filter-trigger-pixel-stage"; - stage.style.boxSizing = "border-box"; - stage.style.background = "var(--bg-canvas)"; - stage.style.padding = "16px"; - stage.style.width = "280px"; - - const wrapper = document.createElement("div"); - wrapper.className = "tag-filter"; - wrapper.style.margin = "0"; - wrapper.style.width = "248px"; - wrapper.innerHTML = html; - - const style = document.createElement("style"); - style.setAttribute("data-tag-filter-fixture", id); - style.textContent = fixtureCss; - - host.appendChild(style); - stage.appendChild(wrapper); - host.appendChild(stage); - document.body.appendChild(host); - }, - { - fixtureCss: tagFilterPixelFixtureCss(`#${fixtureId}`), - fixtureId, - triggerHtml, - }, - ); - - const stage = page - .locator(`#${fixtureId} > .tag-filter-trigger-pixel-stage`) - .first(); - const trigger = page.locator(`#${fixtureId} .tag-filter-trigger`).first(); - await expect(trigger).toBeVisible(); - await page.evaluate(() => document.fonts.ready); - await page.waitForTimeout(250); - const screenshot = await stage.screenshot({ - animations: "disabled", - omitBackground: false, - scale: "css", - }); - await page.evaluate((id) => { - document.getElementById(id)?.remove(); - }, fixtureId); - - return { - dataUrl: `data:image/png;base64,${screenshot.toString("base64")}`, - screenshot, - }; -} - -async function captureOpenTagFilterFixture(page: Page, tagFilterHtml: string) { - const fixtureId = `sot-tag-filter-open-${Date.now()}-${Math.random() - .toString(16) - .slice(2)}`; - - await page.evaluate( - ({ fixtureCss, fixtureId: id, tagFilterHtml: html }) => { - document.getElementById(id)?.remove(); - document.documentElement.dataset.theme = "dark"; - - const host = document.createElement("div"); - host.id = id; - host.style.position = "fixed"; - host.style.left = "32px"; - host.style.top = "32px"; - host.style.zIndex = "2147483647"; - host.style.pointerEvents = "none"; - host.style.background = "transparent"; - - const stage = document.createElement("div"); - stage.className = "tag-filter-open-pixel-stage"; - stage.style.boxSizing = "border-box"; - stage.style.background = "var(--bg-canvas)"; - stage.style.height = "260px"; - stage.style.overflow = "visible"; - stage.style.padding = "16px"; - stage.style.width = "280px"; - stage.innerHTML = html; - - const filter = stage.querySelector(".tag-filter"); - filter?.removeAttribute("hidden"); - if (filter) { - filter.style.margin = "0"; - filter.style.width = "248px"; - } - stage - .querySelector("[data-tag-filter-trigger]") - ?.setAttribute("aria-expanded", "true"); - stage - .querySelector("[data-tag-filter-list]") - ?.removeAttribute("hidden"); - - const style = document.createElement("style"); - style.setAttribute("data-tag-filter-fixture", id); - style.textContent = fixtureCss; - - host.appendChild(style); - host.appendChild(stage); - document.body.appendChild(host); - }, - { - fixtureCss: tagFilterPixelFixtureCss(`#${fixtureId}`), - fixtureId, - tagFilterHtml, - }, - ); - - const stage = page - .locator(`#${fixtureId} > .tag-filter-open-pixel-stage`) - .first(); - await expect(page.locator(`#${fixtureId} .tag-filter-list`)).toBeVisible(); - await page.evaluate(() => document.fonts.ready); - await page.waitForTimeout(250); - const screenshot = await stage.screenshot({ - animations: "disabled", - omitBackground: false, - scale: "css", - }); - await page.evaluate((id) => { - document.getElementById(id)?.remove(); - }, fixtureId); - - return { - dataUrl: `data:image/png;base64,${screenshot.toString("base64")}`, - screenshot, - }; -} - -async function captureListStateBlockFixture(page: Page, blockHtml: string) { - const fixtureId = `sot-list-state-block-${Date.now()}-${Math.random() - .toString(16) - .slice(2)}`; - - await page.evaluate( - ({ blockHtml: html, fixtureCss, fixtureId: id, ownerClasses }) => { - document.getElementById(id)?.remove(); - document.documentElement.dataset.theme = "dark"; - - const addClasses = (element: Element | null, className: string) => { - if (!element) return; - element.classList.add( - ...className.split(/\s+/).filter(Boolean), - ); - }; - const addClassesToAll = (selector: string, className: string) => { - stage.querySelectorAll(selector).forEach((element) => { - addClasses(element, className); - }); - }; - - const host = document.createElement("div"); - host.id = id; - host.style.position = "fixed"; - host.style.left = "32px"; - host.style.top = "32px"; - host.style.zIndex = "2147483647"; - host.style.pointerEvents = "none"; - host.style.background = "transparent"; - - const stage = document.createElement("div"); - stage.className = "list-state-block-pixel-stage"; - stage.style.boxSizing = "border-box"; - stage.style.background = "rgb(24, 29, 35)"; - stage.style.padding = "16px"; - stage.style.width = "420px"; - stage.innerHTML = html; - const style = document.createElement("style"); - style.textContent = fixtureCss; - const stateBlock = - stage.querySelector(".list-state-block") ?? - stage.querySelector("[data-list-state-block]"); - stateBlock?.removeAttribute("hidden"); - stateBlock?.classList.add("list-state-block"); - if (stateBlock) { - const state = - stateBlock.getAttribute("data-list-state-block") ?? - stateBlock.getAttribute("data-sot-state") ?? - ""; - const isPagination = - state.startsWith("paginated") || - stateBlock.classList.contains("list-state-pagination") || - stateBlock.getAttribute("data-sot-panel") === - "recording-list-pagination"; - - stateBlock.setAttribute("data-sot-state", state); - if (isPagination) { - stateBlock.setAttribute( - "data-sot-panel", - "recording-list-pagination", - ); - stateBlock.classList.add("list-state-pagination"); - addClasses(stateBlock, ownerClasses.paginationRoot); - } else { - stateBlock.setAttribute( - "data-sot-part", - "recording-list-state", - ); - addClasses(stateBlock, ownerClasses.listStateRoot); - } - - stateBlock - .querySelector(".lsb-ico") - ?.setAttribute( - "data-sot-part", - "recording-list-state-icon", - ); - addClassesToAll( - '[data-sot-part="recording-list-state-icon"]', - ownerClasses.listStateIcon, - ); - stateBlock - .querySelector(".lsb-t") - ?.setAttribute( - "data-sot-part", - "recording-list-state-title", - ); - addClassesToAll( - '[data-sot-part="recording-list-state-title"]', - ownerClasses.listStateTitle, - ); - stateBlock - .querySelector(".lsb-h") - ?.setAttribute( - "data-sot-part", - "recording-list-state-description", - ); - addClassesToAll( - '[data-sot-part="recording-list-state-description"]', - ownerClasses.listStateDescription, - ); - const pageDivider = - stateBlock.querySelector( - ".lsb-page-divider", - ); - pageDivider?.setAttribute( - "data-sot-part", - "recording-list-page-divider", - ); - addClassesToAll( - '[data-sot-part="recording-list-page-divider"]', - ownerClasses.paginationDivider, - ); - pageDivider - ?.querySelector("span") - ?.setAttribute( - "data-sot-part", - "recording-list-page-status", - ); - addClassesToAll( - '[data-sot-part="recording-list-page-status"]', - ownerClasses.paginationStatus, - ); - stateBlock - .querySelector(".lsb-page-nav") - ?.setAttribute( - "data-sot-part", - "recording-list-page-nav", - ); - addClassesToAll( - '[data-sot-part="recording-list-page-nav"]', - ownerClasses.paginationNav, - ); - stateBlock - .querySelector(".lsb-page-num") - ?.setAttribute( - "data-sot-part", - "recording-list-page-number", - ); - addClassesToAll( - '[data-sot-part="recording-list-page-number"]', - ownerClasses.paginationNumber, - ); - - const stateButtons = stateBlock.querySelectorAll("button"); - for (const button of stateButtons) { - if (isPagination) { - addClasses(button, ownerClasses.paginationButton); - } else if (button.classList.contains("primary")) { - addClasses(button, ownerClasses.listStatePrimary); - } else { - addClasses(button, ownerClasses.listStateAction); - } - } - } - for (const button of stage.querySelectorAll( - '[data-slot="button"][data-variant="ghost"][data-size="sm"]', - )) { - button.className = "btn ghost btn-sm"; - button.removeAttribute("data-slot"); - button.removeAttribute("data-variant"); - button.removeAttribute("data-size"); - } - - host.appendChild(style); - host.appendChild(stage); - document.body.appendChild(host); - }, - { - blockHtml, - fixtureCss: LIST_STATE_BLOCK_MIGRATION_FIXTURE_CSS, - fixtureId, - ownerClasses: DASHBOARD_LIST_STATE_OWNER_CLASS_CONTRACT, - }, - ); - - const stage = page - .locator(`#${fixtureId} > .list-state-block-pixel-stage`) - .first(); - await expect(page.locator(`#${fixtureId} .list-state-block`)).toBeVisible(); - await page.waitForTimeout(250); - const screenshot = await stage.screenshot({ - animations: "disabled", - omitBackground: false, - scale: "css", - }); - await page.evaluate((id) => { - document.getElementById(id)?.remove(); - }, fixtureId); - - return { - dataUrl: `data:image/png;base64,${screenshot.toString("base64")}`, - screenshot, - }; -} - -async function captureListPanelFrameFixture( - page: Page, - panelHtml: string, - sourceAssetDataUrls: Record, - stageWidth: number, - stageHeight: number, - options: { - rowContentOffset?: { - x: number; - y: number; - }; - } = {}, -) { - const fixtureId = `sot-list-panel-frame-${Date.now()}-${Math.random() - .toString(16) - .slice(2)}`; - const rowContentOffset = options.rowContentOffset ?? { x: 0, y: 0 }; - - await installSotPixelDevOverlaySuppression(page, fixtureId); - await page.evaluate( - ({ - fixtureId: id, - migrationFixtureCss, - panelHtml: html, - rowContentOffset: offset, - sourceAssetDataUrls: assetDataUrls, - stageHeight: height, - stageWidth: width, - }) => { - document.getElementById(id)?.remove(); - document.documentElement.dataset.theme = "dark"; - document.body.removeAttribute("data-time-style"); - - const host = document.createElement("div"); - host.id = id; - host.style.position = "fixed"; - host.style.left = "0"; - host.style.top = "0"; - host.style.zIndex = "2147483647"; - host.style.pointerEvents = "none"; - host.style.background = "rgb(24, 29, 35)"; - - const stage = document.createElement("div"); - stage.className = - "workspace list-panel-frame-stage list-row-pixel-stage"; - stage.setAttribute("data-sot-panel", "dashboard-workspace"); - stage.style.background = "rgb(24, 29, 35)"; - stage.style.boxSizing = "border-box"; - stage.style.display = "flex"; - stage.style.height = `${height}px`; - stage.style.overflow = "hidden"; - stage.style.padding = "16px 20px 20px"; - stage.style.width = `${width}px`; - stage.innerHTML = html; - - const alignedContent = stage.querySelectorAll( - ".real-list", - ); - if (offset.x !== 0 || offset.y !== 0) { - alignedContent.forEach((element) => { - element.style.transform = `translate(${offset.x}px, ${offset.y}px)`; - element.style.transformOrigin = "top left"; - if (width <= 390 && offset.x !== 0) { - const rightEdgeInset = Math.abs(offset.x) * 2; - element.style.width = `calc(100% - ${rightEdgeInset}px)`; - element.style.maxWidth = `calc(100% - ${rightEdgeInset}px)`; - } - }); - } - - const style = document.createElement("style"); - style.setAttribute("data-list-panel-frame-fixture", id); - style.textContent = ` - ${migrationFixtureCss} - #${CSS.escape(id)} [data-sot-surface="dashboard-recording-list"][data-slot="card"] { - display: flex; - flex-direction: column; - flex: 1 1 auto; - height: 100%; - max-height: 100%; - min-height: 0; - gap: 0; - overflow: hidden; - border-radius: 16px; - border: 1px solid var(--glass-border); - background: var(--bg-elevated); - box-shadow: var(--shadow-sm); - } - #${CSS.escape(id)} [data-sot-surface="dashboard-recording-list"] [data-sot-part="dashboard-recording-list-content"][data-slot="card-content"] { - display: flex; - flex: 1 1 auto; - min-height: 0; - flex-direction: column; - overflow: hidden; - padding: 0; - } - #${CSS.escape(id)} [data-sot-part="dashboard-recording-list-header"] { - flex: none; - padding: 12px 12px 10px; - border-bottom: 1px solid var(--line-hairline); - background: transparent; - } - [data-theme="dark"] #${CSS.escape(id)} [data-sot-part="dashboard-recording-list-header"] { - background: transparent; - border-bottom-color: var(--glass-border-soft); - } - #${CSS.escape(id)} [data-sot-part="dashboard-recording-list-titlebar"] { - display: flex; - align-items: center; - gap: 10px; - } - #${CSS.escape(id)} [data-sot-part="dashboard-recording-list-title"] { - margin: 0; - color: var(--fg-primary); - font: 600 13px var(--font-sans); - } - #${CSS.escape(id)} [data-sot-part="dashboard-recording-list-count"] { - margin-left: auto; - color: var(--fg-tertiary); - font: 500 11.5px var(--font-mono); - } - #${CSS.escape(id)} [data-sot-list="dashboard-recording-list-scroll"] { - display: flex; - flex: 1 1 auto; - min-height: 0; - overflow: hidden; - } - #${CSS.escape(id)} [data-sot-panel="dashboard-recording-list-mode"] { - display: flex; - align-items: center; - gap: 10px; - margin-top: 8px; - } - #${CSS.escape(id)} [data-sot-part="dashboard-recording-list-mode-label"] { - display: inline-flex; - align-items: center; - gap: 6px; - color: var(--fg-secondary); - font: 600 12px var(--font-sans); - } - #${CSS.escape(id)} [data-sot-part="dashboard-recording-list-mode-count"] { - color: var(--fg-tertiary); - font: 500 11px var(--font-mono); - } - #${CSS.escape(id)} [data-sot-part="dashboard-recording-list-mode-segmented"] { - margin-left: auto; - } - #${CSS.escape(id)} .stack-strip { - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: center; - gap: 6px 8px; - row-gap: 6px; - min-width: 0; - padding: 8px 12px; - border-bottom: 1px solid var(--line-hairline); - background: var(--bg-recessed); - font: 500 11.5px var(--font-sans); - color: var(--fg-tertiary); - } - [data-theme="dark"] #${CSS.escape(id)} .stack-strip { - background: rgb(255 255 255 / .03); - border-bottom-color: var(--glass-border-soft); - } - #${CSS.escape(id)} .stack-strip .stack-from { - display: inline-flex; - align-items: baseline; - flex: 0 1 auto; - min-width: 0; - max-width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 22px; - } - #${CSS.escape(id)} .stack-strip .stack-from b { - white-space: nowrap; - font-weight: 700; - color: var(--fg-secondary); - } - #${CSS.escape(id)} .stack-strip .stack-sep { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - width: 10px; - height: 22px; - line-height: 1; - font-size: 13px; - color: var(--fg-disabled); - user-select: none; - } - #${CSS.escape(id)} .stack-strip .stack-chip { - display: inline-flex; - flex: 0 0 auto; - align-items: center; - gap: 6px; - height: 22px; - padding: 0 4px 0 6px; - border-radius: 999px; - background: var(--bg-elevated); - border: 1px solid var(--line-hairline); - font: 600 11.5px var(--font-sans); - color: var(--fg-primary); - white-space: nowrap; - line-height: 1; - } - [data-theme="dark"] #${CSS.escape(id)} .stack-strip .stack-chip { - background: rgb(255 255 255 / .06); - border-color: var(--glass-border); - } - #${CSS.escape(id)} .stack-strip .stack-chip .ico { - display: inline-flex; - flex: 0 0 14px; - width: 14px; - height: 14px; - border-radius: 4px; - align-items: center; - justify-content: center; - background: #fff; - border: 1px solid var(--line-hairline); - overflow: hidden; - } - #${CSS.escape(id)} .stack-strip .stack-chip .ico img { - display: block; - width: 14px; - height: 14px; - object-fit: contain; - } - #${CSS.escape(id)} .stack-strip .stack-chip .ico.cover img { - object-fit: cover; - } - #${CSS.escape(id)} .stack-strip .stack-chip [data-stack-label] { - white-space: nowrap; - } - #${CSS.escape(id)} .stack-strip .stack-chip .x { - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - display: inline-flex; - flex: 0 0 16px; - width: 16px; - height: 16px; - margin: 0; - padding: 0; - border: 0; - border-radius: 50%; - align-items: center; - justify-content: center; - background: transparent; - color: var(--fg-tertiary); - cursor: pointer; - font: 600 11px var(--font-sans); - } - #${CSS.escape(id)} .stack-strip .stack-chip .x svg { - display: block; - width: 11px; - height: 11px; - stroke: currentColor; - fill: none; - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; - } - #${CSS.escape(id)} .stack-strip .stack-info { - display: inline-flex; - align-items: center; - flex: 0 1 auto; - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 22px; - color: var(--fg-tertiary); - } - #${CSS.escape(id)} .stack-strip .stack-info b { - font-weight: 700; - color: var(--fg-secondary); - margin: 0 2px; - } - #${CSS.escape(id)} .liquid-tabs { - --idx: 0; - --n: 2; - position: relative; - display: grid; - grid-template-columns: repeat(var(--n), 1fr); - gap: 0; - padding: 4px; - border-radius: 12px; - background: var(--bg-recessed); - border: 1px solid color-mix(in srgb, var(--graphite-300) 60%, transparent); - box-shadow: inset 0 1px 2px rgb(20 22 28 / .04); - width: fit-content; - min-width: 220px; - } - [data-theme="dark"] #${CSS.escape(id)} .liquid-tabs { - background: rgb(255 255 255 / .04); - border-color: var(--glass-border-soft); - box-shadow: inset 0 0 0 .5px rgb(255 255 255 / .03); - } - #${CSS.escape(id)} .liquid-tabs.sm { - padding: 3px; - border-radius: 10px; - } - #${CSS.escape(id)} .lt-ind { - position: absolute; - display: block; - left: 4px; - top: 4px; - bottom: 4px; - width: calc((100% - 8px) / var(--n)); - border-radius: 9px; - background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 92%, white 18%), var(--accent)); - box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 22%, transparent), inset 0 1px 0 rgb(255 255 255 / .25); - transform: translateX(calc(var(--idx) * 100%)); - transition: transform 460ms var(--ease-out); - } - #${CSS.escape(id)} .liquid-tabs.sm .lt-ind { - left: 3px; - top: 3px; - bottom: 3px; - width: calc((100% - 6px) / var(--n)); - border-radius: 7px; - } - #${CSS.escape(id)} .lt-tab { - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - position: relative; - z-index: 1; - padding: 6px 14px; - margin: 0; - min-width: 80px; - font-family: var(--font-sans); - font-size: 12.5px; - font-weight: 600; - line-height: normal; - color: var(--fg-secondary); - background: transparent; - border: 0; - box-shadow: none; - cursor: pointer; - border-radius: 9px; - text-align: center; - text-transform: none; - transition: color 220ms var(--ease-out); - } - #${CSS.escape(id)} .lt-tab.active { - color: white; - } - #${CSS.escape(id)} .liquid-tabs[data-tabs="1"] { --n: 1; } - #${CSS.escape(id)} .liquid-tabs[data-tabs="2"] { --n: 2; } - #${CSS.escape(id)} .liquid-tabs[data-tabs="3"] { --n: 3; } - #${CSS.escape(id)} .liquid-tabs[data-tabs="4"] { --n: 4; } - #${CSS.escape(id)} .liquid-tabs[data-tabs="5"] { --n: 5; } - #${CSS.escape(id)} .liquid-tabs[data-tabs="6"] { --n: 6; } - #${CSS.escape(id)} .liquid-tabs[data-idx="0"] { --idx: 0; } - #${CSS.escape(id)} .liquid-tabs[data-idx="1"] { --idx: 1; } - #${CSS.escape(id)} .liquid-tabs[data-idx="2"] { --idx: 2; } - #${CSS.escape(id)} .liquid-tabs[data-idx="3"] { --idx: 3; } - #${CSS.escape(id)} .liquid-tabs[data-idx="4"] { --idx: 4; } - #${CSS.escape(id)} .liquid-tabs[data-idx="5"] { --idx: 5; } - #${CSS.escape(id)} [data-sot-panel="dashboard-recording-time-filter"][data-slot="toggle-group"] { - display: flex; - flex-wrap: wrap; - gap: 4px; - margin-top: 10px; - } - #${CSS.escape(id)} [data-sot-control="dashboard-recording-time-filter"][data-slot="toggle-group-item"] { - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - display: inline-flex; - align-items: center; - gap: 5px; - height: 22px; - margin: 0; - padding: 0 8px; - border-radius: 6px; - background: transparent; - border: 1px solid transparent; - box-shadow: none; - color: var(--fg-secondary); - cursor: pointer; - font: 500 11px var(--font-sans); - text-transform: none; - transition: - background var(--duration-fast) var(--ease-out), - color var(--duration-fast) var(--ease-out), - border-color var(--duration-fast) var(--ease-out); - } - #${CSS.escape(id)} [data-sot-control="dashboard-recording-time-filter"][data-sot-state="selected"] { - background: var(--bg-recessed); - border-color: var(--line-hairline); - color: var(--fg-primary); - box-shadow: var(--shadow-xs); - } - #${CSS.escape(id)} [data-sot-part="dashboard-recording-time-filter-count"] { - font: 500 10px var(--font-mono); - color: var(--fg-tertiary); - opacity: 0.7; - padding: 0 4px; - border-radius: 4px; - background: color-mix(in srgb, var(--graphite-300) 35%, transparent); - } - [data-theme="dark"] #${CSS.escape(id)} [data-sot-part="dashboard-recording-time-filter-count"] { - background: rgb(255 255 255 / 0.06); - } - #${CSS.escape(id)} [data-sot-control="dashboard-recording-time-filter"][data-sot-state="selected"] [data-sot-part="dashboard-recording-time-filter-count"] { - background: color-mix(in srgb, var(--accent) 22%, transparent); - color: var(--accent); - } - `; - const stackStrip = stage.querySelector(".stack-strip"); - if (stackStrip) { - stackStrip.style.display = "flex"; - } - - for (const image of stage.querySelectorAll("img")) { - const src = image.getAttribute("src"); - if (src && assetDataUrls[src]) { - image.setAttribute("src", assetDataUrls[src]); - } - } - - host.appendChild(style); - host.appendChild(stage); - document.body.appendChild(host); - }, - { - fixtureId, - migrationFixtureCss: LIST_ROW_MIGRATION_FIXTURE_CSS, - panelHtml, - rowContentOffset, - sourceAssetDataUrls, - stageHeight, - stageWidth, - }, - ); - - await waitForListRowFixtureImages(page, fixtureId); - const stage = page.locator(`#${fixtureId} > .list-panel-frame-stage`).first(); - await expect( - stage.locator( - '[data-sot-surface="dashboard-recording-list"], .list-panel', - ), - ).toBeVisible(); - await page.evaluate(() => document.fonts?.ready); - await page.waitForTimeout(250); - await page.evaluate(() => { - document.querySelectorAll("nextjs-portal").forEach((element) => { - element.remove(); - }); - }); - const metrics = await stage.evaluate((element) => { - const panel = element.querySelector( - '[data-sot-surface="dashboard-recording-list"]', - ); - const scroll = element.querySelector( - '[data-sot-list="dashboard-recording-list-scroll"]', - ); - const rows = Array.from( - element.querySelectorAll(".real-list .row"), - ); - const scrollRect = scroll?.getBoundingClientRect() ?? null; - const visibleRowCount = - scrollRect === null - ? 0 - : rows.filter((row) => { - const rect = row.getBoundingClientRect(); - return ( - rect.width > 0 && - rect.height > 0 && - rect.bottom > scrollRect.top && - rect.top < scrollRect.bottom - ); - }).length; - const roundedHeight = (node: HTMLElement | null) => - node - ? Math.round(node.getBoundingClientRect().height * 1000) / 1000 - : 0; - - return { - panelHeight: roundedHeight(panel), - recordingIds: rows.map( - (row) => - row.getAttribute("data-rec") ?? - row.getAttribute("data-sot-recording-id") ?? - "", - ), - rowCount: rows.length, - scrollClientHeight: scroll?.clientHeight ?? 0, - scrollHeight: scroll?.scrollHeight ?? 0, - visibleRowCount, - }; - }); - const screenshot = await stage.screenshot({ - animations: "disabled", - omitBackground: false, - scale: "css", - }); - await page.evaluate((id) => { - document.getElementById(id)?.remove(); - }, fixtureId); - await removeSotPixelDevOverlaySuppression(page, fixtureId); - - return { - dataUrl: `data:image/png;base64,${screenshot.toString("base64")}`, - metrics, - screenshot, - }; -} - -async function compareListRowPixels( - page: Page, - expected: string, - actual: string, -): Promise { - return page.evaluate( - async ({ actual: actualSrc, expected: expectedSrc }) => { - const loadImage = (src: string) => - new Promise((resolve, reject) => { - const image = new Image(); - image.onload = () => resolve(image); - image.onerror = () => - reject(new Error(`Failed to decode screenshot ${src}`)); - image.src = src; - }); - const [expectedImage, actualImage] = await Promise.all([ - loadImage(expectedSrc), - loadImage(actualSrc), - ]); - - if ( - expectedImage.naturalWidth !== actualImage.naturalWidth || - expectedImage.naturalHeight !== actualImage.naturalHeight - ) { - return { - bounds: null, - differingPixels: -1, - dimensionsMatch: false, - expectedHeight: expectedImage.naturalHeight, - expectedWidth: expectedImage.naturalWidth, - maxChannelDelta: -1, - productHeight: actualImage.naturalHeight, - productWidth: actualImage.naturalWidth, - }; - } - - const canvas = document.createElement("canvas"); - canvas.width = expectedImage.naturalWidth; - canvas.height = expectedImage.naturalHeight; - const context = canvas.getContext("2d", { - willReadFrequently: true, - }); - if (!context) { - throw new Error("Canvas 2D context unavailable"); - } - - context.drawImage(expectedImage, 0, 0); - const expectedData = context.getImageData( - 0, - 0, - canvas.width, - canvas.height, - ).data; - context.clearRect(0, 0, canvas.width, canvas.height); - context.drawImage(actualImage, 0, 0); - const actualData = context.getImageData( - 0, - 0, - canvas.width, - canvas.height, - ).data; - - let differingPixels = 0; - let maxChannelDelta = 0; - let minX = Number.POSITIVE_INFINITY; - let minY = Number.POSITIVE_INFINITY; - let maxX = -1; - let maxY = -1; - for (let index = 0; index < expectedData.length; index += 4) { - const pixelDelta = Math.max( - Math.abs(expectedData[index] - actualData[index]), - Math.abs(expectedData[index + 1] - actualData[index + 1]), - Math.abs(expectedData[index + 2] - actualData[index + 2]), - Math.abs(expectedData[index + 3] - actualData[index + 3]), - ); - if (pixelDelta > 0) { - const pixelIndex = index / 4; - const x = pixelIndex % canvas.width; - const y = Math.floor(pixelIndex / canvas.width); - differingPixels += 1; - maxChannelDelta = Math.max(maxChannelDelta, pixelDelta); - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - } - } - - return { - bounds: - differingPixels > 0 - ? { maxX, maxY, minX, minY } - : null, - differingPixels, - dimensionsMatch: true, - expectedHeight: expectedImage.naturalHeight, - expectedWidth: expectedImage.naturalWidth, - maxChannelDelta, - productHeight: actualImage.naturalHeight, - productWidth: actualImage.naturalWidth, - }; - }, - { actual, expected }, - ); -} - -function hasListPixelMismatch(diff: ListRowPixelDiff) { - return ( - !diff.dimensionsMatch || - diff.differingPixels > 0 || - diff.maxChannelDelta > 0 - ); -} - -async function renderListPixelDiffImage( - page: Page, - expected: string, - actual: string, -) { - const dataUrl = await page.evaluate( - async ({ actual: actualSrc, expected: expectedSrc }) => { - const loadImage = (src: string) => - new Promise((resolve, reject) => { - const image = new Image(); - image.onload = () => resolve(image); - image.onerror = () => - reject(new Error(`Failed to decode screenshot ${src}`)); - image.src = src; - }); - const [expectedImage, actualImage] = await Promise.all([ - loadImage(expectedSrc), - loadImage(actualSrc), - ]); - const width = Math.max( - expectedImage.naturalWidth, - actualImage.naturalWidth, - ); - const height = Math.max( - expectedImage.naturalHeight, - actualImage.naturalHeight, - ); - const readPixels = (image: HTMLImageElement) => { - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const context = canvas.getContext("2d", { - willReadFrequently: true, - }); - if (!context) { - throw new Error("Canvas 2D context unavailable"); - } - context.clearRect(0, 0, width, height); - context.drawImage(image, 0, 0); - return context.getImageData(0, 0, width, height).data; - }; - const expectedPixels = readPixels(expectedImage); - const actualPixels = readPixels(actualImage); - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const context = canvas.getContext("2d"); - if (!context) { - throw new Error("Canvas 2D context unavailable"); - } - const output = context.createImageData(width, height); - for (let index = 0; index < output.data.length; index += 4) { - const delta = Math.max( - Math.abs(expectedPixels[index] - actualPixels[index]), - Math.abs(expectedPixels[index + 1] - actualPixels[index + 1]), - Math.abs(expectedPixels[index + 2] - actualPixels[index + 2]), - Math.abs(expectedPixels[index + 3] - actualPixels[index + 3]), - ); - if (delta > 0) { - output.data[index] = 255; - output.data[index + 1] = 0; - output.data[index + 2] = Math.min(255, delta * 3); - output.data[index + 3] = 255; - continue; - } - const gray = Math.round( - (expectedPixels[index] + - expectedPixels[index + 1] + - expectedPixels[index + 2]) / - 12, - ); - output.data[index] = gray; - output.data[index + 1] = gray; - output.data[index + 2] = gray; - output.data[index + 3] = 110; - } - context.putImageData(output, 0, 0); - return canvas.toDataURL("image/png"); - }, - { actual, expected }, - ); - return Buffer.from(dataUrl.replace(/^data:image\/png;base64,/, ""), "base64"); -} - -async function persistListFrameDebugArtifacts({ - diff, - diffImage, - frameName, - productCapture, - sotCapture, - testInfo, -}: { - diff: ListRowPixelDiff; - diffImage: Buffer; - frameName: string; - productCapture: ListPanelFrameCapture; - sotCapture: ListPanelFrameCapture; - testInfo: TestInfo; -}) { - const baseName = `list-frame-${frameName}`; - const paths = { - diff: path.join(LIST_FRAME_DEBUG_DIR, `${baseName}-diff.png`), - json: path.join(LIST_FRAME_DEBUG_DIR, `${baseName}.json`), - product: path.join(LIST_FRAME_DEBUG_DIR, `${baseName}-product.png`), - sot: path.join(LIST_FRAME_DEBUG_DIR, `${baseName}-sot.png`), - }; - const payload = { - diff, - frameName, - paths, - product: productCapture.metrics, - sot: sotCapture.metrics, - }; - - await mkdir(LIST_FRAME_DEBUG_DIR, { recursive: true }); - await Promise.all([ - writeFile(paths.sot, sotCapture.screenshot), - writeFile(paths.product, productCapture.screenshot), - writeFile(paths.diff, diffImage), - writeFile(paths.json, `${JSON.stringify(payload, null, 2)}\n`), - ]); - - await testInfo.attach(`${baseName}-sot.png`, { - body: sotCapture.screenshot, - contentType: "image/png", - }); - await testInfo.attach(`${baseName}-product.png`, { - body: productCapture.screenshot, - contentType: "image/png", - }); - await testInfo.attach(`${baseName}-diff.png`, { - body: diffImage, - contentType: "image/png", - }); - await testInfo.attach(`${baseName}.json`, { - body: Buffer.from(JSON.stringify(payload, null, 2)), - contentType: "application/json", - }); - - return paths; -} - -async function expectListRowPixelMatch( - page: Page, - testInfo: TestInfo, - sotPage: Page, - state: ListRowSotState, - rowHtml: string, - sourceAssetDataUrls: Record, -) { - const sotCapture = await captureListRowFixture( - sotPage, - rowHtml, - sourceAssetDataUrls, - ); - const productCapture = await captureListRowFixture( - page, - rowHtml, - sourceAssetDataUrls, - ); - const diff = await compareListRowPixels( - page, - sotCapture.dataUrl, - productCapture.dataUrl, - ); - - if (hasListPixelMismatch(diff)) { - const name = `list-row-${state}` - .replace(/[^a-z0-9]+/gi, "-") - .replace(/^-|-$/g, "") - .toLowerCase(); - await testInfo.attach(`${name}-sot.png`, { - body: sotCapture.screenshot, - contentType: "image/png", - }); - await testInfo.attach(`${name}-product.png`, { - body: productCapture.screenshot, - contentType: "image/png", - }); - await testInfo.attach(`${name}-diff.json`, { - body: Buffer.from(JSON.stringify(diff, null, 2)), - contentType: "application/json", - }); - await testInfo.attach(`${name}-metrics.json`, { - body: Buffer.from( - JSON.stringify( - { - product: productCapture.metrics, - sot: sotCapture.metrics, - }, - null, - 2, - ), - ), - contentType: "application/json", - }); - } - - const diffLabel = `list-row ${state} ${JSON.stringify({ - diff, - product: productCapture.metrics, - sot: sotCapture.metrics, - })}`; - expect(diff.dimensionsMatch, diffLabel).toBe(true); - expect(diff.productHeight, diffLabel).toBe(diff.expectedHeight); - expect(diff.productWidth, diffLabel).toBe(diff.expectedWidth); - expect(diff.differingPixels, diffLabel).toBe(0); - expect(diff.maxChannelDelta, diffLabel).toBe(0); -} - -async function expectListSkeletonPixelMatch( - page: Page, - testInfo: TestInfo, - sotPage: Page, - skeletonHtml: string, -) { - const sotCapture = await captureListSkeletonFixture(sotPage, skeletonHtml); - const productCapture = await captureListSkeletonFixture(page, skeletonHtml); - const diff = await compareListRowPixels( - page, - sotCapture.dataUrl, - productCapture.dataUrl, - ); - - if ( - !diff.dimensionsMatch || - diff.differingPixels > - LIST_SKELETON_PIXEL_TOLERANCE.differingPixels || - diff.maxChannelDelta > LIST_SKELETON_PIXEL_TOLERANCE.maxChannelDelta - ) { - await testInfo.attach("list-skeleton-sot.png", { - body: sotCapture.screenshot, - contentType: "image/png", - }); - await testInfo.attach("list-skeleton-product.png", { - body: productCapture.screenshot, - contentType: "image/png", - }); - await testInfo.attach("list-skeleton-diff.json", { - body: Buffer.from(JSON.stringify(diff, null, 2)), - contentType: "application/json", - }); - } - - const diffLabel = `list-skeleton ${JSON.stringify(diff)}`; - expect(diff.dimensionsMatch, diffLabel).toBe(true); - expect(diff.productHeight, diffLabel).toBe(diff.expectedHeight); - expect(diff.productWidth, diffLabel).toBe(diff.expectedWidth); - expect(diff.differingPixels, diffLabel).toBeLessThanOrEqual( - LIST_SKELETON_PIXEL_TOLERANCE.differingPixels, - ); - expect(diff.maxChannelDelta, diffLabel).toBeLessThanOrEqual( - LIST_SKELETON_PIXEL_TOLERANCE.maxChannelDelta, - ); -} - -async function expectTagFilterTriggerPixelMatch( - page: Page, - testInfo: TestInfo, - sotPage: Page, - state: TagFilterTriggerSotState, - triggerHtml: string, -) { - const [sotCapture, productCapture] = await Promise.all([ - captureTagFilterTriggerFixture(sotPage, triggerHtml), - captureTagFilterTriggerFixture(page, triggerHtml), - ]); - const diff = await compareListRowPixels( - page, - sotCapture.dataUrl, - productCapture.dataUrl, - ); - - if ( - !diff.dimensionsMatch || - diff.differingPixels > 0 || - diff.maxChannelDelta > 0 - ) { - const name = `tag-filter-trigger-${state}`; - await testInfo.attach(`${name}-sot.png`, { - body: sotCapture.screenshot, - contentType: "image/png", - }); - await testInfo.attach(`${name}-product.png`, { - body: productCapture.screenshot, - contentType: "image/png", - }); - await testInfo.attach(`${name}-diff.json`, { - body: Buffer.from(JSON.stringify(diff, null, 2)), - contentType: "application/json", - }); - } - - const diffLabel = `tag-filter-trigger ${state} ${JSON.stringify(diff)}`; - expect(diff.dimensionsMatch, diffLabel).toBe(true); - expect(diff.productHeight, diffLabel).toBe(diff.expectedHeight); - expect(diff.productWidth, diffLabel).toBe(diff.expectedWidth); - expect(diff.differingPixels, diffLabel).toBe(0); - expect(diff.maxChannelDelta, diffLabel).toBe(0); -} - -async function expectOpenTagFilterPixelMatch( - page: Page, - testInfo: TestInfo, - sotPage: Page, - tagFilterHtml: string, -) { - const [sotCapture, productCapture] = await Promise.all([ - captureOpenTagFilterFixture(sotPage, tagFilterHtml), - captureOpenTagFilterFixture(page, tagFilterHtml), - ]); - const diff = await compareListRowPixels( - page, - sotCapture.dataUrl, - productCapture.dataUrl, - ); - - if ( - !diff.dimensionsMatch || - diff.differingPixels > 0 || - diff.maxChannelDelta > 0 - ) { - await testInfo.attach("tag-filter-open-sot.png", { - body: sotCapture.screenshot, - contentType: "image/png", - }); - await testInfo.attach("tag-filter-open-product.png", { - body: productCapture.screenshot, - contentType: "image/png", - }); - await testInfo.attach("tag-filter-open-diff.json", { - body: Buffer.from(JSON.stringify(diff, null, 2)), - contentType: "application/json", - }); - } - - const diffLabel = `tag-filter-open ${JSON.stringify(diff)}`; - expect(diff.dimensionsMatch, diffLabel).toBe(true); - expect(diff.productHeight, diffLabel).toBe(diff.expectedHeight); - expect(diff.productWidth, diffLabel).toBe(diff.expectedWidth); - expect(diff.differingPixels, diffLabel).toBe(0); - expect(diff.maxChannelDelta, diffLabel).toBe(0); -} - -async function expectListStateBlockPixelMatch( - page: Page, - testInfo: TestInfo, - sotPage: Page, - state: ListStateBlockSotState, - blockHtml: string, -) { - const [sotCapture, productCapture] = await Promise.all([ - captureListStateBlockFixture(sotPage, blockHtml), - captureListStateBlockFixture(page, blockHtml), - ]); - const diff = await compareListRowPixels( - page, - sotCapture.dataUrl, - productCapture.dataUrl, - ); - - if ( - !diff.dimensionsMatch || - diff.differingPixels > 0 || - diff.maxChannelDelta > 0 - ) { - const name = `list-state-block-${state}`; - await testInfo.attach(`${name}-sot.png`, { - body: sotCapture.screenshot, - contentType: "image/png", - }); - await testInfo.attach(`${name}-product.png`, { - body: productCapture.screenshot, - contentType: "image/png", - }); - await testInfo.attach(`${name}-diff.json`, { - body: Buffer.from(JSON.stringify(diff, null, 2)), - contentType: "application/json", - }); - } - - const diffLabel = `list-state-block ${state} ${JSON.stringify(diff)}`; - expect(diff.dimensionsMatch, diffLabel).toBe(true); - expect(diff.productHeight, diffLabel).toBe(diff.expectedHeight); - expect(diff.productWidth, diffLabel).toBe(diff.expectedWidth); - expect(diff.differingPixels, diffLabel).toBe(0); - expect(diff.maxChannelDelta, diffLabel).toBe(0); -} - -async function expectRuntimeListStateBlockPixelMatch( - page: Page, - testInfo: TestInfo, - sotPage: Page, - state: ListStateBlockSotState, - expectedBlockHtml: string, - actualBlockHtml: string, -) { - const [sotCapture, productCapture] = await Promise.all([ - captureListStateBlockFixture(sotPage, expectedBlockHtml), - captureListStateBlockFixture(page, actualBlockHtml), - ]); - const diff = await compareListRowPixels( - page, - sotCapture.dataUrl, - productCapture.dataUrl, - ); - - if ( - !diff.dimensionsMatch || - diff.differingPixels > 0 || - diff.maxChannelDelta > 0 - ) { - const name = `runtime-list-state-block-${state}`; - await testInfo.attach(`${name}-sot.png`, { - body: sotCapture.screenshot, - contentType: "image/png", - }); - await testInfo.attach(`${name}-product.png`, { - body: productCapture.screenshot, - contentType: "image/png", - }); - await testInfo.attach(`${name}-diff.json`, { - body: Buffer.from(JSON.stringify(diff, null, 2)), - contentType: "application/json", - }); - } - - const diffLabel = `runtime-list-state-block ${state} ${JSON.stringify(diff)}`; - expect(diff.dimensionsMatch, diffLabel).toBe(true); - expect(diff.productHeight, diffLabel).toBe(diff.expectedHeight); - expect(diff.productWidth, diffLabel).toBe(diff.expectedWidth); - expect(diff.differingPixels, diffLabel).toBe(0); - expect(diff.maxChannelDelta, diffLabel).toBe(0); -} - -async function readComputedStyle( - locator: Locator, - props: readonly ListStyleProp[], -) { - return locator.first().evaluate( - (element, propNames) => { - const style = window.getComputedStyle(element); - const colorProps = new Set([ - "background-color", - "border-top-color", - "color", - "outline-color", - ]); - const normalizeColor = (value: string) => { - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d"); - if (!context) return value; - context.fillStyle = "#000"; - context.fillStyle = value; - return context.fillStyle; - }; - const entries = Object.fromEntries( - propNames.map((prop) => { - const value = style.getPropertyValue(prop); - return [ - prop, - colorProps.has(prop) ? normalizeColor(value) : value, - ]; - }), - ); - if (entries["border-top-width"] === "0px") { - entries["border-top-style"] = "none"; - } - return entries; - }, - props, - ); -} - -async function expectComputedStyleMatch( - sotLocator: Locator, - productLocator: Locator, - props: readonly ListStyleProp[], -) { - const [sot, product] = await Promise.all([ - readComputedStyle(sotLocator, props), - readComputedStyle(productLocator, props), - ]); - - expect(product).toEqual(sot); -} - -async function expectShadcnFocusRingContract(locator: Locator) { - await expect(locator).toBeFocused(); - const className = await locator.evaluate( - (element) => element.getAttribute("class") ?? "", - ); - for (const token of LIST_ROW_SHADCN_FOCUS_CLASS_CONTRACT) { - expect(className).toContain(token); - } - - const focusStyle = await readComputedStyle(locator, LIST_ROW_STYLE_PROPS); - expect(focusStyle["outline-style"]).toBe("none"); -} - -async function switchRecordingListToTags(page: Page) { - const panel = recordingListPanel(page); - const tagsModeTab = panel.getByRole("tab", { - name: "标签", - exact: true, - }); - - await expect(panel).toHaveAttribute("data-sot-state", "ready"); - await expect(tagsModeTab).toBeVisible(); - - for (let attempt = 0; attempt < 3; attempt += 1) { - await tagsModeTab.click(); - if ( - await tagsModeTab - .getAttribute("aria-selected", { timeout: 1_000 }) - .then((selected) => selected === "true") - .catch(() => false) - ) { - return; - } - await page.waitForTimeout(250); - } - - await expect(panel).toHaveAttribute("data-sot-list-mode", "tags"); - await expect( - panel.locator('[data-list-filter-row="timeline"]'), - ).toBeHidden(); - await expect( - panel.locator('[data-sot-panel="recording-list-tag-filter"]'), - ).toBeVisible(); - await expect(panel.locator("[data-tag-filter-trigger]")).toHaveAttribute( - "aria-expanded", - "false", - ); -} - -async function selectTagFilter(page: Page, label: string) { - const panel = recordingListPanel(page); - const trigger = panel.locator("[data-tag-filter-trigger]"); - await trigger.click(); - await expect(trigger).toHaveAttribute("aria-expanded", "true"); - const list = panel.locator("[data-tag-filter-list]"); - await expect(list).toBeVisible(); - const option = list - .locator('[data-sot-control="recording-list-tag-filter"]') - .filter({ hasText: label }); - await option.click(); - await expect(trigger).toHaveAttribute("aria-expanded", "false"); - await expect(list).toBeHidden(); - await expect(trigger.locator("[data-tag-filter-label]")).toHaveText(label); -} - -async function selectTimelineFilter( - page: Page, - panel: Locator, - filter: string, - visibleCount: number, -) { - const trigger = page.locator( - `[data-sot-control="dashboard-recording-time-filter"][data-sot-filter="${filter}"]`, - ); - - for (let attempt = 0; attempt < 3; attempt += 1) { - await trigger.click(); - if ( - await panel - .locator( - '[data-sot-control="dashboard-recording-row"][data-sot-recording-id^="e2e-list-state-"]', - ) - .count() - .then((count) => count === visibleCount) - .catch(() => false) - ) { - return; - } - await page.waitForTimeout(250); - } - - await expect( - seededRecordingRows(panel), - ).toHaveCount(visibleCount); -} - -test("recording list paginates without leaking tweak controls across dark, light, and mobile states", async ({ - page, -}) => { - await mockConnectedDataSources(page); - await ensureSignedIn(page); - await resetDisplay(page, { itemsPerPage: 10, theme: "dark" }); - - const userId = await getPlaywrightUserId(); - await cleanupAllUserRecordings(userId); - await seedListRecordings(userId, 23); - - await page.setViewportSize({ width: 1366, height: 900 }); - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); - - const panel = recordingListPanel(page); - const pagination = page.locator( - '[data-sot-panel="recording-list-pagination"]', - ); - await expect(panel).toHaveAttribute("data-sot-state", "ready"); - await expect(pagination).toHaveAttribute( - "data-sot-state", - "paginated-first", - ); - await expect(panel).toContainText("已加载 10 / 23 条 · 第 1 页"); - await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); - await expectNoTweaksLeak(page); - - await expect(sotControl(page, "recording-list-prev-page")).toBeDisabled(); - await expect(sotControl(page, "recording-list-next-page")).toBeEnabled(); - await expect(sotControl(page, "recording-list-first-page")).toHaveCount(0); - await expect(sotControl(page, "recording-list-last-page")).toHaveCount(0); - await expect(sotControl(page, "recording-list-load-more")).toHaveCount(0); - await expect( - pagination.locator( - '[data-sot-part="recording-list-page-nav"] > [data-slot="button"]', - ), - ).toHaveCount(2); - await expect(pagination.locator("[data-page-prev]")).toHaveCount(1); - await expect(pagination.locator("[data-page-next]")).toHaveCount(1); - await expect( - pagination.locator('[data-sot-part="recording-list-page-number"]'), - ).toHaveText("1 / 3"); - await expect( - seededRecordingRows(panel), - ).toHaveCount(10); - await expect(recordingRow(page, "e2e-list-state-01")).toBeVisible(); - await expect(recordingRow(page, "e2e-list-state-11")).toHaveCount(0); - - await sotControl(page, "recording-list-next-page").click(); - await expect(pagination).toHaveAttribute("data-sot-state", "paginated"); - await expect(panel).toContainText( - "已加载 10 / 23 条 · 滚动加载下一批", - ); - await expect( - seededRecordingRows(panel), - ).toHaveCount(10); - await expect(sotControl(page, "recording-list-prev-page")).toBeEnabled(); - await expect(sotControl(page, "recording-list-next-page")).toBeEnabled(); - await expect(sotControl(page, "recording-list-load-more")).toBeVisible(); - await expect( - pagination.locator('[data-sot-part="recording-list-page-number"]'), - ).toHaveText("2 / 3"); - await expect(recordingRow(page, "e2e-list-state-11")).toBeVisible(); - await expect(recordingRow(page, "e2e-list-state-20")).toBeVisible(); - - await sotControl(page, "recording-list-next-page").click(); - await expect(pagination).toHaveAttribute( - "data-sot-state", - "paginated-last", - ); - await expect(panel).toContainText("已显示全部 23 / 23 条 · 末页"); - await expect( - seededRecordingRows(panel), - ).toHaveCount(3); - await expect(sotControl(page, "recording-list-next-page")).toBeDisabled(); - await expect(sotControl(page, "recording-list-load-more")).toHaveCount(0); - await expect( - pagination.locator('[data-sot-part="recording-list-page-number"]'), - ).toHaveText("3 / 3"); - await expect(recordingRow(page, "e2e-list-state-23")).toBeVisible(); - - await sotControl(page, "recording-list-prev-page").click(); - await expect(pagination).toHaveAttribute("data-sot-state", "paginated"); - await sotControl(page, "recording-list-prev-page").click(); - await expect(pagination).toHaveAttribute( - "data-sot-state", - "paginated-first", - ); - await expect(panel).toContainText("已加载 10 / 23 条 · 第 1 页"); - await expect(sotControl(page, "recording-list-prev-page")).toBeDisabled(); - - await page.setViewportSize({ width: 390, height: 844 }); - await expect(page.locator("#drawer-trigger")).toBeVisible(); - await expect(panel).toHaveAttribute("data-sot-state", "ready"); - await expect(pagination).toHaveAttribute( - "data-sot-state", - "paginated-first", - ); - await expectNoTweaksLeak(page); - - await resetDisplay(page, { itemsPerPage: 10, theme: "light" }); - await page.reload({ waitUntil: "domcontentloaded" }); - await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); - await expect(panel).toHaveAttribute("data-sot-state", "ready"); - await expect(pagination).toHaveAttribute( - "data-sot-state", - "paginated-first", - ); - await expectNoTweaksLeak(page); - - await cleanupListSeeds(userId); -}); - -test("recording list loading state restores the SOT skeleton list", async ({ - browser, - page, -}) => { - await mockConnectedDataSources(page); - await ensureSignedIn(page); - await resetDisplay(page); - - const userId = await getPlaywrightUserId(); - await seedListRecordings(userId, 4); - - let loadingContext: BrowserContext | null = null; - let loadingPage: Page | null = null; - let releaseDisplaySettings: (() => void) | null = null; - let displaySettingsGetCount = 0; - const displaySettingsGate = new Promise((resolve) => { - releaseDisplaySettings = resolve; - }); - - try { - const storageState = await page.context().storageState(); - loadingContext = await browser.newContext({ - storageState, - viewport: page.viewportSize() ?? undefined, - }); - await loadingContext.route("**/api/settings/display", async (route) => { - if (route.request().method() === "GET") { - displaySettingsGetCount += 1; - await displaySettingsGate; - } - await route.continue(); - }); - loadingPage = await loadingContext.newPage(); - await mockConnectedDataSources(loadingPage); - - await loadingPage.goto(new URL("/dashboard", page.url()).toString(), { - waitUntil: "domcontentloaded", - }); - await expect.poll(() => displaySettingsGetCount).toBeGreaterThan(0); - const panel = recordingListPanel(loadingPage); - await expect(panel).toHaveAttribute("data-sot-state", "loading"); - const skeleton = panel.locator( - '[data-sot-panel="recording-list-loading"]', - ); - await expect(skeleton).toBeVisible(); - await expect( - skeleton.locator('[data-sot-part="skeleton-day"]'), - ).toHaveCount(2); - await expect( - skeleton.locator('[data-sot-part="skeleton-row"]'), - ).toHaveCount(5); - await expect( - skeleton.locator('[data-sot-part="skeleton-title"]'), - ).toHaveCount(5); - await expect(skeleton.locator('[data-slot="skeleton"]')).toHaveCount( - 24, - ); - await expect( - panel.locator( - '[data-sot-list="dashboard-recording-list-scroll"] > [data-sot-part="recording-list-state"]', - ), - ).toHaveCount(0); - - const displaySettingsResponsePromise = loadingPage.waitForResponse( - (response) => - response.request().method() === "GET" && - response.url().includes("/api/settings/display"), - { timeout: 20_000 }, - ); - releaseDisplaySettings?.(); - const displaySettingsResponse = await displaySettingsResponsePromise; - expect(displaySettingsResponse.ok()).toBe(true); - await expect(panel).toHaveAttribute("data-sot-state", "ready", { - timeout: 20_000, - }); - await expect(skeleton).toHaveCount(0); - } finally { - releaseDisplaySettings?.(); - await loadingContext?.close(); - await cleanupListSeeds(userId); - } -}); - -test("recording list rows expose every SOT status badge variant", async ({ - page, -}) => { - await mockConnectedDataSources(page); - await ensureSignedIn(page); - await resetDisplay(page); - - const userId = await getPlaywrightUserId(); - try { - await seedRowStatusRecordings(userId); - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); - const panel = recordingListPanel(page); - await expect(panel).toHaveAttribute("data-sot-state", "ready"); - - for (const [id, tone, label] of [ - ["row-updated", "ok", "已更新"], - ["row-transcribing", "warn", "正在转写"], - ["row-failed", "err", "更新失败"], - ["row-local-only", "info", "仅本地"], - ["row-pending", "neu", "待处理"], - ] as const) { - const badge = recordingRow( - page, - `${LIST_RECORDING_PREFIX}${id}`, - ).locator('[data-sot-part="dashboard-recording-status"]'); - await expect(badge).toHaveAttribute("data-sot-tone", tone); - await expect( - badge.locator( - '[data-sot-part="dashboard-recording-status-dot"]', - ), - ).toBeVisible(); - await expect(badge).toContainText(label); - } - } finally { - await cleanupListSeeds(userId); - } -}); - -test("recording list item primitives match SOT component library styles", async ({ - page, -}) => { - const sotPage = await page.context().newPage(); - await mockConnectedDataSources(page); - await ensureSignedIn(page); - await resetDisplay(page); - - const userId = await getPlaywrightUserId(); - try { - await seedRowStatusRecordings(userId); - await openSotComponentLibrary(sotPage); - await applyListRowMigrationFixtureCss(sotPage); - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); - const panel = recordingListPanel(page); - await expect(panel).toHaveAttribute("data-sot-state", "ready"); - await expect(page.locator("html")).toHaveAttribute( - "data-theme", - "dark", - ); - - const sotListItem = sotPage.locator("#listitem"); - const sotBadge = sotPage.locator("#badge"); - const productUpdatedRow = recordingRow( - page, - `${LIST_RECORDING_PREFIX}row-updated`, - ); - const productTranscribingRow = recordingRow( - page, - `${LIST_RECORDING_PREFIX}row-transcribing`, - ); - const productFailedRow = recordingRow( - page, - `${LIST_RECORDING_PREFIX}row-failed`, - ); - const productLocalOnlyRow = recordingRow( - page, - `${LIST_RECORDING_PREFIX}row-local-only`, - ); - await expect(productUpdatedRow).toBeVisible(); - await expect(productUpdatedRow).toHaveAttribute( - "data-sot-state", - "selected", - ); - await expect(productUpdatedRow).toHaveAttribute( - "aria-current", - "true", - ); - const sotDefaultRow = sotListItem - .locator(".cl-card") - .nth(0) - .locator(".row"); - await expectComputedStyleMatch( - sotDefaultRow, - productTranscribingRow, - LIST_ROW_STYLE_PROPS, - ); - await expectComputedStyleMatch( - sotListItem.locator(".cl-card").nth(1).locator(".row.active"), - productUpdatedRow, - LIST_ROW_STYLE_PROPS, - ); - await sotDefaultRow.hover(); - await sotPage.waitForTimeout(250); - const sotHoverStyle = await readComputedStyle( - sotDefaultRow, - LIST_ROW_STYLE_PROPS, - ); - await productFailedRow.hover(); - await page.waitForTimeout(250); - const productHoverStyle = await readComputedStyle( - productFailedRow, - LIST_ROW_STYLE_PROPS, - ); - expect(productHoverStyle).toEqual(sotHoverStyle); - await productLocalOnlyRow.focus(); - await expectShadcnFocusRingContract(productLocalOnlyRow); - - for (const [id, selector, tone] of [ - ["row-updated", ".b.ok", "ok"], - ["row-transcribing", ".b.warn", "warn"], - ["row-failed", ".b.err", "err"], - ["row-local-only", ".b.info", "info"], - ["row-pending", ".b.neu", "neu"], - ] as const) { - await expectComputedStyleMatch( - sotBadge.locator(selector), - recordingRow(page, `${LIST_RECORDING_PREFIX}${id}`).locator( - `[data-sot-part="dashboard-recording-status"][data-sot-tone="${tone}"]`, - ), - LIST_BADGE_STYLE_PROPS, - ); - } - - await expectComputedStyleMatch( - sotBadge.locator(".utag.c-blue"), - productUpdatedRow.locator( - '[data-recording-tag-chip][data-sot-tag-color="blue"]', - ), - LIST_TAG_STYLE_PROPS, - ); - } finally { - await sotPage.close(); - await cleanupListSeeds(userId); - } -}); - -test("recording list row states match SOT web index pixels", async ({ - page, -}, testInfo) => { - const sotPage = await page.context().newPage(); - await ensureSignedIn(page); - await resetDisplay(page, { theme: "dark" }); - - try { - await openSotWorkstation(sotPage); - const rowHtmlByState = await readSotListRowHtml(sotPage); - const sourceAssetDataUrls = await readListRowSourceAssetDataUrls(); - await openSotCssOnlyWorkstation(sotPage); - - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); - await expect(page.locator("html")).toHaveAttribute( - "data-theme", - "dark", - ); - - for (const state of LIST_ROW_SOT_STATES) { - await expectListRowPixelMatch( - page, - testInfo, - sotPage, - state, - rowHtmlByState[state], - sourceAssetDataUrls, - ); - } - } finally { - await sotPage.close(); - } -}); - -test("recording list loading skeleton matches SOT web index pixels", async ({ - page, -}, testInfo) => { - const sotPage = await page.context().newPage(); - await ensureSignedIn(page); - await resetDisplay(page, { theme: "dark" }); - - try { - await openSotWorkstation(sotPage); - const skeletonHtml = await readSotListSkeletonHtml(sotPage); - - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); - await expect(page.locator("html")).toHaveAttribute( - "data-theme", - "dark", - ); - - await expectListSkeletonPixelMatch( - page, - testInfo, - sotPage, - skeletonHtml, - ); - } finally { - await sotPage.close(); - } -}); - -test("recording list tag filter triggers match SOT component-library pixels", async ({ - page, -}, testInfo) => { - const sotPage = await page.context().newPage(); - await ensureSignedIn(page); - await resetDisplay(page, { theme: "dark" }); - - try { - await openSotComponentLibrary(sotPage); - const triggerHtmlByState = await readSotTagFilterTriggerHtml(sotPage); - - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); - await expect(page.locator("html")).toHaveAttribute( - "data-theme", - "dark", - ); - - for (const state of TAG_FILTER_TRIGGER_SOT_STATES) { - await expectTagFilterTriggerPixelMatch( - page, - testInfo, - sotPage, - state, - triggerHtmlByState[state], - ); - } - } finally { - await sotPage.close(); - } -}); - -test("recording list open tag filter matches SOT web index pixels", async ({ - page, -}, testInfo) => { - const sotPage = await page.context().newPage(); - await ensureSignedIn(page); - await resetDisplay(page, { theme: "dark" }); - - try { - await openSotWorkstation(sotPage); - const tagFilterHtml = await readSotOpenTagFilterHtml(sotPage); - - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); - await expect(page.locator("html")).toHaveAttribute( - "data-theme", - "dark", - ); - - await expectOpenTagFilterPixelMatch( - page, - testInfo, - sotPage, - tagFilterHtml, - ); - } finally { - await sotPage.close(); - } -}); - -test("recording list state blocks match SOT web index pixels", async ({ - page, -}, testInfo) => { - const sotPage = await page.context().newPage(); - await ensureSignedIn(page); - await resetDisplay(page, { theme: "dark" }); - - try { - await openSotWorkstation(sotPage); - const blockHtmlByState = await readSotListStateBlockHtml(sotPage); - - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); - await expect(page.locator("html")).toHaveAttribute( - "data-theme", - "dark", - ); - - for (const state of LIST_STATE_BLOCK_SOT_STATES) { - await expectListStateBlockPixelMatch( - page, - testInfo, - sotPage, - state, - blockHtmlByState[state], - ); - } - } finally { - await sotPage.close(); - } -}); - -test("recording list runtime pagination matches SOT web index pixels", async ({ - page, -}, testInfo) => { - const sotPage = await page.context().newPage(); - await mockConnectedDataSources(page); - await ensureSignedIn(page); - await resetDisplay(page, { itemsPerPage: 50, theme: "dark" }); - - const userId = await getPlaywrightUserId(); - try { - await cleanupAllUserRecordings(userId); - await seedListRecordings(userId, 247); - await openSotWorkstation(sotPage); - const sotBlocks = await readSotListStateBlockHtml(sotPage); - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); - await expect(page.locator("html")).toHaveAttribute( - "data-theme", - "dark", - ); - - const pagination = page.locator( - '[data-sot-panel="recording-list-pagination"]', - ); - await expect(pagination).toHaveAttribute( - "data-list-state-block", - "paginated-first", - ); - await expectRuntimeListStateBlockPixelMatch( - page, - testInfo, - sotPage, - "paginated-first", - sotBlocks["paginated-first"], - await pagination.evaluate((element) => element.outerHTML), - ); - - await sotControl(page, "recording-list-next-page").click(); - await expect(pagination).toHaveAttribute( - "data-list-state-block", - "paginated", - ); - await expectRuntimeListStateBlockPixelMatch( - page, - testInfo, - sotPage, - "paginated", - sotBlocks.paginated, - await pagination.evaluate((element) => element.outerHTML), - ); - - await sotControl(page, "recording-list-next-page").click(); - await sotControl(page, "recording-list-next-page").click(); - await sotControl(page, "recording-list-next-page").click(); - await expect(pagination).toHaveAttribute( - "data-list-state-block", - "paginated-last", - ); - await expectRuntimeListStateBlockPixelMatch( - page, - testInfo, - sotPage, - "paginated-last", - sotBlocks["paginated-last"], - await pagination.evaluate((element) => element.outerHTML), - ); - } finally { - await cleanupListSeeds(userId); - await sotPage.close(); - } -}); - -test("recording list responsive frames match SOT web index pixels", async ({ - page, -}, testInfo) => { - const sotPage = await page.context().newPage(); - await mockConnectedDataSources(page); - await ensureSignedIn(page); - await resetDisplay(page, { theme: "dark" }); - - try { - await openSotWorkstation(sotPage); - const [panelHtml, sourceAssetDataUrls] = await Promise.all([ - readSotListPanelHtml(sotPage), - readListRowSourceAssetDataUrls(), - ]); - await openSotCssOnlyWorkstation(sotPage); - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); - - for (const frame of [ - { - name: "desktop", - viewport: { width: 1366, height: 900 }, - stageWidth: 420, - }, - { - name: "mobile", - viewport: { width: 390, height: 844 }, - stageWidth: 390, - }, - ] as const) { - await Promise.all([ - page.setViewportSize(frame.viewport), - sotPage.setViewportSize(frame.viewport), - ]); - const sotCapture = await captureListPanelFrameFixture( - sotPage, - panelHtml, - sourceAssetDataUrls, - frame.stageWidth, - frame.viewport.height, - ); - const productCapture = await captureListPanelFrameFixture( - page, - panelHtml, - sourceAssetDataUrls, - frame.stageWidth, - frame.viewport.height, - { - rowContentOffset: LIST_PANEL_FRAME_PRODUCT_ROW_CONTENT_OFFSET, - }, - ); - const diff = await compareListRowPixels( - page, - sotCapture.dataUrl, - productCapture.dataUrl, - ); - const diffBudget = - frame.name === "desktop" - ? LIST_PANEL_FRAME_DESKTOP_DIFF_BUDGET - : { differingPixels: 0, maxChannelDelta: 0 }; - - if ( - !diff.dimensionsMatch || - diff.differingPixels > diffBudget.differingPixels || - diff.maxChannelDelta > diffBudget.maxChannelDelta - ) { - const diffImage = await renderListPixelDiffImage( - page, - sotCapture.dataUrl, - productCapture.dataUrl, - ); - await persistListFrameDebugArtifacts({ - diff, - diffImage, - frameName: frame.name, - productCapture, - sotCapture, - testInfo, - }); - } - - const diffLabel = `list-frame ${frame.name} ${JSON.stringify(diff)}`; - for (const recordingId of LIST_PANEL_FRAME_REQUIRED_RECORDING_IDS) { - expect(sotCapture.metrics.recordingIds, diffLabel).toContain( - recordingId, - ); - expect(productCapture.metrics.recordingIds, diffLabel).toContain( - recordingId, - ); - } - expect(sotCapture.metrics.rowCount, diffLabel).toBeGreaterThanOrEqual( - LIST_PANEL_FRAME_REQUIRED_RECORDING_IDS.length, - ); - expect(productCapture.metrics.rowCount, diffLabel).toBe( - sotCapture.metrics.rowCount, - ); - expect( - sotCapture.metrics.visibleRowCount, - diffLabel, - ).toBeGreaterThan(0); - expect(productCapture.metrics.visibleRowCount, diffLabel).toBe( - sotCapture.metrics.visibleRowCount, - ); - expect(diff.dimensionsMatch, diffLabel).toBe(true); - expect(diff.expectedHeight, diffLabel).toBe(frame.viewport.height); - expect(diff.productHeight, diffLabel).toBe(diff.expectedHeight); - expect(diff.productWidth, diffLabel).toBe(diff.expectedWidth); - expect(diff.differingPixels, diffLabel).toBeLessThanOrEqual( - diffBudget.differingPixels, - ); - expect(diff.maxChannelDelta, diffLabel).toBeLessThanOrEqual( - diffBudget.maxChannelDelta, - ); - } - } finally { - await sotPage.close(); - } -}); - -test("recording list recovers from stale inner timeline filters after source changes", async ({ - page, -}) => { - await mockConnectedDataSources(page); - await ensureSignedIn(page); - await resetDisplay(page); - - const userId = await getPlaywrightUserId(); - await seedTimelineFilterRecordings(userId); - - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); - - const panel = recordingListPanel(page); - const todayRecording = recordingRow(page, "e2e-list-state-today-ticnote"); - const earlierRecording = recordingRow(page, "e2e-list-state-earlier-plaud"); - await expect(panel).toHaveAttribute("data-sot-state", "ready"); - await expect(todayRecording).toBeVisible(); - await expect(earlierRecording).toBeVisible(); - - await selectTimelineFilter(page, panel, "today", 1); - await expect(todayRecording).toBeVisible(); - await expect(earlierRecording).toHaveCount(0); - - const plaudRow = sourceProvider(page, "plaud"); - await expect(plaudRow).toHaveAttribute("data-sot-status", "connected"); - await plaudRow.click(); - await expect(panel).toHaveAttribute("data-sot-state", "timeline-empty"); - await expect(listStateBlock(panel, "timeline-empty")).toBeVisible(); - - await expect(sotControl(page, "recording-list-clear-timeline")).toBeVisible(); - await sotControl(page, "recording-list-clear-timeline").click(); - await expect(panel).toHaveAttribute("data-sot-state", "ready"); - await expect(earlierRecording).toBeVisible(); - await expect(todayRecording).toHaveCount(0); - - await cleanupListSeeds(userId); -}); - -test("recording list exposes empty setup and no-match recovery states", async ({ - page, -}) => { - await mockConnectedDataSources(page); - await ensureSignedIn(page); - await resetDisplay(page); - - const userId = await getPlaywrightUserId(); - await cleanupAllUserRecordings(userId); - - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); - - const panel = recordingListPanel(page); - await expect(panel).toHaveAttribute("data-sot-state", "empty"); - await expect( - page.locator('[data-sot-panel="recording-list-pagination"]'), - ).toHaveCount(0); - await expect(listStateBlock(panel, "empty")).toBeVisible(); - await expect( - sotControl(page, "recording-list-open-data-sources"), - ).toBeVisible(); - await sotControl(page, "recording-list-open-data-sources").click(); - await expect(page.locator('[data-sot-surface="settings-shell"]')).toBeVisible(); - await expect(page.locator('[data-sot-surface="settings-shell"]')).toHaveAttribute( - "data-sot-section", - "data-sources", - ); - await sotControl(page, "settings-close").click(); - - await seedListRecordings(userId); - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); - await expect(panel).toHaveAttribute("data-sot-state", "ready"); - - await switchRecordingListToTags(page); - await expect(panel).toHaveAttribute("data-sot-state", "tag-empty"); - await expect(listStateBlock(panel, "tag-empty")).toBeVisible(); - await expect(sotControl(page, "recording-list-clear-tag")).toBeVisible(); - await panel.getByRole("tab", { name: "时间", exact: true }).click(); - await expect(panel).toHaveAttribute("data-sot-state", "ready"); - - const plaudRow = sourceProvider(page, "plaud"); - await expect(plaudRow).toHaveAttribute( - "data-sot-status", - "connected-empty", - ); - await plaudRow.click(); - await expect(plaudRow).toHaveAttribute("aria-pressed", "true"); - await expect(plaudRow).toHaveAttribute("data-active", "true"); - await expect(plaudRow).toHaveAttribute( - "data-sot-state", - "connected-active", - ); - await expect(panel).toHaveAttribute("data-sot-state", "no-match"); - await expect(listStateBlock(panel, "no-match")).toBeVisible(); - - await expect(sotControl(page, "recording-list-clear-filters")).toBeVisible(); - await sotControl(page, "recording-list-clear-filters").click(); - await expect(panel).toHaveAttribute("data-sot-state", "ready"); - await expect(plaudRow).toHaveAttribute("aria-pressed", "false"); - await expect(plaudRow).toHaveAttribute("data-active", "false"); - await expect(plaudRow).toHaveAttribute( - "data-sot-state", - "connected-idle", - ); - - await cleanupListSeeds(userId); -}); - -test("recording list tags mode keeps multi-tag recordings in every matching group", async ({ - page, -}) => { - await mockConnectedDataSources(page); - await ensureSignedIn(page); - await resetDisplay(page); - - const userId = await getPlaywrightUserId(); - await seedMultiTagRecordings(userId); - - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); - await switchRecordingListToTags(page); - - const panel = recordingListPanel(page); - const tagTrigger = panel.locator("[data-tag-filter-trigger]"); - await expect( - panel.locator('[data-sot-panel="dashboard-recording-time-filter"]'), - ).toBeHidden(); - await expect(tagTrigger.locator("[data-tag-filter-label]")).toHaveText( - "全部", - ); - await expect(tagTrigger.locator("[data-tag-filter-count]")).toHaveText("1"); - await tagTrigger.click(); - const tagList = panel.locator("[data-tag-filter-list]"); - await expect(tagList).toBeVisible(); - await expect( - tagList.locator('[data-tag-value="tag:e2e-list-state-tag-alpha"]'), - ).toHaveAttribute("role", "option"); - await expect( - tagList.locator('[data-tag-value="tag:e2e-list-state-tag-beta"]'), - ).toHaveAttribute("aria-selected", "false"); - await page.keyboard.press("Escape"); - await expect(tagList).toBeHidden(); - - const alphaGroup = page.locator( - '[data-sot-group-id="e2e-list-state-tag-alpha"]', - ); - const betaGroup = page.locator( - '[data-sot-group-id="e2e-list-state-tag-beta"]', - ); - await expect(alphaGroup).toContainText("Alpha"); - await expect(betaGroup).toContainText("Beta"); - const alphaRow = alphaGroup.locator( - '[data-sot-recording-id="e2e-list-state-multi-tag"]', - ); - const betaRow = betaGroup.locator( - '[data-sot-recording-id="e2e-list-state-multi-tag"]', - ); - await expect( - alphaRow, - ).toBeVisible(); - await expect(alphaRow).toHaveAttribute( - "data-rec", - "e2e-list-state-multi-tag", - ); - await expect( - betaRow, - ).toBeVisible(); - const alphaTag = alphaRow.locator( - '[data-sot-part="dashboard-recording-row-actions"] [data-recording-tag-chip]', - ); - const betaTag = betaRow.locator( - '[data-sot-part="dashboard-recording-row-actions"] [data-recording-tag-chip]', - ); - await expect(alphaTag).toHaveAttribute("data-sot-tag-color", "blue"); - await expect(alphaTag).toHaveAttribute("data-sot-tag-icon", "tag"); - await expect(alphaTag).toContainText("Alpha"); - await expect(betaTag).toHaveAttribute("data-sot-tag-color", "purple"); - await expect(betaTag).toHaveAttribute("data-sot-tag-icon", "star"); - await expect(betaTag).toContainText("Beta"); - - await selectTagFilter(page, "Beta"); - - await expect(betaGroup).toContainText("Beta"); - await expect(alphaGroup).toHaveCount(0); - await expect( - betaGroup.locator('[data-sot-recording-id="e2e-list-state-multi-tag"]'), - ).toBeVisible(); - await expect(betaTag).toHaveAttribute("data-sot-tag-icon", "star"); - await tagTrigger.click(); - await expect( - tagList.locator('[data-tag-value="tag:e2e-list-state-tag-beta"]'), - ).toHaveAttribute("aria-selected", "true"); - await page.keyboard.press("Escape"); - - await cleanupListSeeds(userId); -}); - -test("recording list renders the full SOT tag color and icon matrix", async ({ - page, -}) => { - const sotPage = await page.context().newPage(); - await mockConnectedDataSources(page); - await ensureSignedIn(page); - await resetDisplay(page, { itemsPerPage: 20, theme: "dark" }); - - const userId = await getPlaywrightUserId(); - try { - await seedTagMatrixRecordings(userId); - const sotIconSignatures = - await readSotComponentTagIconSignatures(sotPage); - - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); - await expect(page.locator("html")).toHaveAttribute( - "data-theme", - "dark", - ); - - for (const [index, icon] of SOT_TAG_ICON_MATRIX.entries()) { - const recordingId = `${LIST_RECORDING_PREFIX}tag-matrix-${String(index + 1).padStart(2, "0")}`; - const color = - SOT_TAG_COLOR_MATRIX[index % SOT_TAG_COLOR_MATRIX.length]; - const chip = recordingRow(page, recordingId).locator( - '[data-sot-part="dashboard-recording-row-actions"] [data-recording-tag-chip]', - ); - - await expect(chip).toHaveAttribute("data-sot-tag-color", color.color); - await expect(chip).toHaveAttribute("data-sot-tag-icon", icon); - await expect(chip.locator("svg")).toHaveCount(1); - expect(await readSvgChildSignature(chip)).toEqual( - sotIconSignatures[icon], - ); - } - } finally { - await cleanupListSeeds(userId); - await sotPage.close(); - } -}); - -test("recording list follows display language for empty and pagination copy", async ({ - page, -}) => { - await mockConnectedDataSources(page); - await ensureSignedIn(page); - await resetDisplay(page, { itemsPerPage: 10, uiLanguage: "en" }); - - const userId = await getPlaywrightUserId(); - try { - await cleanupAllUserRecordings(userId); - await seedListRecordings(userId, 23); - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); - - await expect(page.getByText("Timeline", { exact: true })).toBeVisible(); - await expect(sotControl(page, "recording-list-prev-page")).toHaveText( - "Previous", - ); - await expect(sotControl(page, "recording-list-next-page")).toHaveText( - "Next", - ); - await expect(sotControl(page, "recording-list-first-page")).toHaveCount( - 0, - ); - await expect(sotControl(page, "recording-list-last-page")).toHaveCount( - 0, - ); - await expect( - recordingListPanel(page).locator( - '[data-sot-part="recording-list-page-status"]', - ), - ).toContainText("Loaded 10 / 23 items · Page 1"); - - await cleanupAllUserRecordings(userId); - await page.reload({ waitUntil: "domcontentloaded" }); - - await expect(listStateBlock(recordingListPanel(page), "empty")).toContainText( - "No recordings yet", - ); - await expect( - page.getByRole("button", { name: "Open data sources" }), - ).toBeVisible(); - } finally { - await resetDisplay(page); - await cleanupListSeeds(userId); - } -}); diff --git a/e2e/dashboard-manual-sync-refresh.spec.ts b/e2e/dashboard-manual-sync-refresh.spec.ts index 02982a37..3e102c11 100644 --- a/e2e/dashboard-manual-sync-refresh.spec.ts +++ b/e2e/dashboard-manual-sync-refresh.spec.ts @@ -186,14 +186,8 @@ async function seedManualSyncRecording(input: { } } -function dashboardSyncPanel(page: Page) { - return page.locator('[data-panel="dashboard-sync"]'); -} - function dashboardSyncButton(page: Page) { - return dashboardSyncPanel(page).locator( - 'button[data-control="dashboard-sync"]', - ); + return page.getByRole("button", { name: "同步", exact: true }); } function dashboardRecordingRow(page: Page, recordingId: string) { @@ -482,10 +476,12 @@ test("manual dashboard sync uses the real POST and renders real failed, queued, page.locator('[data-surface="dashboard-workstation"]'), ).toHaveAttribute("data-state", "ready"); - const syncPanel = dashboardSyncPanel(page); const syncButton = dashboardSyncButton(page); - await expect(syncPanel).toHaveAttribute("data-state", "idle"); + await expect(syncButton).toHaveAccessibleDescription( + /更新 · BetterAINote/, + ); await expect(syncButton).toBeEnabled(); + await expect(syncButton).toHaveAttribute("aria-busy", "false"); const postResponse = page.waitForResponse( (response) => @@ -499,7 +495,9 @@ test("manual dashboard sync uses the real POST and renders real failed, queued, code: "INVALID_INPUT", error: "No data source configured", }); - await expect(syncPanel).toHaveAttribute("data-state", "error"); + await expect(syncButton).toHaveAccessibleDescription( + /重试 · BetterAINote/, + ); await expect(syncButton).toBeEnabled(); await expect(syncButton).toHaveAttribute("aria-busy", "false"); @@ -513,11 +511,17 @@ test("manual dashboard sync uses the real POST and renders real failed, queued, lastSummary: { errorCount: 1 }, }, }); - await expect(syncPanel).toHaveAttribute("data-state", "error"); + await expect(syncButton).toHaveAccessibleDescription( + /重试 · BetterAINote/, + ); await expect(syncButton).toBeEnabled(); await expect(syncButton).toHaveAttribute("aria-busy", "false"); await seedSyncWorkerState(userId, "queued"); + expect(await snapshotSyncWorkerState(userId)).toMatchObject({ + isRunning: 0, + manualTriggerRequestedAt: expect.any(Number), + }); const queuedStatus = await reloadDashboardWithRealSyncStatus(page); expect(queuedStatus).toMatchObject({ workerStatus: { @@ -526,11 +530,24 @@ test("manual dashboard sync uses the real POST and renders real failed, queued, manualTriggerRequestedAt: expect.any(String), }, }); - await expect(syncPanel).toHaveAttribute("data-state", "queued"); + await expect(syncButton).toHaveAccessibleDescription( + /已加入更新 · BetterAINote/, + ); + await expect(syncButton).toBeDisabled(); + await expect(syncButton).toHaveAttribute("aria-busy", "true"); + + await reloadDashboardWithRealSyncStatus(page); + await expect(syncButton).toHaveAccessibleDescription( + /已加入更新 · BetterAINote/, + ); await expect(syncButton).toBeDisabled(); await expect(syncButton).toHaveAttribute("aria-busy", "true"); await seedSyncWorkerState(userId, "running"); + expect(await snapshotSyncWorkerState(userId)).toMatchObject({ + isRunning: 1, + manualTriggerRequestedAt: null, + }); const runningStatus = await reloadDashboardWithRealSyncStatus(page); expect(runningStatus).toMatchObject({ workerStatus: { @@ -539,9 +556,32 @@ test("manual dashboard sync uses the real POST and renders real failed, queued, manualTriggerRequestedAt: null, }, }); - await expect(syncPanel).toHaveAttribute("data-state", "running"); + await expect(syncButton).toHaveAccessibleDescription( + /更新中 · BetterAINote/, + ); await expect(syncButton).toBeDisabled(); await expect(syncButton).toHaveAttribute("aria-busy", "true"); + + await seedSyncWorkerState(userId, "success"); + expect(await snapshotSyncWorkerState(userId)).toMatchObject({ + isRunning: 0, + manualTriggerRequestedAt: null, + }); + const completedStatus = + await reloadDashboardWithRealSyncStatus(page); + expect(completedStatus).toMatchObject({ + workerStatus: { + isRunning: false, + manualTriggerRequestedAt: null, + lastError: null, + lastSummary: { errorCount: 0 }, + }, + }); + await expect(syncButton).toHaveAccessibleDescription( + /更新 · BetterAINote/, + ); + await expect(syncButton).toBeEnabled(); + await expect(syncButton).toHaveAttribute("aria-busy", "false"); } finally { if (userId) { await restoreSyncWorkerState(userId, workerState); diff --git a/e2e/dashboard-segmented-tabs-keyboard.spec.ts b/e2e/dashboard-segmented-tabs-keyboard.spec.ts new file mode 100644 index 00000000..9e766834 --- /dev/null +++ b/e2e/dashboard-segmented-tabs-keyboard.spec.ts @@ -0,0 +1,392 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createClient } from "@libsql/client"; +import { expect, test } from "@playwright/test"; +import { ensureSignedIn } from "./helpers/auth"; + +const E2E_DATA_DIR = path.resolve(process.cwd(), "tmp/e2e/data"); +const PLAYWRIGHT_USER_EMAIL = "playwright-admin@example.com"; +const TAB_RECORDING_ID = "e2e-segmented-tabs-keyboard"; +const TAB_RECORDING_TITLE = "E2E segmented tabs keyboard"; +const TAB_TRANSCRIPTION_ID = "e2e-segmented-tabs-keyboard-transcription"; +const TAB_SPEAKER_PROFILE_ID = "e2e-segmented-tabs-keyboard-speaker"; +const TAB_SPEAKER_LABEL = "E2E Speaker"; +const TAB_TRANSCRIPT_TEXT = "Keyboard activation proves real transcript detail."; + +function resolveDatabasePath() { + return process.env.DATABASE_PATH + ? path.resolve(process.cwd(), process.env.DATABASE_PATH) + : path.join(E2E_DATA_DIR, "betterainote-e2e.db"); +} + +function deriveSiblingDatabasePath(databasePath: string, suffix: string) { + const parsed = path.parse(databasePath); + return path.resolve( + parsed.dir || ".", + `${parsed.name || "betterainote"}-${suffix}${parsed.ext || ".db"}`, + ); +} + +function assertE2EDatabasePath(filePath: string) { + const e2eRoot = path.resolve( + process.env.PLAYWRIGHT_E2E_ROOT ?? + path.join(process.cwd(), "tmp/e2e"), + ); + const resolvedPath = path.resolve(filePath); + + if ( + resolvedPath !== e2eRoot && + !resolvedPath.startsWith(`${e2eRoot}${path.sep}`) + ) { + throw new Error( + `Refusing to touch non-E2E database path: ${resolvedPath}`, + ); + } +} + +function databaseUrl(filePath: string) { + assertE2EDatabasePath(filePath); + return pathToFileURL(filePath).href; +} + +async function executeWithBusyRetry( + operation: () => Promise, + attempts = 8, +): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return await operation(); + } catch (error) { + lastError = error; + if ( + !(error instanceof Error) || + !error.message.includes("SQLITE_BUSY") || + attempt === attempts - 1 + ) { + throw error; + } + + await new Promise((resolve) => + setTimeout(resolve, 80 * (attempt + 1)), + ); + } + } + + throw lastError; +} + +function contentHash(value: string) { + return createHash("sha256").update(value).digest("hex"); +} + +const CORE_DB = resolveDatabasePath(); +const LIBRARY_DB = deriveSiblingDatabasePath(CORE_DB, "library"); +const TRANSCRIPTS_DB = deriveSiblingDatabasePath(CORE_DB, "transcripts"); +const VOICEPRINTS_DB = deriveSiblingDatabasePath(CORE_DB, "voiceprints"); + +async function getPlaywrightUserId() { + const client = createClient({ url: databaseUrl(CORE_DB) }); + try { + const result = await executeWithBusyRetry(() => + client.execute({ + sql: "SELECT id FROM `users` WHERE email = ? LIMIT 1", + args: [PLAYWRIGHT_USER_EMAIL], + }), + ); + const id = result.rows[0]?.id; + if (typeof id !== "string") { + throw new Error("Playwright user not found"); + } + return id; + } finally { + await client.close(); + } +} + +async function cleanupSeededRecording() { + const library = createClient({ url: databaseUrl(LIBRARY_DB) }); + const transcripts = createClient({ url: databaseUrl(TRANSCRIPTS_DB) }); + const voiceprints = createClient({ url: databaseUrl(VOICEPRINTS_DB) }); + + try { + await executeWithBusyRetry(() => + voiceprints.execute({ + sql: "DELETE FROM recording_speakers WHERE recording_id = ?", + args: [TAB_RECORDING_ID], + }), + ); + await executeWithBusyRetry(() => + voiceprints.execute({ + sql: "DELETE FROM speaker_profiles WHERE id = ?", + args: [TAB_SPEAKER_PROFILE_ID], + }), + ); + await executeWithBusyRetry(() => + transcripts.execute({ + sql: "DELETE FROM transcript_segments WHERE recording_id = ?", + args: [TAB_RECORDING_ID], + }), + ); + await executeWithBusyRetry(() => + transcripts.execute({ + sql: "DELETE FROM transcriptions WHERE recording_id = ?", + args: [TAB_RECORDING_ID], + }), + ); + await executeWithBusyRetry(() => + library.execute({ + sql: "DELETE FROM transcription_jobs WHERE recording_id = ?", + args: [TAB_RECORDING_ID], + }), + ); + await executeWithBusyRetry(() => + library.execute({ + sql: "DELETE FROM recordings WHERE id = ?", + args: [TAB_RECORDING_ID], + }), + ); + } finally { + await library.close(); + await transcripts.close(); + await voiceprints.close(); + } +} + +async function seedDashboardRecording(userId: string) { + await cleanupSeededRecording(); + const library = createClient({ url: databaseUrl(LIBRARY_DB) }); + const transcripts = createClient({ url: databaseUrl(TRANSCRIPTS_DB) }); + const voiceprints = createClient({ url: databaseUrl(VOICEPRINTS_DB) }); + const now = Date.now(); + + try { + await executeWithBusyRetry(() => + library.execute({ + sql: ` + INSERT INTO recordings ( + id, user_id, source_provider, source_recording_id, + source_version, source_metadata, provider_device_id, + filename, duration, start_time, end_time, filesize, + file_md5, storage_type, storage_path, downloaded_at, + upstream_trashed, upstream_deleted, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + args: [ + TAB_RECORDING_ID, + userId, + "ticnote", + `${TAB_RECORDING_ID}-source`, + "1", + "{}", + `${TAB_RECORDING_ID}-device`, + TAB_RECORDING_TITLE, + 120_000, + now - 120_000, + now, + 2048, + TAB_RECORDING_ID, + "local", + "", + now, + 0, + 0, + now, + now, + ], + }), + ); + await executeWithBusyRetry(() => + transcripts.execute({ + sql: ` + INSERT INTO transcriptions ( + id, recording_id, user_id, text, detected_language, + transcription_type, provider, model, provider_job_id, + speaker_map, provider_payload, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + args: [ + TAB_TRANSCRIPTION_ID, + TAB_RECORDING_ID, + userId, + `${TAB_SPEAKER_LABEL}: ${TAB_TRANSCRIPT_TEXT}`, + "en", + "server", + "server", + "e2e", + null, + JSON.stringify({ [TAB_SPEAKER_LABEL]: TAB_SPEAKER_LABEL }), + "{}", + now, + ], + }), + ); + await executeWithBusyRetry(() => + transcripts.execute({ + sql: ` + INSERT INTO transcript_segments ( + id, recording_id, user_id, transcription_id, + source_artifact_id, transcript_origin, provider_segment_id, + speaker_profile_id, raw_speaker_label, start_ms, end_ms, + sort_seq_ms, text, content_hash, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + args: [ + `${TAB_RECORDING_ID}-segment-1`, + TAB_RECORDING_ID, + userId, + TAB_TRANSCRIPTION_ID, + null, + "local", + "segment-1", + TAB_SPEAKER_PROFILE_ID, + TAB_SPEAKER_LABEL, + 0, + 8_000, + 0, + TAB_TRANSCRIPT_TEXT, + contentHash(TAB_TRANSCRIPT_TEXT), + now, + now, + ], + }), + ); + await executeWithBusyRetry(() => + voiceprints.execute({ + sql: ` + INSERT INTO speaker_profiles ( + id, user_id, display_name, voiceprint_ref, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + `, + args: [ + TAB_SPEAKER_PROFILE_ID, + userId, + TAB_SPEAKER_LABEL, + null, + now, + now, + ], + }), + ); + await executeWithBusyRetry(() => + voiceprints.execute({ + sql: ` + INSERT INTO recording_speakers ( + id, user_id, recording_id, raw_label, matched_profile_id, + sample_segments, segment_count, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + args: [ + `${TAB_RECORDING_ID}-speaker-1`, + userId, + TAB_RECORDING_ID, + TAB_SPEAKER_LABEL, + TAB_SPEAKER_PROFILE_ID, + JSON.stringify([ + { + startMs: 0, + endMs: 8_000, + text: TAB_TRANSCRIPT_TEXT, + }, + ]), + 1, + now, + now, + ], + }), + ); + } finally { + await library.close(); + await transcripts.close(); + await voiceprints.close(); + } +} + +test("dashboard detail tabs activate speaker pane on keyboard navigation", async ({ + page, +}) => { + await ensureSignedIn(page); + const userId = await getPlaywrightUserId(); + + try { + await seedDashboardRecording(userId); + + const recordingResponse = await page.request.get( + `/api/recordings/${TAB_RECORDING_ID}`, + ); + await expect(recordingResponse).toBeOK(); + await expect(recordingResponse.json()).resolves.toMatchObject({ + recording: { id: TAB_RECORDING_ID }, + transcription: { + segments: [ + expect.objectContaining({ + speakerLabel: TAB_SPEAKER_LABEL, + text: TAB_TRANSCRIPT_TEXT, + }), + ], + }, + }); + + await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); + + const recording = page.getByRole("button", { + name: new RegExp(TAB_RECORDING_TITLE), + }); + const tabList = page.getByRole("tablist", { name: "详情标签" }); + const transcriptTab = tabList.getByRole("tab", { + name: "转写", + exact: true, + }); + const speakersTab = tabList.getByRole("tab", { + name: "说话人", + exact: true, + }); + const sourceTab = tabList.getByRole("tab", { + name: "来源详情", + exact: true, + }); + + await expect(recording).toBeVisible(); + await recording.click(); + await expect(recording).toHaveAttribute("aria-current", "true"); + await expect(tabList).toBeVisible(); + await expect(transcriptTab).toHaveAttribute("aria-selected", "true"); + await expect( + page.getByText(TAB_TRANSCRIPT_TEXT, { exact: true }), + ).toBeVisible(); + + await transcriptTab.focus(); + await expect(transcriptTab).toBeFocused(); + await transcriptTab.press("ArrowRight"); + await expect(speakersTab).toBeFocused(); + await expect(speakersTab).toHaveAttribute("aria-selected", "true"); + await expect(transcriptTab).toHaveAttribute("aria-selected", "false"); + await expect( + page + .getByRole("listitem") + .filter({ hasText: TAB_SPEAKER_LABEL }), + ).toBeVisible(); + await expect( + page.getByText(TAB_TRANSCRIPT_TEXT, { exact: true }), + ).toBeHidden(); + + await speakersTab.press("ArrowLeft"); + await expect(transcriptTab).toBeFocused(); + await expect(transcriptTab).toHaveAttribute("aria-selected", "true"); + await expect( + page.getByText(TAB_TRANSCRIPT_TEXT, { exact: true }), + ).toBeVisible(); + + await transcriptTab.press("End"); + await expect(sourceTab).toBeFocused(); + await expect(sourceTab).toHaveAttribute("aria-selected", "true"); + + await sourceTab.press("Home"); + await expect(transcriptTab).toBeFocused(); + await expect(transcriptTab).toHaveAttribute("aria-selected", "true"); + } finally { + await cleanupSeededRecording(); + } +}); diff --git a/e2e/dashboard-source-filter-runtime.spec.ts b/e2e/dashboard-source-filter-runtime.spec.ts new file mode 100644 index 00000000..1fa5d8d6 --- /dev/null +++ b/e2e/dashboard-source-filter-runtime.spec.ts @@ -0,0 +1,326 @@ +import { createCipheriv, randomBytes } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createClient } from "@libsql/client"; +import { expect, type Page, test } from "@playwright/test"; +import { ensureSignedIn, putJsonWithRetry } from "./helpers/auth"; + +const E2E_ROOT_MARKER = ".betterainote-e2e-root"; +const E2E_ROOT_MARKER_CONTENTS = "BetterAINote E2E disposable root v1\n"; +const SOURCE_FILTER_STORAGE_KEY = "dashboard-source-filter-provider"; +const FIXTURE_PREFIX = "e2e-source-filter-runtime-"; +const E2E_ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +function requireIsolatedE2ERoot() { + const configuredRoot = process.env.PLAYWRIGHT_E2E_ROOT?.trim(); + if (!configuredRoot) { + throw new Error("PLAYWRIGHT_E2E_ROOT is required"); + } + + return path.resolve(configuredRoot); +} + +const E2E_ROOT = requireIsolatedE2ERoot(); +const CORE_DB = path.resolve( + process.env.DATABASE_PATH ?? + path.join(E2E_ROOT, "data", "betterainote-e2e.db"), +); + +function deriveSiblingDatabasePath(databasePath: string, suffix: string) { + const parsed = path.parse(databasePath); + return path.resolve( + parsed.dir, + `${parsed.name}-${suffix}${parsed.ext || ".db"}`, + ); +} + +const LIBRARY_DB = deriveSiblingDatabasePath(CORE_DB, "library"); + +function assertIsolatedPath(targetPath: string) { + const resolved = path.resolve(targetPath); + if (resolved !== E2E_ROOT && !resolved.startsWith(`${E2E_ROOT}${path.sep}`)) { + throw new Error(`Refusing non-E2E path: ${resolved}`); + } +} + +function databaseUrl(targetPath: string) { + assertIsolatedPath(targetPath); + return pathToFileURL(targetPath).href; +} + +function assertPreparedE2ERoot() { + const markerPath = path.join(E2E_ROOT, E2E_ROOT_MARKER); + expect(existsSync(markerPath)).toBe(true); + expect(readFileSync(markerPath, "utf8")).toBe(E2E_ROOT_MARKER_CONTENTS); +} + +function encryptFixtureSecrets(secrets: Record) { + const iv = randomBytes(16); + const cipher = createCipheriv( + "aes-256-gcm", + Buffer.from(E2E_ENCRYPTION_KEY, "hex"), + iv, + ); + const encrypted = Buffer.concat([ + cipher.update(JSON.stringify(secrets), "utf8"), + cipher.final(), + ]); + + return [ + iv.toString("hex"), + cipher.getAuthTag().toString("hex"), + encrypted.toString("hex"), + ].join(":"); +} + +async function getPlaywrightUserId() { + const core = createClient({ url: databaseUrl(CORE_DB) }); + try { + const result = await core.execute({ + sql: "SELECT id FROM users WHERE email = ? LIMIT 1", + args: ["playwright-admin@example.com"], + }); + const userId = result.rows[0]?.id; + if (typeof userId !== "string") { + throw new Error("Playwright user was not created"); + } + return userId; + } finally { + await core.close(); + } +} + +async function cleanupFixture(userId: string) { + const core = createClient({ url: databaseUrl(CORE_DB) }); + const library = createClient({ url: databaseUrl(LIBRARY_DB) }); + try { + await library.execute({ + sql: "DELETE FROM recordings WHERE user_id = ? AND id LIKE ?", + args: [userId, `${FIXTURE_PREFIX}%`], + }); + await core.execute({ + sql: ` + DELETE FROM source_connections + WHERE user_id = ? AND provider IN ('iflyrec', 'plaud') + `, + args: [userId], + }); + } finally { + await core.close(); + await library.close(); + } +} + +async function seedFixture(userId: string) { + const core = createClient({ url: databaseUrl(CORE_DB) }); + const library = createClient({ url: databaseUrl(LIBRARY_DB) }); + const now = Date.now(); + + try { + await cleanupFixture(userId); + for (const connection of [ + { + authMode: "session-header", + baseUrl: "https://www.iflyrec.com", + provider: "iflyrec", + secretConfig: encryptFixtureSecrets({ + sessionId: "synthetic-e2e-session", + }), + }, + { + authMode: "bearer", + baseUrl: "https://api.plaud.ai", + provider: "plaud", + secretConfig: encryptFixtureSecrets({ + bearerToken: "synthetic-e2e-credential", + }), + }, + ]) { + await core.execute({ + sql: ` + INSERT INTO source_connections ( + id, user_id, provider, enabled, auth_mode, base_url, + config, secret_config, last_sync, sync_status, + last_sync_error, last_sync_started_at, + last_sync_finished_at, created_at, updated_at + ) VALUES (?, ?, ?, 1, ?, ?, '{}', ?, ?, 'idle', NULL, NULL, ?, ?, ?) + `, + args: [ + `${FIXTURE_PREFIX}${connection.provider}`, + userId, + connection.provider, + connection.authMode, + connection.baseUrl, + connection.secretConfig, + now - 60_000, + now - 60_000, + now, + now, + ], + }); + } + + await library.execute({ + sql: ` + INSERT INTO recordings ( + id, user_id, source_provider, source_recording_id, + source_version, source_metadata, provider_device_id, + filename, duration, start_time, end_time, filesize, + file_md5, storage_type, storage_path, downloaded_at, + upstream_trashed, upstream_deleted, created_at, updated_at + ) VALUES (?, ?, 'iflyrec', ?, '1', '{}', ?, ?, 60000, ?, ?, 0, ?, 'local', '', ?, 0, 0, ?, ?) + `, + args: [ + `${FIXTURE_PREFIX}iflyrec-recording`, + userId, + `${FIXTURE_PREFIX}source-recording`, + `${FIXTURE_PREFIX}device`, + "Source filter runtime recording", + now - 60_000, + now, + `${FIXTURE_PREFIX}md5`, + now, + now, + now, + ], + }); + } finally { + await core.close(); + await library.close(); + } +} + +async function openDashboardInEnglish(page: Page) { + const response = await putJsonWithRetry(page, "/api/settings/display", { + dateTimeFormat: "relative", + itemsPerPage: 50, + recordingListSortOrder: "newest", + theme: "dark", + uiLanguage: "en", + }); + expect(response.ok()).toBe(true); + + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey); + }, SOURCE_FILTER_STORAGE_KEY); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect( + page.getByRole("heading", { name: "Source filter runtime recording" }), + ).toBeVisible(); +} + +test("source rail keeps pressed/current selection, reconciles no-results, and persists clearing", async ({ + page, +}) => { + assertPreparedE2ERoot(); + await page.setViewportSize({ width: 1280, height: 760 }); + await ensureSignedIn(page); + const userId = await getPlaywrightUserId(); + + try { + await seedFixture(userId); + await openDashboardInEnglish(page); + + const dataSourcesResponse = await page.request.get("/api/data-sources"); + expect(dataSourcesResponse.ok()).toBe(true); + const dataSources = (await dataSourcesResponse.json()) as { + sources: Array<{ + connected: boolean; + provider: string; + }>; + }; + expect( + dataSources.sources.find((source) => source.provider === "iflyrec"), + ).toMatchObject({ connected: true, provider: "iflyrec" }); + expect( + dataSources.sources.find((source) => source.provider === "plaud"), + ).toMatchObject({ connected: true, provider: "plaud" }); + + const library = createClient({ url: databaseUrl(LIBRARY_DB) }); + try { + const seededRecording = await library.execute({ + sql: ` + SELECT source_provider + FROM recordings + WHERE user_id = ? AND id = ? + `, + args: [userId, `${FIXTURE_PREFIX}iflyrec-recording`], + }); + expect(seededRecording.rows).toHaveLength(1); + expect(seededRecording.rows[0]?.source_provider).toBe("iflyrec"); + } finally { + await library.close(); + } + + const plaud = page.getByRole("button", { + name: /^Plaud ·/, + }); + const iflyrec = page.getByRole("button", { + name: /^iFLYTEK iflyrec ·/, + }); + await expect(plaud).toHaveAttribute("aria-pressed", "false"); + await expect(plaud).not.toHaveAttribute("aria-current"); + + const sourceQuery = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname === "/api/recordings/query" && + url.searchParams.get("source") === "iflyrec" && + url.searchParams.get("favorite") === null + ); + }); + await iflyrec.click(); + expect((await sourceQuery).ok()).toBe(true); + await expect(iflyrec).toHaveAttribute("aria-pressed", "true"); + await expect(iflyrec).toHaveAttribute("aria-current", "true"); + await expect(plaud).toHaveAttribute("aria-pressed", "false"); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(iflyrec).toHaveAttribute("aria-pressed", "true"); + await expect(iflyrec).toHaveAttribute("aria-current", "true"); + await expect + .poll(() => + page.evaluate((storageKey) => { + return window.localStorage.getItem(storageKey); + }, SOURCE_FILTER_STORAGE_KEY), + ) + .toBe("iflyrec"); + + const transcribed = page.getByRole("button", { + name: /^Transcribed\b/, + }); + await transcribed.click(); + await expect(transcribed).toHaveAttribute("aria-pressed", "true"); + await expect(iflyrec).toHaveAttribute("aria-pressed", "true"); + await expect(iflyrec).toHaveAttribute("aria-current", "true"); + await expect( + page.getByRole("status").filter({ hasText: /no matches/i }), + ).toBeVisible(); + + const allRecordings = page.getByRole("button", { + name: /^All recordings\b/, + }); + await allRecordings.click(); + await expect(allRecordings).toHaveAttribute("aria-pressed", "true"); + await expect(iflyrec).toHaveAttribute("aria-pressed", "false"); + await expect(iflyrec).not.toHaveAttribute("aria-current"); + await expect( + page.getByRole("status").filter({ hasText: /no matches/i }), + ).toHaveCount(0); + await expect + .poll(() => + page.evaluate((storageKey) => { + return window.localStorage.getItem(storageKey); + }, SOURCE_FILTER_STORAGE_KEY), + ) + .toBe("all"); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(iflyrec).toHaveAttribute("aria-pressed", "false"); + await expect(iflyrec).not.toHaveAttribute("aria-current"); + } finally { + await cleanupFixture(userId); + } +}); diff --git a/e2e/dashboard-tag-filter-state.spec.ts b/e2e/dashboard-tag-filter-state.spec.ts new file mode 100644 index 00000000..25b5d530 --- /dev/null +++ b/e2e/dashboard-tag-filter-state.spec.ts @@ -0,0 +1,285 @@ +import { expect, type Page, test, type TestInfo } from "@playwright/test"; +import { ensureSignedIn, putJsonWithRetry } from "./helpers/auth"; +import { + cleanupDashboardTagTriggerSeed, + expectedDashboardTagTriggerDatabaseState, + readDashboardTagTriggerApiTags, + readDashboardTagTriggerDatabaseState, + seedDashboardTagTriggerData, +} from "./helpers/dashboard-tag-filter-state-seed"; + +const DASHBOARD_FILTER_STATE_STORAGE_KEY = + "dashboard-recording-filter-state"; +const DASHBOARD_SOURCE_FILTER_STORAGE_KEY = + "dashboard-source-filter-provider"; + +function favorite(page: Page, value: "all" | "tags" | "transcribed") { + return page.locator(`[data-favorite="${value}"]`); +} + +function listMode(page: Page, value: "tags" | "timeline") { + return page + .getByRole("tablist", { name: "列表模式" }) + .getByRole("tab", { + exact: true, + name: value === "tags" ? "标签" : "时间", + }); +} + +function tagTrigger(page: Page) { + return page.locator( + '[data-control="recording-list-tag-filter-trigger"]', + ); +} + +async function expectTagTrigger( + page: Page, + { count, label }: { count: number; label: string }, +) { + const trigger = tagTrigger(page); + await expect(trigger).toBeVisible(); + await expect( + trigger.locator('[data-part="recording-list-tag-filter-label"]'), + ).toHaveText(label); + await expect( + trigger.locator('[data-part="recording-list-tag-filter-count"]'), + ).toHaveText(String(count)); + return trigger; +} + +function waitForRecordingQuery( + page: Page, + expected: { + favorite?: "tags" | "transcribed" | null; + source?: string | null; + tagId?: string | null; + }, +) { + return page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname === "/api/recordings/query" && + url.searchParams.get("favorite") === + (expected.favorite ?? null) && + url.searchParams.get("source") === (expected.source ?? null) && + url.searchParams.get("tagId") === (expected.tagId ?? null) + ); + }); +} + +async function selectTag(page: Page, tagId: string, label: string) { + const trigger = tagTrigger(page); + await trigger.click(); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + const option = page.locator(`[data-tag-value="tag:${tagId}"]`); + await expect(option).toContainText(label); + await option.click(); + await expect(trigger).toHaveAttribute("aria-expanded", "false"); +} + +async function setChineseDisplay(page: Page) { + const response = await putJsonWithRetry(page, "/api/settings/display", { + dateTimeFormat: "relative", + itemsPerPage: 50, + recordingListSortOrder: "newest", + theme: "dark", + uiLanguage: "zh-CN", + }); + expect(response.status()).toBe(200); +} + +async function readStoredFilterState(page: Page) { + return page.evaluate((storageKey) => { + const value = window.localStorage.getItem(storageKey); + return value ? JSON.parse(value) : null; + }, DASHBOARD_FILTER_STATE_STORAGE_KEY); +} + +async function attachCurrentGeometry(page: Page, testInfo: TestInfo) { + const box = await tagTrigger(page).boundingBox(); + expect(box).not.toBeNull(); + expect(box?.width).toBe(354); + expect(box?.height).toBe(32); + await testInfo.attach("tag-trigger-current-geometry.json", { + body: Buffer.from( + JSON.stringify( + { + current: box, + previousRealProduct: { height: 32, width: 354 }, + previousSot: { height: 31, width: 276 }, + previousSotDifferingPixels: 11_324, + previousSotMaxChannelDelta: 255, + }, + null, + 2, + ), + ), + contentType: "application/json", + }); +} + +test("tag mode, Favorites, source and explicit tag state compose deterministically", async ({ + page, +}, testInfo) => { + await ensureSignedIn(page); + const { tags, userId } = await seedDashboardTagTriggerData(page); + const productTag = tags.find((tag) => tag.name === "产品周会"); + const customerTag = tags.find((tag) => tag.name === "客户访谈"); + expect(productTag).toBeDefined(); + expect(customerTag).toBeDefined(); + + try { + await setChineseDisplay(page); + await page.evaluate( + ({ filterKey, sourceKey }) => { + window.localStorage.removeItem(filterKey); + window.localStorage.setItem(sourceKey, "ticnote"); + }, + { + filterKey: DASHBOARD_FILTER_STATE_STORAGE_KEY, + sourceKey: DASHBOARD_SOURCE_FILTER_STORAGE_KEY, + }, + ); + + const initialQuery = waitForRecordingQuery(page, { + source: "ticnote", + }); + await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); + expect((await initialQuery).ok()).toBe(true); + await expect(favorite(page, "all")).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect(listMode(page, "timeline")).toHaveAttribute( + "aria-selected", + "true", + ); + + const tagModeQuery = waitForRecordingQuery(page, { + source: "ticnote", + }); + await listMode(page, "tags").click(); + expect((await tagModeQuery).ok()).toBe(true); + await expect(listMode(page, "tags")).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(favorite(page, "all")).toHaveAttribute( + "aria-pressed", + "true", + ); + await expectTagTrigger(page, { count: 6, label: "全部" }); + + const explicitFavoriteQuery = waitForRecordingQuery(page, { + favorite: "tags", + source: "ticnote", + }); + await favorite(page, "tags").click(); + expect((await explicitFavoriteQuery).ok()).toBe(true); + await expect(favorite(page, "tags")).toHaveAttribute( + "aria-pressed", + "true", + ); + await expectTagTrigger(page, { count: 3, label: "全部" }); + + const clearFavoriteQuery = waitForRecordingQuery(page, { + source: "ticnote", + }); + await favorite(page, "all").click(); + expect((await clearFavoriteQuery).ok()).toBe(true); + await expect(listMode(page, "tags")).toHaveAttribute( + "aria-selected", + "true", + ); + await expectTagTrigger(page, { count: 6, label: "全部" }); + await expect + .poll(() => + page.evaluate( + (storageKey) => window.localStorage.getItem(storageKey), + DASHBOARD_SOURCE_FILTER_STORAGE_KEY, + ), + ) + .toBe("ticnote"); + + const productQuery = waitForRecordingQuery(page, { + source: "ticnote", + tagId: productTag?.id, + }); + await selectTag(page, productTag?.id ?? "", "产品周会"); + expect((await productQuery).ok()).toBe(true); + await expectTagTrigger(page, { count: 2, label: "产品周会" }); + + await expect.poll(() => readStoredFilterState(page)).toEqual({ + favorite: "all", + listMode: "tags", + selectedTagFilter: `tag:${productTag?.id}`, + }); + expect(new URL(page.url()).searchParams.get("mode")).toBe("tags"); + expect(new URL(page.url()).searchParams.get("tagId")).toBe( + productTag?.id, + ); + + const reloadQuery = waitForRecordingQuery(page, { + source: "ticnote", + tagId: productTag?.id, + }); + await page.reload({ waitUntil: "domcontentloaded" }); + expect((await reloadQuery).ok()).toBe(true); + await expectTagTrigger(page, { count: 2, label: "产品周会" }); + await expect(listMode(page, "tags")).toHaveAttribute( + "aria-selected", + "true", + ); + + const composedFavoriteQuery = waitForRecordingQuery(page, { + favorite: "tags", + source: "ticnote", + tagId: productTag?.id, + }); + await favorite(page, "tags").click(); + expect((await composedFavoriteQuery).ok()).toBe(true); + await expectTagTrigger(page, { count: 2, label: "产品周会" }); + + const allQuery = waitForRecordingQuery(page, { + source: "ticnote", + }); + await favorite(page, "all").click(); + expect((await allQuery).ok()).toBe(true); + await expectTagTrigger(page, { count: 6, label: "全部" }); + await expect(listMode(page, "tags")).toHaveAttribute( + "aria-selected", + "true", + ); + expect(new URL(page.url()).searchParams.get("favorite")).toBeNull(); + expect(new URL(page.url()).searchParams.get("tagId")).toBeNull(); + expect(new URL(page.url()).searchParams.get("mode")).toBe("tags"); + + const urlOverrideQuery = waitForRecordingQuery(page, { + favorite: "tags", + source: "ticnote", + tagId: customerTag?.id, + }); + await page.goto( + `/dashboard?mode=tags&favorite=tags&tagId=${encodeURIComponent(customerTag?.id ?? "")}`, + { waitUntil: "domcontentloaded" }, + ); + expect((await urlOverrideQuery).ok()).toBe(true); + await expectTagTrigger(page, { count: 1, label: "客户访谈" }); + await expect(favorite(page, "tags")).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect(listMode(page, "tags")).toHaveAttribute( + "aria-selected", + "true", + ); + + expect(await readDashboardTagTriggerDatabaseState(userId)).toEqual( + expectedDashboardTagTriggerDatabaseState(), + ); + expect(await readDashboardTagTriggerApiTags(page)).toHaveLength(6); + await attachCurrentGeometry(page, testInfo); + } finally { + await cleanupDashboardTagTriggerSeed(userId); + } +}); diff --git a/e2e/dashboard-transcription-integration.spec.ts b/e2e/dashboard-transcription-integration.spec.ts new file mode 100644 index 00000000..c2ef1ec6 --- /dev/null +++ b/e2e/dashboard-transcription-integration.spec.ts @@ -0,0 +1,544 @@ +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createClient } from "@libsql/client"; +import { expect, test, type Page } from "@playwright/test"; +import { ensureSignedIn } from "./helpers/auth"; + +const USER_EMAIL = "playwright-admin@example.com"; +const RECORDING_PREFIX = "e2e-dashboard-transcription-"; +const MERGE_RECORDING_ID = `${RECORDING_PREFIX}merge`; +const QUEUE_RECORDING_ID = `${RECORDING_PREFIX}queue`; +const MERGE_TITLE = "E2E dashboard transcription merge"; +const QUEUE_TITLE = "E2E dashboard transcription queue"; +const E2E_ROOT_MARKER = ".betterainote-e2e-root"; + +function requireIsolatedE2ERoot() { + const configuredRoot = process.env.PLAYWRIGHT_E2E_ROOT?.trim(); + if (!configuredRoot) { + throw new Error("PLAYWRIGHT_E2E_ROOT is required"); + } + const root = path.resolve(configuredRoot); + if (root === path.resolve(process.cwd())) { + throw new Error("Dashboard transcription E2E requires a disposable root"); + } + return root; +} + +const E2E_ROOT = requireIsolatedE2ERoot(); +const E2E_DATA_DIR = path.join(E2E_ROOT, "data"); +const E2E_STORAGE_DIR = path.join(E2E_ROOT, "storage"); +const CORE_DB = path.resolve(process.env.DATABASE_PATH ?? ""); + +function deriveSiblingDatabasePath(databasePath: string, suffix: string) { + const parsed = path.parse(databasePath); + return path.resolve( + parsed.dir, + `${parsed.name}-${suffix}${parsed.ext || ".db"}`, + ); +} + +const LIBRARY_DB = deriveSiblingDatabasePath(CORE_DB, "library"); +const TRANSCRIPTS_DB = deriveSiblingDatabasePath(CORE_DB, "transcripts"); +const VOICEPRINTS_DB = deriveSiblingDatabasePath(CORE_DB, "voiceprints"); + +function assertDisposableDatabase(filePath: string) { + const relative = path.relative(E2E_DATA_DIR, path.resolve(filePath)); + if ( + !relative || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new Error(`Refusing non-E2E database path: ${filePath}`); + } +} + +function databaseUrl(filePath: string) { + assertDisposableDatabase(filePath); + return pathToFileURL(filePath).href; +} + +async function withBusyRetry(operation: () => Promise) { + const delays = [50, 100, 200, 400, 800]; + for (let attempt = 0; ; attempt += 1) { + try { + return await operation(); + } catch (error) { + const delay = delays[attempt]; + const message = + error instanceof Error ? error.message : String(error); + if ( + delay == null || + !/SQLITE_BUSY|database is locked/i.test(message) + ) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } +} + +async function getUserId() { + const core = createClient({ url: databaseUrl(CORE_DB) }); + try { + const result = await withBusyRetry(() => + core.execute({ + sql: "SELECT id FROM users WHERE email = ? LIMIT 1", + args: [USER_EMAIL], + }), + ); + const userId = result.rows[0]?.id; + if (typeof userId !== "string") { + throw new Error("Playwright user was not created"); + } + return userId; + } finally { + await core.close(); + } +} + +async function setPrivateTranscriptionCapability( + userId: string, + enabled: boolean, +) { + const core = createClient({ url: databaseUrl(CORE_DB) }); + const now = Date.now(); + try { + if (!enabled) { + await withBusyRetry(() => + core.execute({ + sql: ` + UPDATE user_settings + SET private_transcription_base_url = NULL, updated_at = ? + WHERE user_id = ? + `, + args: [now, userId], + }), + ); + return; + } + + await withBusyRetry(() => + core.execute({ + sql: ` + INSERT INTO user_settings ( + id, user_id, private_transcription_base_url, + private_transcription_min_speakers, + private_transcription_max_speakers, + private_transcription_denoise_model, + private_transcription_no_repeat_ngram_size, + private_transcription_max_inflight_jobs, + created_at, updated_at + ) VALUES (?, ?, ?, 0, 0, 'none', 0, 1, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + private_transcription_base_url = excluded.private_transcription_base_url, + updated_at = excluded.updated_at + `, + args: [ + `${RECORDING_PREFIX}settings`, + userId, + "https://transcribe.e2e.example", + now, + now, + ], + }), + ); + } finally { + await core.close(); + } +} + +async function cleanupSeeds(userId: string) { + const library = createClient({ url: databaseUrl(LIBRARY_DB) }); + const transcripts = createClient({ url: databaseUrl(TRANSCRIPTS_DB) }); + const voiceprints = createClient({ url: databaseUrl(VOICEPRINTS_DB) }); + try { + await withBusyRetry(() => + voiceprints.execute({ + sql: "DELETE FROM recording_speakers WHERE user_id = ? AND recording_id LIKE ?", + args: [userId, `${RECORDING_PREFIX}%`], + }), + ); + await withBusyRetry(() => + voiceprints.execute({ + sql: "DELETE FROM speaker_profiles WHERE user_id = ? AND display_name IN ('Speaker 1', 'Speaker 2')", + args: [userId], + }), + ); + await withBusyRetry(() => + transcripts.execute({ + sql: "DELETE FROM transcript_segments WHERE user_id = ? AND recording_id LIKE ?", + args: [userId, `${RECORDING_PREFIX}%`], + }), + ); + await withBusyRetry(() => + transcripts.execute({ + sql: "DELETE FROM transcriptions WHERE user_id = ? AND recording_id LIKE ?", + args: [userId, `${RECORDING_PREFIX}%`], + }), + ); + await withBusyRetry(() => + library.execute({ + sql: "DELETE FROM transcription_jobs WHERE user_id = ? AND recording_id LIKE ?", + args: [userId, `${RECORDING_PREFIX}%`], + }), + ); + await withBusyRetry(() => + library.execute({ + sql: "DELETE FROM recordings WHERE user_id = ? AND id LIKE ?", + args: [userId, `${RECORDING_PREFIX}%`], + }), + ); + } finally { + await library.close(); + await transcripts.close(); + await voiceprints.close(); + } +} + +async function seedRecording( + userId: string, + input: { + id: string; + title: string; + withTranscript: boolean; + }, +) { + const library = createClient({ url: databaseUrl(LIBRARY_DB) }); + const transcripts = createClient({ url: databaseUrl(TRANSCRIPTS_DB) }); + const voiceprints = createClient({ url: databaseUrl(VOICEPRINTS_DB) }); + const now = Date.now(); + const storagePath = `${RECORDING_PREFIX}${input.id}.mp3`; + const transcriptId = `${input.id}-transcript`; + const segments = [ + { + id: 1, + start: 0, + end: 1.5, + text: "First dashboard statement.", + speakerLabel: "Speaker 1", + speakerId: null, + speakerName: null, + similarity: null, + hasOverlap: false, + words: null, + }, + { + id: 2, + start: 2, + end: 3.5, + text: "Second dashboard statement.", + speakerLabel: "Speaker 2", + speakerId: null, + speakerName: null, + similarity: null, + hasOverlap: false, + words: null, + }, + ]; + + await mkdir(path.dirname(path.join(E2E_STORAGE_DIR, storagePath)), { + recursive: true, + }); + await writeFile(path.join(E2E_STORAGE_DIR, storagePath), Buffer.from("ID3")); + + try { + await withBusyRetry(() => + library.execute({ + sql: ` + INSERT INTO recordings ( + id, user_id, source_provider, source_recording_id, source_version, + source_metadata, provider_device_id, filename, duration, start_time, + end_time, filesize, file_md5, storage_type, storage_path, + downloaded_at, upstream_trashed, upstream_deleted, created_at, updated_at + ) VALUES (?, ?, 'ticnote', ?, '1', '{}', ?, ?, 60000, ?, ?, 3, ?, 'local', ?, ?, 0, 0, ?, ?) + `, + args: [ + input.id, + userId, + `${input.id}-source`, + `${RECORDING_PREFIX}device`, + input.title, + now - 60_000, + now, + `${input.id}-md5`, + storagePath, + now, + now, + now, + ], + }), + ); + if (!input.withTranscript) { + return; + } + + await withBusyRetry(() => + transcripts.execute({ + sql: ` + INSERT INTO transcriptions ( + id, recording_id, user_id, text, detected_language, + transcription_type, provider, model, provider_job_id, + speaker_map, provider_payload, created_at + ) VALUES (?, ?, ?, ?, 'en', 'private', 'voice-transcribe', 'e2e', NULL, '{}', ?, ?) + `, + args: [ + transcriptId, + input.id, + userId, + "Speaker 1: First dashboard statement.\nSpeaker 2: Second dashboard statement.", + JSON.stringify({ + id: `${input.id}-provider`, + status: "completed", + language: "en", + speakerMap: {}, + uniqueSpeakers: ["Speaker 1", "Speaker 2"], + segments, + }), + now, + ], + }), + ); + for (const [index, segment] of segments.entries()) { + await withBusyRetry(() => + voiceprints.execute({ + sql: ` + INSERT INTO recording_speakers ( + id, user_id, recording_id, raw_label, + matched_profile_id, sample_segments, segment_count, + created_at, updated_at + ) VALUES (?, ?, ?, ?, NULL, ?, 1, ?, ?) + `, + args: [ + `${input.id}-speaker-${index + 1}`, + userId, + input.id, + segment.speakerLabel, + JSON.stringify([ + { + startMs: segment.start * 1000, + endMs: segment.end * 1000, + text: segment.text, + }, + ]), + now, + now, + ], + }), + ); + } + } finally { + await library.close(); + await transcripts.close(); + await voiceprints.close(); + } +} + +async function selectRecording(page: Page, title: string) { + await page.getByRole("button", { name: new RegExp(title) }).click(); + await expect(page.getByRole("heading", { name: new RegExp(title) })).toBeVisible(); +} + +function captureReactLoopErrors(page: Page) { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") { + errors.push(message.text()); + } + }); + return errors; +} + +test.beforeEach(() => { + if (!existsSync(path.join(E2E_ROOT, E2E_ROOT_MARKER))) { + throw new Error("Missing marked disposable E2E root"); + } + for (const databasePath of [ + CORE_DB, + LIBRARY_DB, + TRANSCRIPTS_DB, + VOICEPRINTS_DB, + ]) { + assertDisposableDatabase(databasePath); + if (!existsSync(databasePath)) { + throw new Error(`Missing initialized E2E database: ${databasePath}`); + } + } +}); + +test("dashboard merges speakers through real APIs and SQLite without an update loop", async ({ + page, +}) => { + const reactErrors = captureReactLoopErrors(page); + await ensureSignedIn(page); + const userId = await getUserId(); + + try { + await cleanupSeeds(userId); + await seedRecording(userId, { + id: MERGE_RECORDING_ID, + title: MERGE_TITLE, + withTranscript: true, + }); + await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); + await selectRecording(page, MERGE_TITLE); + await page.getByRole("tab", { name: "说话人", exact: true }).click(); + + await expect(page.getByLabel("选择Speaker 1")).toBeVisible(); + await page.getByLabel("选择Speaker 1").check(); + await page.getByLabel("选择Speaker 2").check(); + + const patchResponses: number[] = []; + page.on("response", (response) => { + const url = new URL(response.url()); + if ( + url.pathname === + `/api/recordings/${MERGE_RECORDING_ID}/speakers` && + response.request().method() === "PATCH" + ) { + patchResponses.push(response.status()); + } + }); + await page + .locator('[data-control="dashboard-speakers-merge"]') + .click(); + await expect( + page.locator( + '[data-panel="dashboard-speaker-merge-status"][data-state="success"]', + ), + ).toBeVisible(); + await expect.poll(() => patchResponses).toEqual([200, 200]); + + const voiceprints = createClient({ + url: databaseUrl(VOICEPRINTS_DB), + }); + const transcripts = createClient({ + url: databaseUrl(TRANSCRIPTS_DB), + }); + try { + const assignments = await withBusyRetry(() => + voiceprints.execute({ + sql: ` + SELECT raw_label, matched_profile_id + FROM recording_speakers + WHERE user_id = ? AND recording_id = ? + ORDER BY raw_label + `, + args: [userId, MERGE_RECORDING_ID], + }), + ); + const profileIds = assignments.rows.map( + (row) => row.matched_profile_id, + ); + expect(profileIds).toHaveLength(2); + expect(typeof profileIds[0]).toBe("string"); + expect(profileIds[1]).toBe(profileIds[0]); + + const transcript = await withBusyRetry(() => + transcripts.execute({ + sql: ` + SELECT speaker_map + FROM transcriptions + WHERE user_id = ? AND recording_id = ? + LIMIT 1 + `, + args: [userId, MERGE_RECORDING_ID], + }), + ); + expect( + JSON.parse(String(transcript.rows[0]?.speaker_map ?? "{}")), + ).toEqual({ + "Speaker 1": "Speaker 1", + "Speaker 2": "Speaker 1", + }); + } finally { + await voiceprints.close(); + await transcripts.close(); + } + + expect( + reactErrors.filter((message) => + /Maximum update depth|Too many re-renders/i.test(message), + ), + ).toEqual([]); + } finally { + await cleanupSeeds(userId); + } +}); + +test("dashboard queues private retranscription through the real POST 202 flow", async ({ + page, +}) => { + const reactErrors = captureReactLoopErrors(page); + await ensureSignedIn(page); + const userId = await getUserId(); + + try { + await cleanupSeeds(userId); + await setPrivateTranscriptionCapability(userId, true); + await seedRecording(userId, { + id: QUEUE_RECORDING_ID, + title: QUEUE_TITLE, + withTranscript: false, + }); + await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); + await selectRecording(page, QUEUE_TITLE); + await expect(page.getByText("还没有逐字稿")).toBeVisible(); + + const responsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname === + `/api/recordings/${QUEUE_RECORDING_ID}/transcribe` && + response.request().method() === "POST" + ); + }); + await page + .locator('[data-control="retranscribe-recording"]') + .click(); + await page + .getByRole("dialog") + .getByRole("button", { name: "确认重新转写", exact: true }) + .click(); + const response = await responsePromise; + expect(response.status()).toBe(202); + expect(response.request().postDataJSON()).toEqual({ force: true }); + await expect( + page.locator( + '[data-panel="dashboard-retranscription"][data-retx-state="queued"]', + ), + ).toContainText("转写任务已加入队列"); + + const library = createClient({ url: databaseUrl(LIBRARY_DB) }); + try { + const jobs = await withBusyRetry(() => + library.execute({ + sql: ` + SELECT status, force + FROM transcription_jobs + WHERE user_id = ? AND recording_id = ? + ORDER BY created_at DESC + LIMIT 1 + `, + args: [userId, QUEUE_RECORDING_ID], + }), + ); + expect(jobs.rows[0]?.status).toBe("pending"); + expect(Number(jobs.rows[0]?.force)).toBe(1); + } finally { + await library.close(); + } + + expect( + reactErrors.filter((message) => + /Maximum update depth|Too many re-renders/i.test(message), + ), + ).toEqual([]); + } finally { + await cleanupSeeds(userId); + await setPrivateTranscriptionCapability(userId, false); + } +}); diff --git a/e2e/data-source-provider-lifecycle.spec.ts b/e2e/data-source-provider-lifecycle.spec.ts index 09f027b9..d0a0eb1c 100644 --- a/e2e/data-source-provider-lifecycle.spec.ts +++ b/e2e/data-source-provider-lifecycle.spec.ts @@ -1,4 +1,6 @@ import { createCipheriv, randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { createClient } from "@libsql/client"; @@ -7,7 +9,7 @@ import type { Page } from "@playwright/test"; import { ensureSignedIn } from "./helpers/auth"; const E2E_PROVIDER = "dingtalk-a1"; -const E2E_PROVIDER_BASE_URL = "https://meeting-ai-tingji.dingtalk.com"; +const E2E_PROVIDER_CREDENTIAL = "e2e-provider-lifecycle-secret-sentinel"; const E2E_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -29,6 +31,135 @@ type SourceConnectionSnapshotRow = { updatedAt: number; }; +async function startDingTalkTestUpstream() { + let requestCount = 0; + let lastRequest: { + method: string | undefined; + path: string | undefined; + credentialMatched: boolean; + } | null = null; + let releaseResponse!: () => void; + const responseGate = new Promise((resolve) => { + releaseResponse = resolve; + }); + let markRequestReceived!: () => void; + const requestReceived = new Promise((resolve) => { + markRequestReceived = resolve; + }); + const server = createServer((request, response) => { + lastRequest = { + method: request.method, + path: request.url, + credentialMatched: + request.headers["dt-meeting-agent-token"] === + E2E_PROVIDER_CREDENTIAL, + }; + if ( + request.method !== "POST" || + request.url !== "/ai/tingji/getConversationList" + ) { + response.writeHead(404).end(); + return; + } + + requestCount += 1; + if (request.headers["dt-meeting-agent-token"] !== E2E_PROVIDER_CREDENTIAL) { + response.writeHead(401).end(); + return; + } + + markRequestReceived(); + void responseGate.then(() => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: { items: [] } })); + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address() as AddressInfo; + + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: () => { + releaseResponse(); + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }); + }, + getLastRequest: () => lastRequest, + getRequestCount: () => requestCount, + releaseResponse, + waitForRequest: () => requestReceived, + }; +} + +async function startDingTalkRetryUpstream() { + const requests: Array<{ + method: string | undefined; + path: string | undefined; + credentialMatched: boolean; + responseStatus: number; + }> = []; + const server = createServer((request, response) => { + if ( + request.method !== "POST" || + request.url !== "/ai/tingji/getConversationList" + ) { + response.writeHead(404).end(); + return; + } + + const credentialMatched = + request.headers["dt-meeting-agent-token"] === + E2E_PROVIDER_CREDENTIAL; + const responseStatus = !credentialMatched + ? 401 + : requests.length === 0 + ? 403 + : 200; + requests.push({ + method: request.method, + path: request.url, + credentialMatched, + responseStatus, + }); + + if (responseStatus !== 200) { + response.writeHead(responseStatus).end(); + return; + } + + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: { items: [] } })); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address() as AddressInfo; + + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }), + getRequests: () => [...requests], + }; +} + function resolveE2ERoot() { return path.resolve( process.env.PLAYWRIGHT_E2E_ROOT ?? path.join(process.cwd(), "tmp/e2e"), @@ -41,6 +172,14 @@ function resolveDatabasePath() { : path.join(resolveE2ERoot(), "data", "betterainote-e2e.db"); } +function resolveLibraryDatabasePath() { + const parsed = path.parse(resolveDatabasePath()); + return path.resolve( + parsed.dir || ".", + `${parsed.name || "betterainote"}-library${parsed.ext || ".db"}`, + ); +} + function isE2EDataPath(filePath: string) { const dataDirectory = path.resolve(resolveE2ERoot(), "data"); const relativePath = path.relative(dataDirectory, path.resolve(filePath)); @@ -222,6 +361,45 @@ async function snapshotDingTalkConnection(userId: string) { } } +async function snapshotDingTalkTestPersistence(userId: string) { + const core = createClient({ url: databaseUrl(resolveDatabasePath()) }); + const library = createClient({ + url: databaseUrl(resolveLibraryDatabasePath()), + }); + + try { + const [connections, workerState, devices] = await Promise.all([ + snapshotDingTalkConnection(userId), + core.execute({ + sql: `SELECT id, user_id, last_heartbeat_at, last_started_at, + last_finished_at, next_run_at, + manual_trigger_requested_at, is_running, + last_error, last_summary, created_at, updated_at + FROM sync_worker_state + WHERE user_id = ? + ORDER BY id`, + args: [userId], + }), + library.execute({ + sql: `SELECT id, user_id, provider, provider_device_id, + name, model, version_number, created_at, updated_at + FROM source_devices + WHERE user_id = ? AND provider = ? + ORDER BY id`, + args: [userId, E2E_PROVIDER], + }), + ]); + + return { + connections, + devices: devices.rows.map((row) => ({ ...row })), + workerState: workerState.rows.map((row) => ({ ...row })), + }; + } finally { + await Promise.all([core.close(), library.close()]); + } +} + async function restoreDingTalkConnection( userId: string, snapshot: SourceConnectionSnapshotRow[], @@ -287,12 +465,12 @@ function encryptWithPlaywrightE2EKey(plaintext: string) { ].join(":"); } -async function seedConnectedDingTalkConnection(userId: string) { +async function seedConnectedDingTalkConnection(userId: string, baseUrl: string) { const client = createClient({ url: databaseUrl(resolveDatabasePath()) }); const now = Date.now(); const secretConfig = encryptWithPlaywrightE2EKey( JSON.stringify({ - deviceCredential: "e2e-provider-lifecycle-secret-sentinel", + deviceCredential: E2E_PROVIDER_CREDENTIAL, }), ); @@ -316,7 +494,7 @@ async function seedConnectedDingTalkConnection(userId: string) { E2E_PROVIDER, 1, "device-signin", - E2E_PROVIDER_BASE_URL, + baseUrl, JSON.stringify({ syncTitleToSource: true }), secretConfig, now - 60_000, @@ -339,20 +517,27 @@ async function seedConnectedDingTalkConnection(userId: string) { async function openDingTalkProvider(page: Page) { await page.goto("/settings#data-sources", { waitUntil: "domcontentloaded" }); - const section = page.locator('[data-sot-surface="settings-data-sources"]'); - const providerCard = section.locator( - `[data-sot-control="source-provider"][data-sot-provider="${E2E_PROVIDER}"]`, - ); - const detail = section.locator( - `[data-sot-panel="source-provider-detail"][data-sot-provider="${E2E_PROVIDER}"]`, - ); - - await expect(section).toHaveAttribute("data-sot-load-state", "ready"); - await expect(providerCard).toHaveAttribute("data-sot-status", "connected"); + const dialog = page.getByRole("dialog", { name: /^(设置|Settings)$/ }); + const sourceList = dialog.getByRole("complementary", { + name: /^(数据源列表|Data source list)$/, + }); + const providerName = /钉钉\s*闪记|DingTalk A1 Flash Notes/; + const providerCard = sourceList.getByRole("button", { name: providerName }); + const providerStatusName = + /^(钉钉\s*闪记|DingTalk A1 Flash Notes): (已连接|Connected)$/; + + await expect(dialog).toBeVisible(); + await expect( + providerCard.getByRole("status", { name: providerStatusName }), + ).toBeVisible(); await providerCard.click(); - await expect(detail).toHaveAttribute("data-sot-status", "connected"); + const detail = dialog.getByRole("region", { name: providerName }); + await expect(detail).toBeVisible(); + await expect( + detail.getByRole("status", { name: providerStatusName }), + ).toBeVisible(); - return { detail, providerCard, section }; + return { detail, providerCard }; } function waitForDataSourcesTestResponse(page: Page) { @@ -411,79 +596,69 @@ test.describe("Data Sources provider Test lifecycle", () => { await ensureSignedIn(page); const userId = await getPlaywrightUserId(); const originalSnapshot = await snapshotDingTalkConnection(userId); - let releaseTestResponse: (() => void) | null = null; - let testRouteInstalled = false; + const upstream = await startDingTalkTestUpstream(); try { - await seedConnectedDingTalkConnection(userId); - const beforeTest = await snapshotDingTalkConnection(userId); - expect(beforeTest).toHaveLength(1); - - const { detail, providerCard, section } = - await openDingTalkProvider(page); - const sourceTest = detail.locator( - '[data-sot-control="source-test"]', - ); - const reconnect = detail.locator( - '[data-sot-control="source-reconnect"]', - ); - const disconnect = detail.locator( - '[data-sot-control="source-disconnect"]', + await seedConnectedDingTalkConnection(userId, upstream.baseUrl); + const beforeTest = await snapshotDingTalkTestPersistence(userId); + expect(beforeTest.connections).toHaveLength(1); + expect(beforeTest.connections[0]).toMatchObject({ + enabled: 1, + authMode: "device-signin", + baseUrl: upstream.baseUrl, + }); + expect(beforeTest.connections[0]?.secretConfig).not.toContain( + E2E_PROVIDER_CREDENTIAL, ); - await expect(sourceTest).toBeEnabled(); - await expect(reconnect).toBeEnabled(); - await expect(disconnect).toBeEnabled(); - let resolveTestResponse!: () => void; - const testResponseGate = new Promise((resolve) => { - resolveTestResponse = resolve; + const { detail } = await openDingTalkProvider(page); + const sourceTest = detail.getByRole("button", { + name: /^(测试连接|测试中|连接正常|Test|Testing|Ready)$/, }); - let forwardedTestRequest = false; - - await page.route("**/api/data-sources/test", async (route) => { - if (route.request().method() !== "POST") { - await route.continue(); - return; - } - - // Keep the UI in its real in-flight state after the route has - // already received the product API response. - forwardedTestRequest = true; - const response = await route.fetch(); - await testResponseGate; - await route.fulfill({ response }); + const reconnect = detail.getByRole("button", { + name: /^(重新连接|Reconnect)$/, }); - testRouteInstalled = true; - releaseTestResponse = resolveTestResponse; + const disconnect = detail.getByRole("button", { + name: /^(断开连接|Disconnect)$/, + }); + await expect(sourceTest).toBeEnabled(); + await expect(reconnect).toBeEnabled(); + await expect(disconnect).toBeEnabled(); const testResponse = waitForDataSourcesTestResponse(page); await sourceTest.click(); - await expect.poll(() => forwardedTestRequest).toBe(true); - await expect(detail).toHaveAttribute( - "data-sot-action-state", - "testing", - ); + await upstream.waitForRequest(); + await expect(detail).toHaveAttribute("aria-busy", "true"); + await expect(sourceTest).toHaveAttribute("aria-busy", "true"); await expect(sourceTest).toBeDisabled(); await expect(reconnect).toBeDisabled(); await expect(disconnect).toBeDisabled(); - releaseTestResponse(); - releaseTestResponse = null; + upstream.releaseResponse(); const response = await testResponse; + expect(upstream.getLastRequest()).toEqual({ + method: "POST", + path: "/ai/tingji/getConversationList", + credentialMatched: true, + }); + expect(upstream.getRequestCount()).toBe(1); expect(response.status()).toBe(200); await expect(response.json()).resolves.toEqual({ success: true }); expect( - readRecord( - response.request().postDataJSON(), - "data source test request", - ).provider, - ).toBe(E2E_PROVIDER); - - await expect(detail).toHaveAttribute( - "data-sot-action-state", - "test-success", - ); - await expect(sourceTest).toHaveAttribute("data-sot-state", "success"); + response.request().postDataJSON(), + ).toMatchObject({ + provider: E2E_PROVIDER, + authMode: "device-signin", + baseUrl: upstream.baseUrl, + }); + + await expect(detail).toHaveAttribute("aria-busy", "false"); + await expect( + detail.getByRole("status", { + name: /^(连接测试通过|Connection test passed)$/, + }), + ).toBeVisible(); + await expect(sourceTest).toHaveText(/连接正常|Ready/); await expect(reconnect).toBeEnabled(); await expect(disconnect).toBeEnabled(); @@ -495,33 +670,99 @@ test.describe("Data Sources provider Test lifecycle", () => { expect(sourceReadback.enabled).toBe(true); expect(sourceReadback.connected).toBe(true); expect(sourceReadback.authMode).toBe("device-signin"); + expect(sourceReadback.baseUrl).toBe(upstream.baseUrl); - const afterTest = await snapshotDingTalkConnection(userId); + const afterTest = await snapshotDingTalkTestPersistence(userId); expect(afterTest).toEqual(beforeTest); - await page.unroute("**/api/data-sources/test"); - testRouteInstalled = false; const reloadResponse = waitForDataSourcesReload(page); await page.reload({ waitUntil: "domcontentloaded" }); expect((await reloadResponse).status()).toBe(200); - await expect(section).toHaveAttribute("data-sot-load-state", "ready"); - await expect(providerCard).toHaveAttribute( - "data-sot-status", - "connected", + const reloaded = await openDingTalkProvider(page); + await expect( + reloaded.detail.getByRole("button", { + name: /^(测试连接|Test)$/, + }), + ).toBeVisible(); + } finally { + try { + await restoreDingTalkConnection(userId, originalSnapshot); + } finally { + await upstream.close(); + } + } + }); + + test("Test surfaces a real permission error, retries successfully, and leaves all persistence unchanged", async ({ + page, + }) => { + await ensureSignedIn(page); + const userId = await getPlaywrightUserId(); + const originalSnapshot = await snapshotDingTalkConnection(userId); + const upstream = await startDingTalkRetryUpstream(); + + try { + await seedConnectedDingTalkConnection(userId, upstream.baseUrl); + const beforeTest = await snapshotDingTalkTestPersistence(userId); + const { detail } = await openDingTalkProvider(page); + const sourceTest = detail.getByRole("button", { + name: /^(测试连接|连接正常|Test|Ready)$/, + }); + + const failedTestResponse = waitForDataSourcesTestResponse(page); + await sourceTest.click(); + const failedResponse = await failedTestResponse; + expect(failedResponse.status()).toBe(400); + await expect(failedResponse.json()).resolves.toEqual({ + error: "未能连接数据源", + }); + await expect( + detail.getByRole("alert", { + name: /^(连接测试失败|Connection test failed)$/, + }), + ).toBeVisible(); + await expect(detail).toHaveAttribute("aria-busy", "false"); + await expect(sourceTest).toBeEnabled(); + expect(await snapshotDingTalkTestPersistence(userId)).toEqual( + beforeTest, ); - await providerCard.click(); - await expect(detail).toHaveAttribute( - "data-sot-status", - "connected", + + const retryResponse = waitForDataSourcesTestResponse(page); + await sourceTest.click(); + const recoveredResponse = await retryResponse; + expect(recoveredResponse.status()).toBe(200); + await expect(recoveredResponse.json()).resolves.toEqual({ + success: true, + }); + await expect( + detail.getByRole("status", { + name: /^(连接测试通过|Connection test passed)$/, + }), + ).toBeVisible(); + expect(upstream.getRequests()).toEqual([ + { + method: "POST", + path: "/ai/tingji/getConversationList", + credentialMatched: true, + responseStatus: 403, + }, + { + method: "POST", + path: "/ai/tingji/getConversationList", + credentialMatched: true, + responseStatus: 200, + }, + ]); + expect(await snapshotDingTalkTestPersistence(userId)).toEqual( + beforeTest, ); - await expect(sourceTest).toHaveAttribute("data-sot-state", "idle"); } finally { - releaseTestResponse?.(); - if (testRouteInstalled) { - await page.unroute("**/api/data-sources/test"); + try { + await restoreDingTalkConnection(userId, originalSnapshot); + } finally { + await upstream.close(); } - await restoreDingTalkConnection(userId, originalSnapshot); } }); }); diff --git a/e2e/data-source-save-readback.spec.ts b/e2e/data-source-save-readback.spec.ts index fd56bea5..bc29d442 100644 --- a/e2e/data-source-save-readback.spec.ts +++ b/e2e/data-source-save-readback.spec.ts @@ -1,3 +1,5 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { createClient } from "@libsql/client"; @@ -6,7 +8,7 @@ import type { Page, Response } from "@playwright/test"; import { ensureSignedIn } from "./helpers/auth"; const E2E_PROVIDER = "dingtalk-a1"; -const E2E_PROVIDER_BASE_URL = "https://meeting-ai-tingji.dingtalk.com"; +const E2E_PROVIDER_DRAFT = "e2e-data-source-save-draft"; const PLAYWRIGHT_EMAIL = "playwright-admin@example.com"; type SourceConnectionSnapshotRow = { @@ -27,6 +29,60 @@ type SourceConnectionSnapshotRow = { updatedAt: number; }; +async function startDingTalkSaveUpstream() { + let requestCount = 0; + let lastRequest: { + method: string | undefined; + path: string | undefined; + credentialMatched: boolean; + } | null = null; + const server = createServer((request, response) => { + lastRequest = { + method: request.method, + path: request.url, + credentialMatched: + request.headers["dt-meeting-agent-token"] === + E2E_PROVIDER_DRAFT, + }; + if ( + request.method !== "POST" || + request.url !== "/ai/tingji/getConversationList" + ) { + response.writeHead(404).end(); + return; + } + + requestCount += 1; + if (request.headers["dt-meeting-agent-token"] !== E2E_PROVIDER_DRAFT) { + response.writeHead(401).end(); + return; + } + + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ data: { items: [] } })); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address() as AddressInfo; + + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }), + getLastRequest: () => lastRequest, + getRequestCount: () => requestCount, + }; +} + function resolveE2ERoot() { return path.resolve( process.env.PLAYWRIGHT_E2E_ROOT ?? path.join(process.cwd(), "tmp/e2e"), @@ -293,7 +349,7 @@ async function restoreProviderConnection( } } -async function seedDisabledFallbackConnection(userId: string) { +async function seedDisabledFallbackConnection(userId: string, baseUrl: string) { const client = createClient({ url: databaseUrl(resolveDatabasePath()) }); const now = Date.now(); @@ -318,7 +374,7 @@ async function seedDisabledFallbackConnection(userId: string) { E2E_PROVIDER, 0, "device-signin", - E2E_PROVIDER_BASE_URL, + baseUrl, JSON.stringify({ syncTitleToSource: true }), null, null, @@ -339,6 +395,33 @@ async function seedDisabledFallbackConnection(userId: string) { } } +async function acquireExclusiveDatabaseLock() { + const client = createClient({ url: databaseUrl(resolveDatabasePath()) }); + let active = false; + + try { + await client.execute("PRAGMA busy_timeout = 10000"); + await client.execute("BEGIN EXCLUSIVE"); + active = true; + } catch (error) { + await client.close(); + throw error; + } + + return async () => { + if (!active) { + return; + } + + active = false; + try { + await client.execute("COMMIT"); + } finally { + await client.close(); + } + }; +} + function getDataSourceReadback(payload: unknown) { const response = readRecord(payload, "data sources response"); if (!Array.isArray(response.sources)) { @@ -366,23 +449,44 @@ async function readDataSourceState(page: Page) { return getDataSourceReadback(await response.json()); } -async function openFallbackProvider(page: Page) { +async function openFallbackProvider( + page: Page, + expectedStatus: RegExp = /待设置|Not configured/, +) { await page.goto("/settings#data-sources", { waitUntil: "domcontentloaded" }); - const section = page.locator('[data-sot-surface="settings-data-sources"]'); - const providerCard = section.locator( - `[data-sot-control="source-provider"][data-sot-provider="${E2E_PROVIDER}"]`, - ); - const detail = section.locator( - `[data-sot-panel="source-provider-detail"][data-sot-provider="${E2E_PROVIDER}"]`, + const dialog = page.getByRole("dialog", { name: /^(设置|Settings)$/ }); + const sourceList = dialog.getByRole("complementary", { + name: /^(数据源列表|Data source list)$/, + }); + const providerName = /钉钉\s*闪记|DingTalk A1 Flash Notes/; + const providerCard = sourceList.getByRole("button", { name: providerName }); + const providerStatusName = new RegExp( + `^(钉钉\\s*闪记|DingTalk A1 Flash Notes): (${expectedStatus.source})$`, + expectedStatus.flags.replace("g", ""), ); - await expect(section).toHaveAttribute("data-sot-load-state", "ready"); - await expect(providerCard).toHaveAttribute("data-sot-status", "needs-setup"); + await expect(dialog).toBeVisible(); + await expect( + providerCard.getByRole("status", { name: providerStatusName }), + ).toBeVisible(); await providerCard.click(); - await expect(detail).toHaveAttribute("data-sot-status", "needs-setup"); + const detail = dialog.getByRole("region", { name: providerName }); + await expect(detail).toBeVisible(); + await expect( + detail.getByRole("status", { name: providerStatusName }), + ).toBeVisible(); - return { detail, providerCard, section }; + return { detail, providerCard }; +} + +function waitForSaveRequest(page: Page) { + return page.waitForRequest((request) => { + const url = new URL(request.url()); + return ( + url.pathname === "/api/data-sources" && request.method() === "PUT" + ); + }); } function waitForSaveResponse(page: Page) { @@ -406,142 +510,243 @@ function waitForDataSourcesReload(page: Page) { } test.describe("Data Sources enable save readback", () => { - test.skip( - !hasGuardedPlaywrightFallback(), - "Requires the guarded local Playwright data-sources fallback.", - ); + test.beforeEach(() => { + if (!hasGuardedPlaywrightFallback()) { + throw new Error( + "This spec requires the guarded local Playwright data-sources fallback.", + ); + } + }); - test("enables a visible fallback source, saves through the real API, and restores its fixture", async ({ + test("preserves a locked-database draft, retries the real save, and reads enabled and disabled states back", async ({ page, }) => { await ensureSignedIn(page); const userId = await getPlaywrightUserId(); const originalSnapshot = await snapshotProviderConnection(userId); + const upstream = await startDingTalkSaveUpstream(); let primaryFlowError: unknown; - let releaseSaveResponse: (() => void) | null = null; - let saveRouteInstalled = false; + let releaseDatabaseLock: (() => Promise) | null = null; try { - await seedDisabledFallbackConnection(userId); + await seedDisabledFallbackConnection(userId, upstream.baseUrl); const beforeSave = await snapshotProviderConnection(userId); expect(beforeSave).toHaveLength(1); expect(beforeSave[0]?.enabled).toBe(0); expect(beforeSave[0]?.secretConfig).toBeNull(); const { detail, providerCard } = await openFallbackProvider(page); - const enableSync = detail.locator( - `[data-sot-control="source-enable-sync"][data-sot-provider="${E2E_PROVIDER}"]`, - ); - const save = detail.locator( - `[data-sot-control="source-save"][data-sot-provider="${E2E_PROVIDER}"]`, - ); + const enableSync = detail.getByRole("switch", { + name: /^(启用同步|Enable sync)$/, + }); + const titleWriteback = detail.getByRole("switch", { + name: /^(标题更新回来源|Title updates to source)$/, + }); + const deviceIdentifier = detail.getByRole("textbox", { + name: /^(设备标识|Device identifier)$/, + }); + const save = detail.getByRole("button", { + name: /^(保存|保存中|已保存|Save|Saving|Saved)$/, + }); await expect(providerCard).toBeVisible(); await expect(enableSync).toHaveAttribute("aria-checked", "false"); - await expect(enableSync).toHaveAttribute("data-sot-enabled", "false"); + await expect(titleWriteback).toHaveAttribute("aria-checked", "true"); + await deviceIdentifier.fill(E2E_PROVIDER_DRAFT); + await titleWriteback.click(); + await expect(titleWriteback).toHaveAttribute("aria-checked", "false"); await expect(save).toBeEnabled(); await enableSync.click(); await expect(enableSync).toHaveAttribute("aria-checked", "true"); - await expect(enableSync).toHaveAttribute("data-sot-enabled", "true"); - let forwardedSaveRequest = false; - let releaseForwardedResponse!: () => void; - const forwardedResponseGate = new Promise((resolve) => { - releaseForwardedResponse = resolve; + releaseDatabaseLock = await acquireExclusiveDatabaseLock(); + const failedSaveRequest = waitForSaveRequest(page); + const failedSaveResponse = waitForSaveResponse(page); + await save.click(); + const failedRequest = await failedSaveRequest; + await expect(detail).toHaveAttribute("aria-busy", "true"); + await expect(save).toHaveAttribute("aria-busy", "true"); + await expect(enableSync).toBeDisabled(); + await expect(save).toBeDisabled(); + + expect(failedRequest.postDataJSON()).toMatchObject({ + provider: E2E_PROVIDER, + enabled: true, + authMode: "device-signin", + baseUrl: upstream.baseUrl, + config: { syncTitleToSource: false }, + secrets: { deviceCredential: E2E_PROVIDER_DRAFT }, }); - await page.route("**/api/data-sources", async (route) => { - if (route.request().method() !== "PUT") { - await route.continue(); - return; - } - forwardedSaveRequest = true; - const response = await route.fetch(); - await forwardedResponseGate; - await route.fulfill({ response }); + const failedResponse = await failedSaveResponse; + expect(failedResponse.status()).toBe(500); + await expect(failedResponse.json()).resolves.toEqual({ + error: "Failed to save data sources", }); - saveRouteInstalled = true; - releaseSaveResponse = releaseForwardedResponse; + await expect( + detail.getByRole("alert", { + name: /^(保存失败|Save failed)$/, + }), + ).toBeVisible(); + await expect(detail).toHaveAttribute("aria-busy", "false"); + await expect(deviceIdentifier).toHaveValue(E2E_PROVIDER_DRAFT); + await expect(enableSync).toHaveAttribute("aria-checked", "true"); + await expect(titleWriteback).toHaveAttribute("aria-checked", "false"); + await expect(save).toBeEnabled(); + expect(upstream.getRequestCount()).toBe(0); + + await releaseDatabaseLock(); + releaseDatabaseLock = null; + expect(await snapshotProviderConnection(userId)).toEqual(beforeSave); + const saveRequest = waitForSaveRequest(page); const saveResponse = waitForSaveResponse(page); const refreshResponse = waitForDataSourcesReload(page); await save.click(); - await expect.poll(() => forwardedSaveRequest).toBe(true); - await expect(detail).toHaveAttribute("data-sot-action-state", "saving"); - await expect(enableSync).toBeDisabled(); - await expect(save).toBeDisabled(); - - releaseSaveResponse(); - releaseSaveResponse = null; const [response, refresh] = await Promise.all([ saveResponse, refreshResponse, ]); + const request = await saveRequest; expect(response.status()).toBe(200); await expect(response.json()).resolves.toEqual({ success: true }); expect(refresh.status()).toBe(200); - const payload = readRecord( - response.request().postDataJSON(), - "data source save payload", - ); + const payload = readRecord(request.postDataJSON(), "data source save payload"); expect(payload.provider).toBe(E2E_PROVIDER); expect(payload.enabled).toBe(true); + expect(payload.config).toEqual({ syncTitleToSource: false }); + expect(payload.secrets).toEqual({ + deviceCredential: E2E_PROVIDER_DRAFT, + }); const persisted = await snapshotProviderConnection(userId); expect(persisted).toHaveLength(1); expect(persisted[0]).toMatchObject({ authMode: "device-signin", - baseUrl: E2E_PROVIDER_BASE_URL, + baseUrl: upstream.baseUrl, enabled: 1, - secretConfig: null, }); + expect(persisted[0]?.config).toBe( + JSON.stringify({ syncTitleToSource: false }), + ); + expect(persisted[0]?.secretConfig).not.toBeNull(); + expect(persisted[0]?.secretConfig).not.toContain(E2E_PROVIDER_DRAFT); const apiReadback = await readDataSourceState(page); expect(apiReadback.enabled).toBe(true); - expect(apiReadback.connected).toBe(false); + expect(apiReadback.connected).toBe(true); expect(apiReadback.authMode).toBe("device-signin"); - expect(apiReadback.baseUrl).toBe(E2E_PROVIDER_BASE_URL); + expect(apiReadback.baseUrl).toBe(upstream.baseUrl); + expect(apiReadback.config).toEqual({ syncTitleToSource: false }); + expect(apiReadback.secretsConfigured).toMatchObject({ + deviceCredential: true, + }); + expect(upstream.getRequestCount()).toBe(1); + expect(upstream.getLastRequest()).toEqual({ + method: "POST", + path: "/ai/tingji/getConversationList", + credentialMatched: true, + }); const pageReload = waitForDataSourcesReload(page); await page.reload({ waitUntil: "domcontentloaded" }); expect((await pageReload).status()).toBe(200); - const reloaded = await openFallbackProvider(page); - const reloadedEnableSync = reloaded.detail.locator( - `[data-sot-control="source-enable-sync"][data-sot-provider="${E2E_PROVIDER}"]`, - ); - await expect(reloaded.providerCard).toHaveAttribute( - "data-sot-status", - "needs-setup", - ); + const reloaded = await openFallbackProvider(page, /已连接|Connected/); + const reloadedEnableSync = reloaded.detail.getByRole("switch", { + name: /^(启用同步|Enable sync)$/, + }); + await expect(reloadedEnableSync).toHaveAttribute("aria-checked", "true"); + + const disabledSaveRequest = waitForSaveRequest(page); + const disabledSaveResponse = waitForSaveResponse(page); + const disabledRefreshResponse = waitForDataSourcesReload(page); + await reloadedEnableSync.click(); await expect(reloadedEnableSync).toHaveAttribute( "aria-checked", - "true", + "false", ); - await expect(reloadedEnableSync).toHaveAttribute( - "data-sot-enabled", - "true", + await reloaded.detail + .getByRole("button", { + name: /^(保存|保存中|已保存|Save|Saving|Saved)$/, + }) + .click(); + const [disabledRequest, disabledResponse, disabledRefresh] = + await Promise.all([ + disabledSaveRequest, + disabledSaveResponse, + disabledRefreshResponse, + ]); + expect(disabledResponse.status()).toBe(200); + expect(disabledRefresh.status()).toBe(200); + expect(disabledRequest.postDataJSON()).toMatchObject({ + provider: E2E_PROVIDER, + enabled: false, + config: { syncTitleToSource: false }, + }); + + const disabledPersisted = await snapshotProviderConnection(userId); + expect(disabledPersisted).toHaveLength(1); + expect(disabledPersisted[0]).toMatchObject({ + enabled: 0, + config: JSON.stringify({ syncTitleToSource: false }), + }); + expect(disabledPersisted[0]?.secretConfig).not.toBeNull(); + expect(disabledPersisted[0]?.secretConfig).not.toContain( + E2E_PROVIDER_DRAFT, ); + const disabledApiReadback = await readDataSourceState(page); + expect(disabledApiReadback.enabled).toBe(false); + expect(disabledApiReadback.connected).toBe(false); + expect(disabledApiReadback.config).toEqual({ + syncTitleToSource: false, + }); + expect(disabledApiReadback.secretsConfigured).toMatchObject({ + deviceCredential: true, + }); + expect(upstream.getRequestCount()).toBe(1); + + const disabledPageReload = waitForDataSourcesReload(page); + await page.reload({ waitUntil: "domcontentloaded" }); + expect((await disabledPageReload).status()).toBe(200); + const disabledReloaded = await openFallbackProvider( + page, + /同步已暂停|Paused/, + ); + await expect( + disabledReloaded.detail.getByRole("switch", { + name: /^(启用同步|Enable sync)$/, + }), + ).toHaveAttribute("aria-checked", "false"); } catch (error) { primaryFlowError = error; throw error; } finally { try { - releaseSaveResponse?.(); - if (saveRouteInstalled) { - await page.unroute("**/api/data-sources"); + try { + await releaseDatabaseLock?.(); + await restoreProviderConnection(userId, originalSnapshot); + expect(await snapshotProviderConnection(userId)).toEqual( + originalSnapshot, + ); + } finally { + await upstream.close(); } - await restoreProviderConnection(userId, originalSnapshot); - expect(await snapshotProviderConnection(userId)).toEqual( - originalSnapshot, - ); } catch (cleanupError) { if (primaryFlowError) { + const primaryMessage = + primaryFlowError instanceof Error + ? primaryFlowError.message + : String(primaryFlowError); + const cleanupMessage = + cleanupError instanceof Error + ? cleanupError.message + : String(cleanupError); throw new AggregateError( [primaryFlowError, cleanupError], - "Data source save test and cleanup both failed.", + `Data source save test and cleanup both failed: primary=${primaryMessage}; cleanup=${cleanupMessage}`, ); } diff --git a/e2e/data-sources-real-server-lifecycle.spec.ts b/e2e/data-sources-real-server-lifecycle.spec.ts deleted file mode 100644 index 47191fad..00000000 --- a/e2e/data-sources-real-server-lifecycle.spec.ts +++ /dev/null @@ -1,809 +0,0 @@ -import { createCipheriv, randomBytes } from "node:crypto"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; -import { createClient } from "@libsql/client"; -import { expect, test } from "@playwright/test"; -import type { Page, Response } from "@playwright/test"; -import { ensureSignedIn } from "./helpers/auth"; - -const E2E_DATA_DIR = path.resolve(process.cwd(), "tmp/e2e/data"); -const E2E_PROVIDER = "dingtalk-a1"; -const E2E_PROVIDER_BASE_URL = "https://meeting-ai-tingji.dingtalk.com"; -const PLAYWRIGHT_E2E_ENCRYPTION_KEY = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - -type SourceConnectionSnapshotRow = { - id: string; - userId: string; - provider: typeof E2E_PROVIDER; - enabled: number; - authMode: string | null; - baseUrl: string | null; - config: string | null; - secretConfig: string | null; - lastSync: number | null; - syncStatus: string; - lastSyncError: string | null; - lastSyncStartedAt: number | null; - lastSyncFinishedAt: number | null; - createdAt: number; - updatedAt: number; -}; - -type SourceDeviceSnapshotRow = { - id: string; - userId: string; - provider: typeof E2E_PROVIDER; - providerDeviceId: string; - name: string; - model: string | null; - versionNumber: number | null; - createdAt: number; - updatedAt: number; -}; - -type ProviderDatabaseSnapshot = { - connections: SourceConnectionSnapshotRow[]; - devices: SourceDeviceSnapshotRow[]; -}; - -type SeededProviderState = { - config: Record; - secretConfig: string; -}; - -function resolveE2ERoot() { - return path.resolve( - process.env.PLAYWRIGHT_E2E_ROOT ?? path.join(process.cwd(), "tmp/e2e"), - ); -} - -function resolveDatabasePath() { - return process.env.DATABASE_PATH - ? path.resolve(process.cwd(), process.env.DATABASE_PATH) - : path.join(E2E_DATA_DIR, "betterainote-e2e.db"); -} - -function resolveLibraryDatabasePath() { - const coreDatabasePath = resolveDatabasePath(); - const parsed = path.parse(coreDatabasePath); - const libraryDatabasePath = path.resolve( - parsed.dir || ".", - `${parsed.name || "betterainote"}-library${parsed.ext || ".db"}`, - ); - assertE2EPath(libraryDatabasePath); - return libraryDatabasePath; -} - -function isE2EDataPath(filePath: string) { - const dataDirectory = path.resolve(resolveE2ERoot(), "data"); - const relativePath = path.relative(dataDirectory, path.resolve(filePath)); - - return ( - relativePath.length > 0 && - relativePath !== ".." && - !relativePath.startsWith(`..${path.sep}`) && - !path.isAbsolute(relativePath) - ); -} - -function assertE2EPath(filePath: string) { - if (!isE2EDataPath(filePath)) { - throw new Error(`Refusing to touch non-E2E database path: ${filePath}`); - } -} - -function databaseUrl(filePath: string) { - assertE2EPath(filePath); - return pathToFileURL(filePath).href; -} - -function isLoopbackPlaywrightBaseUrl() { - try { - const baseUrl = new URL( - process.env.PLAYWRIGHT_BASE_URL ?? - process.env.APP_URL ?? - "http://127.0.0.1:3201", - ); - - return ["127.0.0.1", "localhost", "::1"].includes( - baseUrl.hostname, - ); - } catch { - return false; - } -} - -function hasGuardedPlaywrightFallback() { - return ( - process.env.NODE_ENV === "development" && - process.env.PLAYWRIGHT_E2E_DATA_SOURCES_FALLBACK === "1" && - process.env.PLAYWRIGHT_SKIP_WEBSERVER !== "1" && - isLoopbackPlaywrightBaseUrl() && - isE2EDataPath(resolveDatabasePath()) - ); -} - -async function executeWithBusyRetry( - operation: () => Promise, - attempts = 8, -) { - let lastError: unknown; - - for (let attempt = 0; attempt < attempts; attempt += 1) { - try { - return await operation(); - } catch (error) { - lastError = error; - if ( - !(error instanceof Error) || - !error.message.includes("SQLITE_BUSY") || - attempt === attempts - 1 - ) { - throw error; - } - - await new Promise((resolve) => - setTimeout(resolve, 80 * (attempt + 1)), - ); - } - } - - throw lastError; -} - -function readRequiredString(value: unknown, field: string) { - if (typeof value !== "string") { - throw new Error(`Unexpected ${field} value`); - } - - return value; -} - -function readNullableString(value: unknown, field: string) { - if (value === null || typeof value === "string") { - return value; - } - - throw new Error(`Unexpected ${field} value`); -} - -function readRequiredNumber(value: unknown, field: string) { - if (typeof value !== "number" && typeof value !== "bigint") { - throw new Error(`Unexpected ${field} value`); - } - - const numericValue = Number(value); - if (!Number.isFinite(numericValue)) { - throw new Error(`Unexpected ${field} value`); - } - - return numericValue; -} - -function readNullableNumber(value: unknown, field: string) { - return value === null ? null : readRequiredNumber(value, field); -} - -function readProvider(value: unknown) { - if (value === E2E_PROVIDER) { - return value; - } - - throw new Error("Unexpected data source provider"); -} - -function readRecord(value: unknown, field: string) { - if ( - typeof value !== "object" || - value === null || - Array.isArray(value) - ) { - throw new Error(`Unexpected ${field} value`); - } - - return value as Record; -} - -function parseStoredConfig(config: string | null) { - if (config === null) { - throw new Error("Expected seeded source connection config"); - } - - try { - return readRecord(JSON.parse(config), "source_connections.config"); - } catch (error) { - if (error instanceof Error) { - throw error; - } - - throw new Error("Unable to parse source_connections.config"); - } -} - -async function getPlaywrightUserId() { - const client = createClient({ url: databaseUrl(resolveDatabasePath()) }); - - try { - const result = await executeWithBusyRetry(() => - client.execute({ - sql: "SELECT id FROM users WHERE email = ? LIMIT 1", - args: ["playwright-admin@example.com"], - }), - ); - return readRequiredString(result.rows[0]?.id, "users.id"); - } finally { - await client.close(); - } -} - -async function snapshotProviderDatabaseState( - userId: string, -): Promise { - const core = createClient({ url: databaseUrl(resolveDatabasePath()) }); - const library = createClient({ - url: databaseUrl(resolveLibraryDatabasePath()), - }); - - try { - const [connectionResult, deviceResult] = await Promise.all([ - executeWithBusyRetry(() => - core.execute({ - sql: `SELECT id, user_id, provider, enabled, auth_mode, base_url, - config, secret_config, last_sync, sync_status, - last_sync_error, last_sync_started_at, - last_sync_finished_at, created_at, updated_at - FROM source_connections - WHERE user_id = ? AND provider = ?`, - args: [userId, E2E_PROVIDER], - }), - ), - executeWithBusyRetry(() => - library.execute({ - sql: `SELECT id, user_id, provider, provider_device_id, name, - model, version_number, created_at, updated_at - FROM source_devices - WHERE user_id = ? AND provider = ? - ORDER BY provider_device_id`, - args: [userId, E2E_PROVIDER], - }), - ), - ]); - - return { - connections: connectionResult.rows.map( - (row): SourceConnectionSnapshotRow => ({ - id: readRequiredString(row.id, "source_connections.id"), - userId: readRequiredString( - row.user_id, - "source_connections.user_id", - ), - provider: readProvider(row.provider), - enabled: readRequiredNumber( - row.enabled, - "source_connections.enabled", - ), - authMode: readNullableString( - row.auth_mode, - "source_connections.auth_mode", - ), - baseUrl: readNullableString( - row.base_url, - "source_connections.base_url", - ), - config: readNullableString( - row.config, - "source_connections.config", - ), - secretConfig: readNullableString( - row.secret_config, - "source_connections.secret_config", - ), - lastSync: readNullableNumber( - row.last_sync, - "source_connections.last_sync", - ), - syncStatus: readRequiredString( - row.sync_status, - "source_connections.sync_status", - ), - lastSyncError: readNullableString( - row.last_sync_error, - "source_connections.last_sync_error", - ), - lastSyncStartedAt: readNullableNumber( - row.last_sync_started_at, - "source_connections.last_sync_started_at", - ), - lastSyncFinishedAt: readNullableNumber( - row.last_sync_finished_at, - "source_connections.last_sync_finished_at", - ), - createdAt: readRequiredNumber( - row.created_at, - "source_connections.created_at", - ), - updatedAt: readRequiredNumber( - row.updated_at, - "source_connections.updated_at", - ), - }), - ), - devices: deviceResult.rows.map( - (row): SourceDeviceSnapshotRow => ({ - id: readRequiredString(row.id, "source_devices.id"), - userId: readRequiredString( - row.user_id, - "source_devices.user_id", - ), - provider: readProvider(row.provider), - providerDeviceId: readRequiredString( - row.provider_device_id, - "source_devices.provider_device_id", - ), - name: readRequiredString(row.name, "source_devices.name"), - model: readNullableString(row.model, "source_devices.model"), - versionNumber: readNullableNumber( - row.version_number, - "source_devices.version_number", - ), - createdAt: readRequiredNumber( - row.created_at, - "source_devices.created_at", - ), - updatedAt: readRequiredNumber( - row.updated_at, - "source_devices.updated_at", - ), - }), - ), - }; - } finally { - await Promise.all([core.close(), library.close()]); - } -} - -async function restoreProviderDatabaseState( - userId: string, - snapshot: ProviderDatabaseSnapshot, -) { - const core = createClient({ url: databaseUrl(resolveDatabasePath()) }); - const library = createClient({ - url: databaseUrl(resolveLibraryDatabasePath()), - }); - const connectionStatements = [ - { - sql: "DELETE FROM source_connections WHERE user_id = ? AND provider = ?", - args: [userId, E2E_PROVIDER], - }, - ...snapshot.connections.map((row) => ({ - sql: `INSERT INTO source_connections ( - id, user_id, provider, enabled, auth_mode, base_url, - config, secret_config, last_sync, sync_status, - last_sync_error, last_sync_started_at, - last_sync_finished_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - args: [ - row.id, - row.userId, - row.provider, - row.enabled, - row.authMode, - row.baseUrl, - row.config, - row.secretConfig, - row.lastSync, - row.syncStatus, - row.lastSyncError, - row.lastSyncStartedAt, - row.lastSyncFinishedAt, - row.createdAt, - row.updatedAt, - ], - })), - ]; - const sourceDeviceStatements = [ - { - sql: "DELETE FROM source_devices WHERE user_id = ? AND provider = ?", - args: [userId, E2E_PROVIDER], - }, - ...snapshot.devices.map((row) => ({ - sql: `INSERT INTO source_devices ( - id, user_id, provider, provider_device_id, name, - model, version_number, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - args: [ - row.id, - row.userId, - row.provider, - row.providerDeviceId, - row.name, - row.model, - row.versionNumber, - row.createdAt, - row.updatedAt, - ], - })), - ]; - - try { - const results = await Promise.allSettled([ - executeWithBusyRetry(() => core.batch(connectionStatements, "write")), - executeWithBusyRetry(() => - library.batch(sourceDeviceStatements, "write"), - ), - ]); - const failure = results.find((result) => result.status === "rejected"); - if (failure?.status === "rejected") { - throw failure.reason; - } - } finally { - await Promise.all([core.close(), library.close()]); - } -} - -function encryptWithPlaywrightE2EKey(plaintext: string) { - const iv = randomBytes(16); - const cipher = createCipheriv( - "aes-256-gcm", - Buffer.from(PLAYWRIGHT_E2E_ENCRYPTION_KEY, "hex"), - iv, - ); - const encrypted = Buffer.concat([ - cipher.update(plaintext, "utf8"), - cipher.final(), - ]); - - return [ - iv.toString("hex"), - cipher.getAuthTag().toString("hex"), - encrypted.toString("hex"), - ].join(":"); -} - -async function seedExistingProviderConnection( - userId: string, - enabled: boolean, -): Promise { - const core = createClient({ url: databaseUrl(resolveDatabasePath()) }); - const library = createClient({ - url: databaseUrl(resolveLibraryDatabasePath()), - }); - const now = Date.now(); - const config = { syncTitleToSource: true }; - const secretConfig = encryptWithPlaywrightE2EKey( - JSON.stringify({ - deviceCredential: "e2e-data-sources-lifecycle-secret-sentinel", - }), - ); - - try { - const results = await Promise.allSettled([ - executeWithBusyRetry(() => - core.batch( - [ - { - sql: "DELETE FROM source_connections WHERE user_id = ? AND provider = ?", - args: [userId, E2E_PROVIDER], - }, - { - sql: `INSERT INTO source_connections ( - id, user_id, provider, enabled, auth_mode, base_url, - config, secret_config, last_sync, sync_status, - last_sync_error, last_sync_started_at, - last_sync_finished_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - args: [ - `e2e-data-sources-lifecycle-connection-${now}`, - userId, - E2E_PROVIDER, - enabled ? 1 : 0, - "device-signin", - E2E_PROVIDER_BASE_URL, - JSON.stringify(config), - secretConfig, - now - 60_000, - "error", - "e2e-lifecycle-sync-error", - now - 30_000, - now - 20_000, - now, - now, - ], - }, - ], - "write", - ), - ), - executeWithBusyRetry(() => - library.batch( - [ - { - sql: "DELETE FROM source_devices WHERE user_id = ? AND provider = ?", - args: [userId, E2E_PROVIDER], - }, - { - sql: `INSERT INTO source_devices ( - id, user_id, provider, provider_device_id, name, - model, version_number, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - args: [ - `e2e-data-sources-lifecycle-device-${now}`, - userId, - E2E_PROVIDER, - "e2e-data-sources-lifecycle-device", - "E2E lifecycle device", - "E2E device model", - 1, - now, - now, - ], - }, - ], - "write", - ), - ), - ]); - const failure = results.find((result) => result.status === "rejected"); - if (failure?.status === "rejected") { - throw failure.reason; - } - } finally { - await Promise.all([core.close(), library.close()]); - } - - return { config, secretConfig }; -} - -function getOnlyConnection(snapshot: ProviderDatabaseSnapshot) { - if (snapshot.connections.length !== 1) { - throw new Error("Expected exactly one seeded data source connection"); - } - - return snapshot.connections[0]; -} - -async function openDataSourcesSettings(page: Page) { - await page.goto("/settings#data-sources", { waitUntil: "domcontentloaded" }); - const section = page.locator('[data-sot-surface="settings-data-sources"]'); - await expect(section).toBeVisible(); - await expect(section).toHaveAttribute("data-sot-load-state", "ready"); - return section; -} - -async function selectSeededProvider(page: Page) { - const section = await openDataSourcesSettings(page); - const providerCard = section.locator( - `[data-sot-control="source-provider"][data-sot-provider="${E2E_PROVIDER}"]`, - ); - await expect(providerCard).toBeVisible(); - await providerCard.click(); - - const detail = section.locator( - `[data-sot-panel="source-provider-detail"][data-sot-provider="${E2E_PROVIDER}"]`, - ); - await expect(detail).toBeVisible(); - - return { detail, providerCard, section }; -} - -function sourceEnableSyncControl(page: Page) { - return page.locator( - `[data-sot-control="source-enable-sync"][data-sot-provider="${E2E_PROVIDER}"]`, - ); -} - -function waitForPostResponse(page: Page, pathname: string) { - return page.waitForResponse((response) => { - const url = new URL(response.url()); - return ( - url.pathname === pathname && response.request().method() === "POST" - ); - }); -} - -function waitForDataSourcesReload(page: Page) { - return page.waitForResponse((response) => { - const url = new URL(response.url()); - return ( - url.pathname === "/api/data-sources" && - response.request().method() === "GET" - ); - }); -} - -async function expectSuccess(response: Response) { - expect(response.status()).toBe(200); - await expect(response.json()).resolves.toEqual({ success: true }); -} - -test.describe("Data Sources real server lifecycle actions", () => { - test.skip( - !hasGuardedPlaywrightFallback(), - "Requires the guarded local Playwright data-sources fallback.", - ); - - test("Reconnect posts to the real route and restores the seeded connection", async ({ - page, - }) => { - await ensureSignedIn(page); - const userId = await getPlaywrightUserId(); - const originalSnapshot = await snapshotProviderDatabaseState(userId); - - try { - const seeded = await seedExistingProviderConnection(userId, false); - const seededSnapshot = await snapshotProviderDatabaseState(userId); - const seededConnection = getOnlyConnection(seededSnapshot); - const { detail, providerCard } = await selectSeededProvider(page); - const reconnect = detail.locator( - '[data-sot-control="source-reconnect"]', - ); - - await expect(reconnect).toBeEnabled(); - const reconnectResponse = waitForPostResponse( - page, - "/api/data-sources/reconnect", - ); - const reloadResponse = waitForDataSourcesReload(page); - await reconnect.click(); - const [postResponse, getResponse] = await Promise.all([ - reconnectResponse, - reloadResponse, - ]); - - await expectSuccess(postResponse); - expect(getResponse.status()).toBe(200); - - const payload = readRecord( - postResponse.request().postDataJSON(), - "reconnect request payload", - ); - expect(payload.provider).toBe(E2E_PROVIDER); - expect(payload.enabled).toBe(true); - const payloadConfig = readRecord(payload.config, "reconnect config"); - - const persistedSnapshot = - await snapshotProviderDatabaseState(userId); - const persistedConnection = getOnlyConnection(persistedSnapshot); - expect(persistedConnection.enabled).toBe(1); - expect(persistedConnection.authMode).toBe(payload.authMode); - expect(persistedConnection.baseUrl).toBe(payload.baseUrl); - expect(parseStoredConfig(persistedConnection.config)).toEqual({ - ...seeded.config, - ...payloadConfig, - }); - expect(persistedConnection.secretConfig).toBe(seeded.secretConfig); - expect(persistedConnection.lastSync).toBe(seededConnection.lastSync); - expect(persistedConnection.syncStatus).toBe("idle"); - expect(persistedConnection.lastSyncError).toBeNull(); - expect(persistedConnection.lastSyncStartedAt).toBeNull(); - expect(persistedConnection.lastSyncFinishedAt).toBeNull(); - expect(persistedSnapshot.devices).toEqual(seededSnapshot.devices); - - await expect(detail).toHaveAttribute( - "data-sot-action-state", - "reconnected", - ); - await expect(reconnect).toHaveAttribute( - "data-sot-state", - "reconnected", - ); - await expect(sourceEnableSyncControl(page)).toHaveAttribute( - "aria-checked", - "true", - ); - - const pageReloadResponse = waitForDataSourcesReload(page); - await page.reload({ waitUntil: "domcontentloaded" }); - expect((await pageReloadResponse).status()).toBe(200); - - const reloaded = await selectSeededProvider(page); - await expect(reloaded.providerCard).toHaveAttribute( - "data-sot-status", - "connected", - ); - await expect(reloaded.detail).toHaveAttribute( - "data-sot-status", - "connected", - ); - await expect(sourceEnableSyncControl(page)).toHaveAttribute( - "data-sot-enabled", - "true", - ); - } finally { - await restoreProviderDatabaseState(userId, originalSnapshot); - } - }); - - test("Disconnect posts to the real route and clears the connection without removing devices", async ({ - page, - }) => { - await ensureSignedIn(page); - const userId = await getPlaywrightUserId(); - const originalSnapshot = await snapshotProviderDatabaseState(userId); - - try { - const seeded = await seedExistingProviderConnection(userId, true); - const seededSnapshot = await snapshotProviderDatabaseState(userId); - const seededConnection = getOnlyConnection(seededSnapshot); - const { detail } = await selectSeededProvider(page); - const disconnect = detail.locator( - '[data-sot-control="source-disconnect"]', - ); - - await expect(disconnect).toBeEnabled(); - const disconnectResponse = waitForPostResponse( - page, - "/api/data-sources/disconnect", - ); - const reloadResponse = waitForDataSourcesReload(page); - await disconnect.click(); - const [postResponse, getResponse] = await Promise.all([ - disconnectResponse, - reloadResponse, - ]); - - await expectSuccess(postResponse); - expect(getResponse.status()).toBe(200); - expect( - readRecord( - postResponse.request().postDataJSON(), - "disconnect request payload", - ), - ).toEqual({ provider: E2E_PROVIDER }); - - const persistedSnapshot = - await snapshotProviderDatabaseState(userId); - const persistedConnection = getOnlyConnection(persistedSnapshot); - expect(persistedConnection.enabled).toBe(0); - expect(persistedConnection.authMode).toBe( - seededConnection.authMode, - ); - expect(persistedConnection.baseUrl).toBe(seededConnection.baseUrl); - expect(parseStoredConfig(persistedConnection.config)).toEqual( - parseStoredConfig(seededConnection.config), - ); - expect(persistedConnection.secretConfig).toBeNull(); - expect(persistedConnection.lastSync).toBe(seededConnection.lastSync); - expect(persistedConnection.syncStatus).toBe("idle"); - expect(persistedConnection.lastSyncError).toBeNull(); - expect(persistedConnection.lastSyncStartedAt).toBeNull(); - expect(persistedConnection.lastSyncFinishedAt).toBeNull(); - expect(persistedSnapshot.devices).toEqual(seededSnapshot.devices); - expect(seeded.secretConfig).toBe(seededConnection.secretConfig); - - await expect(detail).toHaveAttribute( - "data-sot-action-state", - "disconnected", - ); - await expect(disconnect).toHaveAttribute( - "data-sot-state", - "disconnected", - ); - await expect(sourceEnableSyncControl(page)).toHaveAttribute( - "aria-checked", - "false", - ); - - const pageReloadResponse = waitForDataSourcesReload(page); - await page.reload({ waitUntil: "domcontentloaded" }); - expect((await pageReloadResponse).status()).toBe(200); - - const reloaded = await selectSeededProvider(page); - await expect(reloaded.providerCard).toHaveAttribute( - "data-sot-status", - "needs-setup", - ); - await expect(reloaded.detail).toHaveAttribute( - "data-sot-status", - "needs-setup", - ); - await expect(sourceEnableSyncControl(page)).toHaveAttribute( - "data-sot-enabled", - "false", - ); - } finally { - await restoreProviderDatabaseState(userId, originalSnapshot); - } - }); -}); diff --git a/e2e/data-sources-settings.spec.ts b/e2e/data-sources-settings.spec.ts index 08eed2e2..5b17de57 100644 --- a/e2e/data-sources-settings.spec.ts +++ b/e2e/data-sources-settings.spec.ts @@ -12,6 +12,10 @@ type SourceState = { secretConfig: string | null; }; +type CoreDatabaseLock = { + release: () => Promise; +}; + function resolveDatabasePath() { const databasePath = process.env.DATABASE_PATH; if (!databasePath) { @@ -41,6 +45,31 @@ async function withDatabase(callback: (client: ReturnType { + const client = createClient({ url: databaseUrl(resolveDatabasePath()) }); + let released = false; + + try { + await client.execute("PRAGMA busy_timeout = 0"); + await client.execute("BEGIN EXCLUSIVE"); + } catch (error) { + client.close(); + throw error; + } + + return { + async release() { + if (released) return; + released = true; + try { + await client.execute("COMMIT"); + } finally { + client.close(); + } + }, + }; +} + async function getE2eUserId() { return withDatabase(async (client) => { const result = await client.execute({ @@ -81,6 +110,83 @@ async function markDingTalkConnectionExpired(userId: string) { }); } +async function setSourceRuntimeState( + userId: string, + provider: string, + params: { + config?: Record; + lastSyncError?: string | null; + syncStatus?: "error" | "idle" | "syncing"; + }, +) { + await withDatabase(async (client) => { + const result = await client.execute({ + sql: `UPDATE source_connections + SET config = COALESCE(?, config), + sync_status = COALESCE(?, sync_status), + last_sync_error = ?, + updated_at = ? + WHERE user_id = ? AND provider = ?`, + args: [ + params.config === undefined ? null : JSON.stringify(params.config), + params.syncStatus ?? null, + params.lastSyncError ?? null, + Date.now(), + userId, + provider, + ], + }); + + if (result.rowsAffected !== 1) { + throw new Error(`Unable to seed the ${provider} runtime state`); + } + }); +} + +async function seedSourceConnection( + userId: string, + provider: string, + params: { + config?: Record; + enabled?: boolean; + lastSyncError?: string | null; + syncStatus?: "error" | "idle" | "syncing"; + } = {}, +) { + await withDatabase(async (client) => { + const now = Date.now(); + await client.execute({ + sql: `INSERT INTO source_connections ( + id, user_id, provider, enabled, auth_mode, base_url, config, + secret_config, sync_status, last_sync_error, + last_sync_started_at, last_sync_finished_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, NULL, NULL, ?, NULL, ?, ?, NULL, NULL, ?, ?) + ON CONFLICT(user_id, provider) DO UPDATE SET + enabled = excluded.enabled, + auth_mode = NULL, + base_url = NULL, + config = excluded.config, + secret_config = NULL, + sync_status = excluded.sync_status, + last_sync_error = excluded.last_sync_error, + last_sync_started_at = NULL, + last_sync_finished_at = NULL, + updated_at = excluded.updated_at`, + args: [ + `e2e-data-source-${provider}-${now}`, + userId, + provider, + params.enabled ? 1 : 0, + JSON.stringify(params.config ?? {}), + params.syncStatus ?? "idle", + params.lastSyncError ?? null, + now, + now, + ], + }); + }); +} + async function readSourceState(userId: string, provider: string): Promise { return withDatabase(async (client) => { const result = await client.execute({ @@ -149,6 +255,18 @@ function sourceStatus(detail: Locator, label: RegExp) { return detail.getByRole("status").filter({ hasText: label }).first(); } +async function expectPlaudDraftToRemain(detail: Locator) { + await expect(detail.locator("#plaud-source-secret")).toHaveValue( + "local-e2e-credential", + ); + await expect(detail.locator("#plaud-source-custom-api-base")).toHaveValue( + "not-a-url", + ); + await expect( + detail.getByRole("switch", { name: "启用同步" }), + ).toHaveAttribute("aria-checked", "true"); +} + async function openDataSources(page: Page) { const currentUrl = new URL(page.url()); if ( @@ -183,13 +301,46 @@ async function getSourceFromApi(page: Page, provider: string) { return source; } -test("Data Sources persists disabled state, renders a real test error, and clears expired state after disconnect", async ({ +test("Data Sources waits for the real locked core database and preserves failed drafts", async ({ page, }) => { await ensureSignedIn(page); await setChineseDisplay(page); - let section = await openDataSources(page); + const userId = await getE2eUserId(); + await seedSourceConnection(userId, "plaud"); + + await page.goto("/settings", { waitUntil: "domcontentloaded" }); + await expect(settingsDialog(page)).toBeVisible(); + const displayNavigation = settingsDialog(page).getByRole("button", { + name: "显示设置", + }); + await displayNavigation.click(); + await expect(displayNavigation).toHaveAttribute("aria-current", "page"); + + const databaseLock = await lockCoreDatabase(); + try { + const loadResponse = page.waitForResponse( + (response) => + new URL(response.url()).pathname === "/api/data-sources" && + response.request().method() === "GET", + ); + const dataSourcesNavigation = settingsDialog(page).getByRole("button", { + name: "数据源", + }); + await dataSourcesNavigation.click(); + const section = dataSourcesSection(page); + + await expect(section).toBeVisible(); + await expect(section).toHaveAttribute("aria-busy", "true"); + await databaseLock.release(); + expect((await loadResponse).status()).toBe(200); + await expect(section).toHaveAttribute("aria-busy", "false"); + } finally { + await databaseLock.release(); + } + + const section = dataSourcesSection(page); await sourceButton(section, /Plaud/).click(); const plaudDetail = sourceDetail(section, /Plaud/); const plaudServer = plaudDetail.getByRole("combobox", { @@ -197,56 +348,142 @@ test("Data Sources persists disabled state, renders a real test error, and clear }); await chooseShadcnSelectOption(page, plaudServer, "自定义"); - await page.locator("#plaud-source-secret").fill("e2e-test-credential"); + await page.locator("#plaud-source-secret").fill("local-e2e-credential"); await page.locator("#plaud-source-custom-api-base").fill("not-a-url"); + const enabledDraft = plaudDetail.getByRole("switch", { + name: "启用同步", + }); + await enabledDraft.click(); + await expect(sourceStatus(plaudDetail, /已配置|Configured/)).toBeVisible(); + + const testRequest = page.waitForRequest( + (request) => + new URL(request.url()).pathname === "/api/data-sources/test" && + request.method() === "POST", + ); const testResponse = page.waitForResponse( (response) => new URL(response.url()).pathname === "/api/data-sources/test" && response.request().method() === "POST", ); - await plaudDetail.getByRole("button", { name: "测试连接" }).click(); - expect((await testResponse).status()).toBe(400); - await expect( - plaudDetail - .locator('[data-slot="alert"]') - .filter({ hasText: "连接测试失败" }), - ).toBeVisible(); - expect((await getSourceFromApi(page, "plaud")).enabled).toBe(false); - - await sourceButton(section, /讯飞听见|iFLYTEK iflyrec/).click(); - const iflyrecDetail = sourceDetail(section, /讯飞听见|iFLYTEK iflyrec/); - await page.locator("#iflyrec-source-secret").fill("e2e-session-id"); + const testButton = plaudDetail.getByRole("button", { + name: /测试连接|测试中/, + }); + const testLock = await lockCoreDatabase(); + try { + await Promise.all([ + testRequest, + testButton.click(), + expect(testButton).toBeDisabled({ timeout: 1_000 }), + expect(testButton).toHaveAttribute("aria-busy", "true", { + timeout: 1_000, + }), + ]); + await testLock.release(); + expect((await testResponse).status()).toBe(400); + await expect( + plaudDetail + .locator('[data-slot="alert"]') + .filter({ hasText: "连接测试失败" }), + ).toBeVisible(); + await expect(section).toHaveAttribute("aria-busy", "false"); + await expect(testButton).toBeEnabled(); + await expect(testButton).toHaveAttribute("aria-busy", "false"); + await expectPlaudDraftToRemain(plaudDetail); + } finally { + await testLock.release(); + } + const testFailureApiState = await getSourceFromApi(page, "plaud"); + expect(testFailureApiState.enabled).toBe(false); + const saveRequest = page.waitForRequest( + (request) => + new URL(request.url()).pathname === "/api/data-sources" && + request.method() === "PUT", + ); const saveResponse = page.waitForResponse( (response) => new URL(response.url()).pathname === "/api/data-sources" && response.request().method() === "PUT", ); - await iflyrecDetail.getByRole("button", { name: "保存" }).click(); - expect((await saveResponse).status()).toBe(200); - await expect(sourceStatus(iflyrecDetail, /同步已暂停/)).toBeVisible(); + const saveButton = plaudDetail.getByRole("button", { + name: /保存|保存中/, + }); + const saveLock = await lockCoreDatabase(); + try { + await Promise.all([ + saveRequest, + saveButton.click(), + expect(saveButton).toBeDisabled({ timeout: 1_000 }), + expect(saveButton).toHaveAttribute("aria-busy", "true", { + timeout: 1_000, + }), + ]); + await saveLock.release(); + expect((await saveResponse).status()).toBe(400); + await expect( + plaudDetail + .locator('[data-slot="alert"]') + .filter({ hasText: "保存失败" }), + ).toBeVisible(); + await expect(section).toHaveAttribute("aria-busy", "false"); + await expect(testButton).toBeEnabled(); + await expect(saveButton).toBeEnabled(); + await expect(saveButton).toHaveAttribute("aria-busy", "false"); + await expectPlaudDraftToRemain(plaudDetail); + } finally { + await saveLock.release(); + } - const iflyrecApiState = await getSourceFromApi(page, "iflyrec"); - expect(iflyrecApiState.enabled).toBe(false); - expect(iflyrecApiState.secretsConfigured).toMatchObject({ sessionId: true }); + const saveFailureApiState = await getSourceFromApi(page, "plaud"); + expect(saveFailureApiState.enabled).toBe(false); + const persistedState = await readSourceState(userId, "plaud"); + expect(persistedState.enabled).toBe(false); + expect(persistedState.secretConfig).toBeNull(); + expect(persistedState.config).not.toHaveProperty("customApiBase"); +}); - const userId = await getE2eUserId(); - const persistedIflyrec = await readSourceState(userId, "iflyrec"); - expect(persistedIflyrec.enabled).toBe(false); - expect(persistedIflyrec.secretConfig).not.toBeNull(); +test("Data Sources validates missing Plaud sign-in details locally and clears expired state after disconnect", async ({ + page, +}) => { + await ensureSignedIn(page); + await setChineseDisplay(page); + let section = await openDataSources(page); - const seedResponse = await page.request.put("/api/data-sources", { - data: { - authMode: "device-signin", - baseUrl: "https://meeting-ai-tingji.dingtalk.com", - config: { syncTitleToSource: false }, - enabled: false, - provider: "dingtalk-a1", - secrets: { deviceCredential: "e2e-device-credential" }, - }, + await sourceButton(section, /Plaud/).click(); + const plaudDetail = sourceDetail(section, /Plaud/); + const plaudServer = plaudDetail.getByRole("combobox", { + name: "站点版本", }); - expect(seedResponse.ok()).toBe(true); + + await chooseShadcnSelectOption(page, plaudServer, "自定义"); + await page.locator("#plaud-source-custom-api-base").fill("not-a-url"); + + let testRequests = 0; + page.on("request", (request) => { + if ( + new URL(request.url()).pathname === "/api/data-sources/test" && + request.method() === "POST" + ) { + testRequests += 1; + } + }); + await plaudDetail.getByRole("button", { name: "测试连接" }).click(); + const incompletePlaudBanner = plaudDetail + .locator('[data-slot="alert"]') + .filter({ hasText: "请先补齐登录信息,再测试连接。" }); + await expect( + incompletePlaudBanner.getByText("信息不完整", { exact: true }), + ).toBeVisible(); + await expect(incompletePlaudBanner).toContainText( + "请先补齐登录信息,再测试连接。", + ); + expect(testRequests).toBe(0); + expect((await getSourceFromApi(page, "plaud")).enabled).toBe(false); + + const userId = await getE2eUserId(); + await seedSourceConnection(userId, "dingtalk-a1"); await markDingTalkConnectionExpired(userId); const expiredApiState = await getSourceFromApi(page, "dingtalk-a1"); @@ -282,14 +519,6 @@ test("Data Sources persists disabled state, renders a real test error, and clear expect(disconnectedDatabaseState.config).not.toHaveProperty("connectionStatus"); section = await openDataSources(page); - await sourceButton(section, /讯飞听见|iFLYTEK iflyrec/).click(); - await expect( - sourceStatus( - sourceDetail(section, /讯飞听见|iFLYTEK iflyrec/), - /同步已暂停|Paused/, - ), - ).toBeVisible(); - await sourceButton(section, /钉钉\s*闪记|DingTalk A1 Flash Notes/).click(); await expect( sourceStatus( @@ -298,3 +527,117 @@ test("Data Sources persists disabled state, renders a real test error, and clear ), ).toBeVisible(); }); + +test("Data Sources renders real persisted runtime states for all supported providers", async ({ + page, +}) => { + await ensureSignedIn(page); + await setChineseDisplay(page); + + const userId = await getE2eUserId(); + for (const provider of [ + "dingtalk-a1", + "ticnote", + "plaud", + "feishu-minutes", + "iflyrec", + ]) { + await seedSourceConnection(userId, provider); + } + + let section = await openDataSources(page); + + for (const provider of [ + /钉钉\s*闪记|DingTalk A1 Flash Notes/, + /TicNote/, + /Plaud/, + /飞书妙记|Feishu Minutes/, + /讯飞听见|iFLYTEK iflyrec/, + ]) { + await sourceButton(section, provider).click(); + await expect( + sourceStatus(sourceDetail(section, provider), /待设置|Not configured/), + ).toBeVisible(); + } + + await setSourceRuntimeState(userId, "dingtalk-a1", { + config: { connectionStatus: "expired" }, + syncStatus: "idle", + }); + await setSourceRuntimeState(userId, "ticnote", { + syncStatus: "syncing", + }); + await setSourceRuntimeState(userId, "plaud", { + lastSyncError: "Source update did not complete.", + syncStatus: "error", + }); + await setSourceRuntimeState(userId, "feishu-minutes", { + lastSyncError: "permission-denied", + syncStatus: "error", + }); + + section = await openDataSources(page); + + const expectedStates = [ + { + provider: /钉钉\s*闪记|DingTalk A1 Flash Notes/, + status: /需要重新登录|Re-auth required/, + }, + { provider: /TicNote/, status: /同步中|Syncing/ }, + { provider: /Plaud/, status: /同步失败|Sync failed/ }, + { + provider: /飞书妙记|Feishu Minutes/, + status: /需要授权|Permission required/, + }, + { + provider: /讯飞听见|iFLYTEK iflyrec/, + status: /待设置|Not configured/, + }, + ]; + + for (const expected of expectedStates) { + await sourceButton(section, expected.provider).click(); + await expect( + sourceStatus(sourceDetail(section, expected.provider), expected.status), + ).toBeVisible(); + } + + const response = await page.request.get("/api/data-sources"); + expect(response.ok()).toBe(true); + const payload = (await response.json()) as { + sources: Array<{ + enabled: boolean; + provider: string; + syncStatus: string; + }>; + }; + expect(payload.sources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + enabled: false, + provider: "dingtalk-a1", + syncStatus: "idle", + }), + expect.objectContaining({ + enabled: false, + provider: "ticnote", + syncStatus: "syncing", + }), + expect.objectContaining({ + enabled: false, + provider: "plaud", + syncStatus: "error", + }), + expect.objectContaining({ + enabled: false, + provider: "feishu-minutes", + syncStatus: "error", + }), + expect.objectContaining({ + enabled: false, + provider: "iflyrec", + syncStatus: "idle", + }), + ]), + ); +}); diff --git a/e2e/display-settings-persistence.spec.ts b/e2e/display-settings-persistence.spec.ts index 6071e68b..8298d696 100644 --- a/e2e/display-settings-persistence.spec.ts +++ b/e2e/display-settings-persistence.spec.ts @@ -3,6 +3,11 @@ import { ensureSignedIn } from "./helpers/auth"; const THEME_VALUES = ["system", "light", "dark"] as const; type Theme = (typeof THEME_VALUES)[number]; +const THEME_LABELS: Record = { + dark: /^(深色|Dark)$/, + light: /^(浅色|Light)$/, + system: /^(自动|Auto)$/, +}; function isTheme(value: unknown): value is Theme { return ( @@ -12,15 +17,15 @@ function isTheme(value: unknown): value is Theme { } function displaySection(page: Page) { - return page.locator( - '[data-sot-surface="settings-section"][data-sot-section="appearance"]', - ); + return page.getByRole("region", { + name: /^(显示设置|Display Settings)$/, + }); } function themeSegment(page: Page, theme: Theme) { - return displaySection(page).locator( - `[data-sot-control="theme"][data-sot-value="${theme}"]`, - ); + return displaySection(page) + .getByRole("radiogroup", { name: /^(主题|Theme)$/ }) + .getByRole("radio", { name: THEME_LABELS[theme] }); } async function expectThemeSelected(page: Page, theme: Theme) { @@ -28,7 +33,6 @@ async function expectThemeSelected(page: Page, theme: Theme) { await expect(segment).toHaveAttribute("aria-checked", "true"); await expect(segment).toHaveAttribute("data-state", "on"); - await expect(segment).toHaveAttribute("data-sot-state", "selected"); } async function readTheme(page: Page): Promise { @@ -61,7 +65,7 @@ test("display preferences UI save survives reload and real API readback", async try { await page.goto("/settings#appearance", { waitUntil: "domcontentloaded" }); await expect(displaySection(page)).toBeVisible(); - await expect(displaySection(page)).toHaveAttribute("data-sot-state", "ready"); + await expect(displaySection(page)).toHaveAttribute("aria-busy", "false"); await expectThemeSelected(page, originalTheme); const saveResponse = page.waitForResponse( @@ -78,7 +82,7 @@ test("display preferences UI save survives reload and real API readback", async expect(await readTheme(page)).toBe(savedTheme); await page.reload({ waitUntil: "domcontentloaded" }); - await expect(displaySection(page)).toHaveAttribute("data-sot-state", "ready"); + await expect(displaySection(page)).toHaveAttribute("aria-busy", "false"); await expectThemeSelected(page, savedTheme); await expect(page.locator("html")).toHaveAttribute("data-theme", savedTheme); } catch (error) { @@ -99,8 +103,8 @@ test("display preferences UI save survives reload and real API readback", async await page.reload({ waitUntil: "domcontentloaded" }); await expect(displaySection(page)).toHaveAttribute( - "data-sot-state", - "ready", + "aria-busy", + "false", ); await expectThemeSelected(page, originalTheme); await expect(page.locator("html")).toHaveAttribute( diff --git a/e2e/fixtures/canonical-sot-verifier/integrity.json b/e2e/fixtures/canonical-sot-verifier/integrity.json new file mode 100644 index 00000000..e287cc7e --- /dev/null +++ b/e2e/fixtures/canonical-sot-verifier/integrity.json @@ -0,0 +1,17 @@ +{ + "fixtureRole": "synthetic canonical SOT verifier structure fixture", + "sourceMaterial": "synthetic; no private handoff content copied", + "prohibitedContent": [ + "recordings", + "transcripts", + "tokens", + "private identifiers" + ], + "requiredFiles": [ + "project/ui_kits/web/index.html" + ], + "snapshot": { + "fileCount": 1, + "manifestSha256": "c68935faf874d06a3003526cc37d78997c2ed93482863036b4315b6151d10d3b" + } +} diff --git a/e2e/fixtures/canonical-sot-verifier/root/project/ui_kits/web/index.html b/e2e/fixtures/canonical-sot-verifier/root/project/ui_kits/web/index.html new file mode 100644 index 00000000..cf15a541 --- /dev/null +++ b/e2e/fixtures/canonical-sot-verifier/root/project/ui_kits/web/index.html @@ -0,0 +1 @@ +Canonical SOT verifier fixturesynthetic structure-only fixture diff --git a/e2e/helpers/canonical-sot-reference.ts b/e2e/helpers/canonical-sot-reference.ts index c672237f..2b4880be 100644 --- a/e2e/helpers/canonical-sot-reference.ts +++ b/e2e/helpers/canonical-sot-reference.ts @@ -1,20 +1,66 @@ +import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { statSync } from "node:fs"; -import { readdir, readFile } from "node:fs/promises"; +import { readdir, readFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; export const CANONICAL_SOT_REFERENCE_ROOT_ENV = "BETTERAINOTE_CANONICAL_SOT_REFERENCE_ROOT"; -const CANONICAL_SOT_WEB_INDEX_SEGMENTS = [ +export const CANONICAL_SOT_REPOSITORY_ROOT_ENV = + "BETTERAINOTE_CANONICAL_SOT_REPOSITORY_ROOT"; +const LEGACY_HANDOFF_WEB_INDEX_SEGMENTS = [ "project", "ui_kits", "web", "index.html", ] as const; -const EXPECTED_CANONICAL_SOT_FILE_COUNT = 182; +const CANONICAL_SOT_WEB_INDEX_RELATIVE_PATH = "ui_kits/web/index.html"; +const CANONICAL_SOT_REQUIRED_RELATIVE_PATHS = [ + "README.md", + "ui_kits/web/README.md", + CANONICAL_SOT_WEB_INDEX_RELATIVE_PATH, + "ui_kits/web/kit.css", +] as const; +const EXPECTED_CANONICAL_SOT_FILE_COUNT = 181; const EXPECTED_CANONICAL_SOT_MANIFEST_SHA256 = + "f2875d4af4784b4944d19a51c918b612942f6f9cf374cca119fb9c1416d9508b"; +const APPROVED_HANDOFF_PROJECT_REALPATH_SUFFIX = [ + "tmp", + "betterainote-design-evidence", + "handoff-20260715-094949", + "betterainote-design-system", + "project", +] as const; +const APPROVED_HANDOFF_BUNDLE_REALPATH_SUFFIX = + APPROVED_HANDOFF_PROJECT_REALPATH_SUFFIX.slice(0, -1); +const APPROVED_HANDOFF_MANIFEST_RELATIVE_PATH = "../../manifest.sha256"; +const EXPECTED_HANDOFF_BUNDLE_FILE_COUNT = 182; +const EXPECTED_HANDOFF_MANIFEST_SIDECAR_SHA256 = "ce2ace3745e94538deb145268da21cf0015faec90460aa0a2605508360fa82c7"; +const DEPRECATED_HANDOFF_SNAPSHOT = { + fileCount: 182, + manifestSha256: + "ce2ace3745e94538deb145268da21cf0015faec90460aa0a2605508360fa82c7", +} as const; + +export const CANONICAL_SOT_REFERENCE_PROVENANCE = { + id: "claude-design-handoff-20260715-094949-project", + handoff: { + bundleFileCount: EXPECTED_HANDOFF_BUNDLE_FILE_COUNT, + manifestSidecarRelativePath: APPROVED_HANDOFF_MANIFEST_RELATIVE_PATH, + manifestSidecarSha256: EXPECTED_HANDOFF_MANIFEST_SIDECAR_SHA256, + projectRealpathSuffix: APPROVED_HANDOFF_PROJECT_REALPATH_SUFFIX, + }, + manifestAlgorithm: + "sha256 of sorted ' \\n' entries", + requiredFiles: CANONICAL_SOT_REQUIRED_RELATIVE_PATHS, + rootKind: "project", + snapshot: { + fileCount: EXPECTED_CANONICAL_SOT_FILE_COUNT, + manifestSha256: EXPECTED_CANONICAL_SOT_MANIFEST_SHA256, + }, +} as const; export type CanonicalSotReference = { root: string; @@ -37,6 +83,14 @@ export type VerifiedCanonicalSotReferenceResolution = reason: string; }; +export type VerifiedSotReferenceExpectation = { + requiredRelativePaths?: readonly string[]; + rootEnvironmentVariable: string; + snapshot: CanonicalSotReferenceSnapshot; + subject: string; + webIndexRelativePath?: string; +}; + function isDirectory(target: string) { try { return statSync(target).isDirectory(); @@ -53,15 +107,153 @@ function isFile(target: string) { } } -function matchesVerifiedCanonicalSotManifest( +function matchesExpectedSotManifest( snapshot: CanonicalSotReferenceSnapshot, + expectedSnapshot: CanonicalSotReferenceSnapshot, ) { return ( - snapshot.fileCount === EXPECTED_CANONICAL_SOT_FILE_COUNT && - snapshot.manifestSha256 === EXPECTED_CANONICAL_SOT_MANIFEST_SHA256 + snapshot.fileCount === expectedSnapshot.fileCount && + snapshot.manifestSha256 === expectedSnapshot.manifestSha256 + ); +} + +function hasRealpathSuffix( + target: string, + expectedSegments: readonly string[], +) { + const targetSegments = path + .normalize(target) + .split(path.sep) + .filter(Boolean); + return expectedSegments.every( + (segment, index) => + targetSegments[ + targetSegments.length - expectedSegments.length + index + ] === segment, + ); +} + +async function resolveRealpath(target: string) { + try { + return await realpath(target); + } catch { + return undefined; + } +} + +async function hasReadOnlyFilesystemContract(targets: readonly string[]) { + try { + const stats = await Promise.all(targets.map((target) => stat(target))); + return stats.every((entry) => (entry.mode & 0o222) === 0); + } catch { + return false; + } +} + +async function resolveApprovedHandoffProjectRealpath() { + const configuredRepositoryRoot = + process.env[CANONICAL_SOT_REPOSITORY_ROOT_ENV]?.trim(); + if ( + !configuredRepositoryRoot || + !path.isAbsolute(configuredRepositoryRoot) + ) { + return undefined; + } + const repositoryRoot = await resolveRealpath(configuredRepositoryRoot); + if (!repositoryRoot || repositoryRoot !== configuredRepositoryRoot) { + return undefined; + } + const topLevelResult = spawnSync( + "git", + ["rev-parse", "--path-format=absolute", "--show-toplevel"], + { + cwd: repositoryRoot, + encoding: "utf8", + stdio: "pipe", + }, + ); + if ( + topLevelResult.status !== 0 || + topLevelResult.error || + (await resolveRealpath(topLevelResult.stdout.trim())) !== repositoryRoot + ) { + return undefined; + } + const result = spawnSync( + "git", + ["rev-parse", "--path-format=absolute", "--git-common-dir"], + { + cwd: repositoryRoot, + encoding: "utf8", + stdio: "pipe", + }, + ); + if (result.status !== 0 || result.error) { + return undefined; + } + + const commonRepositoryRoot = path.dirname(result.stdout.trim()); + return resolveRealpath( + path.join( + commonRepositoryRoot, + ...APPROVED_HANDOFF_PROJECT_REALPATH_SUFFIX, + ), ); } +async function verifyApprovedCanonicalHandoffProvenance(root: string) { + const configuredPath = path.resolve(root); + const projectRealpath = await resolveRealpath(configuredPath); + const approvedProjectRealpath = + await resolveApprovedHandoffProjectRealpath(); + if ( + !projectRealpath || + !approvedProjectRealpath || + projectRealpath !== configuredPath || + projectRealpath !== approvedProjectRealpath || + !hasRealpathSuffix( + projectRealpath, + APPROVED_HANDOFF_PROJECT_REALPATH_SUFFIX, + ) + ) { + return `${CANONICAL_SOT_REFERENCE_ROOT_ENV} does not name the approved 20260715 handoff project realpath`; + } + + const bundleRoot = path.dirname(projectRealpath); + const handoffRoot = path.dirname(bundleRoot); + const manifestPath = path.join(handoffRoot, "manifest.sha256"); + const manifestRealpath = await resolveRealpath(manifestPath); + if ( + manifestRealpath !== manifestPath || + !(await hasReadOnlyFilesystemContract([ + handoffRoot, + bundleRoot, + projectRealpath, + manifestPath, + ])) + ) { + return `${CANONICAL_SOT_REFERENCE_ROOT_ENV} does not have the pinned read-only 20260715 handoff contract`; + } + + try { + const manifest = await readFile(manifestPath); + const manifestLines = manifest.toString("utf8").trimEnd().split("\n"); + if ( + sha256(manifest) !== EXPECTED_HANDOFF_MANIFEST_SIDECAR_SHA256 || + manifestLines.length !== EXPECTED_HANDOFF_BUNDLE_FILE_COUNT || + !manifestLines.every((line) => + /^[a-f0-9]{64} {2}(?:README\.md|project\/.+)$/.test(line), + ) + ) { + return `${CANONICAL_SOT_REFERENCE_ROOT_ENV} does not match the pinned 20260715 handoff manifest sidecar`; + } + } catch { + return `${CANONICAL_SOT_REFERENCE_ROOT_ENV} handoff manifest sidecar could not be read`; + } + + return undefined; +} + function unprovenCanonicalSotReference( reason: string, ): VerifiedCanonicalSotReferenceResolution { @@ -71,23 +263,64 @@ function unprovenCanonicalSotReference( }; } -export async function resolveVerifiedCanonicalSotReference(): Promise { - const configuredRoot = process.env[ - CANONICAL_SOT_REFERENCE_ROOT_ENV - ]?.trim(); +function resolveProjectRelativePath(root: string, relativePath: string) { + const normalized = path.posix.normalize(relativePath.replaceAll("\\", "/")); + if ( + path.posix.isAbsolute(normalized) || + normalized === ".." || + normalized.startsWith("../") + ) { + throw new Error( + `Canonical SOT required path must stay inside its configured root: ${relativePath}`, + ); + } + + return path.join(root, ...normalized.split("/")); +} + +export async function resolveVerifiedSotReference( + expectation: VerifiedSotReferenceExpectation, +): Promise { + const configuredRoot = + process.env[expectation.rootEnvironmentVariable]?.trim(); if (!configuredRoot) { return unprovenCanonicalSotReference( - `${CANONICAL_SOT_REFERENCE_ROOT_ENV} is not set`, + `${expectation.rootEnvironmentVariable} is not set`, ); } const root = path.resolve(process.cwd(), configuredRoot); - const webIndexPath = path.join(root, ...CANONICAL_SOT_WEB_INDEX_SEGMENTS); + const webIndexRelativePath = + expectation.webIndexRelativePath ?? + LEGACY_HANDOFF_WEB_INDEX_SEGMENTS.join("/"); + const requiredRelativePaths = expectation.requiredRelativePaths ?? [ + webIndexRelativePath, + ]; + let webIndexPath: string; + let requiredPaths: string[]; - if (!isDirectory(root) || !isFile(webIndexPath)) { + try { + webIndexPath = resolveProjectRelativePath(root, webIndexRelativePath); + requiredPaths = requiredRelativePaths.map((relativePath) => + resolveProjectRelativePath(root, relativePath), + ); + } catch { return unprovenCanonicalSotReference( - `${CANONICAL_SOT_REFERENCE_ROOT_ENV} does not resolve to a readable handoff root`, + `${expectation.rootEnvironmentVariable} contains an invalid required path contract`, + ); + } + + if ( + !isDirectory(root) || + !isFile(webIndexPath) || + requiredPaths.some((requiredPath) => !isFile(requiredPath)) + ) { + const rootSubject = expectation.requiredRelativePaths + ? expectation.subject + : "handoff"; + return unprovenCanonicalSotReference( + `${expectation.rootEnvironmentVariable} does not resolve to a readable ${rootSubject} root`, ); } @@ -98,9 +331,9 @@ export async function resolveVerifiedCanonicalSotReference(): Promise { + const configuredRoot = + process.env[CANONICAL_SOT_REFERENCE_ROOT_ENV]?.trim(); + if (configuredRoot) { + const root = path.resolve(process.cwd(), configuredRoot); + const configuredRealpath = await resolveRealpath(root); + if ( + configuredRealpath && + hasRealpathSuffix( + configuredRealpath, + APPROVED_HANDOFF_BUNDLE_REALPATH_SUFFIX, + ) + ) { + return unprovenCanonicalSotReference( + `${CANONICAL_SOT_REFERENCE_ROOT_ENV} names the approved 20260715 handoff bundle root; provide its project directory`, + ); + } + const deprecatedWebIndexPath = path.join( + root, + ...LEGACY_HANDOFF_WEB_INDEX_SEGMENTS, + ); + if (isDirectory(root) && isFile(deprecatedWebIndexPath)) { + const deprecatedReference = { + root, + webIndexUrl: pathToFileURL(deprecatedWebIndexPath).href, + }; + try { + const deprecatedSnapshot = + await snapshotCanonicalSotReference(deprecatedReference); + if ( + matchesExpectedSotManifest( + deprecatedSnapshot, + DEPRECATED_HANDOFF_SNAPSHOT, + ) + ) { + return unprovenCanonicalSotReference( + `${CANONICAL_SOT_REFERENCE_ROOT_ENV} names the deprecated 182-file handoff root; provide its project directory`, + ); + } + } catch { + return unprovenCanonicalSotReference( + `${CANONICAL_SOT_REFERENCE_ROOT_ENV} could not be read`, + ); + } + + return unprovenCanonicalSotReference( + `${CANONICAL_SOT_REFERENCE_ROOT_ENV} does not match the verified 182-file handoff manifest`, + ); + } + + const provenanceFailure = + await verifyApprovedCanonicalHandoffProvenance(root); + if (provenanceFailure) { + return unprovenCanonicalSotReference(provenanceFailure); + } + } + + return resolveVerifiedSotReference({ + requiredRelativePaths: CANONICAL_SOT_REQUIRED_RELATIVE_PATHS, + rootEnvironmentVariable: CANONICAL_SOT_REFERENCE_ROOT_ENV, + snapshot: CANONICAL_SOT_REFERENCE_PROVENANCE.snapshot, + subject: "canonical SOT project", + webIndexRelativePath: CANONICAL_SOT_WEB_INDEX_RELATIVE_PATH, + }); +} + function sha256(value: string | Buffer) { return createHash("sha256").update(value).digest("hex"); } @@ -165,12 +464,17 @@ export async function snapshotCanonicalSotReference( export function assertCanonicalSotReferenceMatchesRecovery( snapshot: CanonicalSotReferenceSnapshot, ) { - if (matchesVerifiedCanonicalSotManifest(snapshot)) { + if ( + matchesExpectedSotManifest(snapshot, { + fileCount: EXPECTED_CANONICAL_SOT_FILE_COUNT, + manifestSha256: EXPECTED_CANONICAL_SOT_MANIFEST_SHA256, + }) + ) { return; } throw new Error( - "Canonical SOT handoff hash mismatch: " + + "Canonical SOT project hash mismatch: " + `expected ${EXPECTED_CANONICAL_SOT_FILE_COUNT} files / ` + `${EXPECTED_CANONICAL_SOT_MANIFEST_SHA256}, received ` + `${snapshot.fileCount} files / ${snapshot.manifestSha256}`, @@ -186,7 +490,7 @@ export function assertCanonicalSotReferenceUnchanged( before.manifestSha256 !== after.manifestSha256 ) { throw new Error( - "Canonical SOT handoff changed during this E2E spec: " + + "Canonical SOT project changed during this E2E spec: " + `before ${before.fileCount} files / ${before.manifestSha256}, ` + `after ${after.fileCount} files / ${after.manifestSha256}`, ); diff --git a/e2e/helpers/dashboard-tag-filter-state-seed.ts b/e2e/helpers/dashboard-tag-filter-state-seed.ts new file mode 100644 index 00000000..30887a2e --- /dev/null +++ b/e2e/helpers/dashboard-tag-filter-state-seed.ts @@ -0,0 +1,337 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createClient } from "@libsql/client"; +import { expect, type Page } from "@playwright/test"; + +const E2E_ROOT = path.resolve( + process.env.PLAYWRIGHT_E2E_ROOT ?? path.join(process.cwd(), "tmp/e2e"), +); +const E2E_DATA_DIR = process.env.PLAYWRIGHT_E2E_DATA_DIR + ? path.resolve(process.env.PLAYWRIGHT_E2E_DATA_DIR) + : path.join(E2E_ROOT, "data"); +const CORE_DB = process.env.DATABASE_PATH + ? path.resolve(process.env.DATABASE_PATH) + : path.join(E2E_DATA_DIR, "betterainote-e2e.db"); +const LIBRARY_DB = path.join( + path.dirname(CORE_DB), + `${path.basename(CORE_DB, path.extname(CORE_DB))}-library${path.extname(CORE_DB) || ".db"}`, +); + +export const DASHBOARD_TAG_TRIGGER_RECORDING_PREFIX = + "e2e-dashboard-tag-trigger-recording-"; +export const DASHBOARD_TAG_TRIGGER_TAGS = [ + { color: "purple", icon: "grid", name: "产品周会" }, + { color: "blue", icon: "user", name: "客户访谈" }, + { color: "red", icon: "flag", name: "设计评审" }, + { color: "orange", icon: "clock", name: "技术复盘" }, + { color: "green", icon: "star", name: "销售同步" }, + { color: "slate", icon: "book", name: "个人备忘" }, +] as const; + +type SeededTag = { + color: string; + icon: string; + id: string; + name: string; +}; + +export type DashboardTagTriggerDatabaseState = { + assignmentCount: number; + productAssignments: number; + recordingCount: number; + tagCount: number; + tagNames: string[]; + untaggedCount: number; +}; + +function databaseUrl(filePath: string) { + return pathToFileURL(filePath).href; +} + +function assertIsolatedDatabase(filePath: string) { + const resolved = path.resolve(filePath); + if (!resolved.startsWith(`${E2E_ROOT}${path.sep}`)) { + throw new Error(`Refusing non-isolated E2E database: ${resolved}`); + } + if (!existsSync(path.join(E2E_ROOT, ".betterainote-e2e-root"))) { + throw new Error(`Missing isolated E2E marker under ${E2E_ROOT}`); + } +} + +async function executeWithBusyRetry(operation: () => Promise) { + const delays = [50, 100, 200, 400, 800]; + for (let attempt = 0; ; attempt += 1) { + try { + return await operation(); + } catch (error) { + const delay = delays[attempt]; + const message = + error instanceof Error ? error.message : String(error); + if ( + delay == null || + !/SQLITE_BUSY|database is locked/i.test(message) + ) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } +} + +export async function getDashboardTagTriggerUserId() { + assertIsolatedDatabase(CORE_DB); + const core = createClient({ url: databaseUrl(CORE_DB) }); + try { + const result = await executeWithBusyRetry(() => + core.execute({ + sql: "SELECT id FROM users WHERE email = ? LIMIT 1", + args: ["playwright-admin@example.com"], + }), + ); + const userId = result.rows[0]?.id; + if (typeof userId !== "string") { + throw new Error("Playwright user not found"); + } + return userId; + } finally { + await core.close(); + } +} + +export async function cleanupDashboardTagTriggerSeed(userId: string) { + assertIsolatedDatabase(LIBRARY_DB); + const library = createClient({ url: databaseUrl(LIBRARY_DB) }); + try { + await executeWithBusyRetry(() => + library.execute({ + sql: "DELETE FROM recording_tag_assignments WHERE user_id = ?", + args: [userId], + }), + ); + await executeWithBusyRetry(() => + library.execute({ + sql: "DELETE FROM recording_tags WHERE user_id = ?", + args: [userId], + }), + ); + await executeWithBusyRetry(() => + library.execute({ + sql: "DELETE FROM recordings WHERE user_id = ?", + args: [userId], + }), + ); + } finally { + await library.close(); + } +} + +async function seedSixRecordings(userId: string) { + assertIsolatedDatabase(LIBRARY_DB); + const library = createClient({ url: databaseUrl(LIBRARY_DB) }); + const now = Date.now(); + try { + await executeWithBusyRetry(() => + library.batch( + Array.from({ length: 6 }, (_, index) => { + const suffix = String(index + 1).padStart(2, "0"); + const recordingId = `${DASHBOARD_TAG_TRIGGER_RECORDING_PREFIX}${suffix}`; + const startTime = now - index * 60_000; + return { + sql: ` + INSERT INTO recordings ( + id, user_id, source_provider, source_recording_id, + source_version, source_metadata, provider_device_id, + filename, duration, start_time, end_time, filesize, + file_md5, storage_type, storage_path, downloaded_at, + upstream_trashed, upstream_deleted, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + args: [ + recordingId, + userId, + "ticnote", + `${recordingId}-source`, + "1", + "{}", + "e2e-dashboard-tag-trigger-device", + `Dashboard tag trigger ${suffix}`, + 120_000, + startTime, + startTime + 120_000, + 2048 + index, + `${recordingId}-md5`, + "local", + "", + now, + 0, + 0, + now, + now, + ], + }; + }), + ), + ); + } finally { + await library.close(); + } +} + +async function createSixTagsThroughApi(page: Page) { + const tags: SeededTag[] = []; + for (const seed of DASHBOARD_TAG_TRIGGER_TAGS) { + const response = await page.request.post("/api/recording-tags", { + data: seed, + }); + expect(response.status(), `POST tag ${seed.name}`).toBe(200); + const body = (await response.json()) as { tag?: SeededTag }; + expect(body.tag).toEqual( + expect.objectContaining({ + color: seed.color, + icon: seed.icon, + name: seed.name, + }), + ); + expect(body.tag?.id).toEqual(expect.any(String)); + tags.push(body.tag as SeededTag); + } + return tags; +} + +async function assignTagsThroughApi(page: Page, tags: SeededTag[]) { + const tagByName = new Map(tags.map((tag) => [tag.name, tag])); + const productTag = tagByName.get("产品周会"); + const secondaryTag = tagByName.get("客户访谈"); + if (!productTag || !secondaryTag) { + throw new Error("Deterministic dashboard tag seed is incomplete"); + } + + const assignments = [ + { index: "01", tagIds: [productTag.id] }, + { index: "02", tagIds: [productTag.id] }, + { index: "03", tagIds: [secondaryTag.id] }, + ]; + for (const assignment of assignments) { + const recordingId = `${DASHBOARD_TAG_TRIGGER_RECORDING_PREFIX}${assignment.index}`; + const response = await page.request.put( + `/api/recordings/${recordingId}/tags`, + { data: { tagIds: assignment.tagIds } }, + ); + expect(response.status(), `PUT tags for ${recordingId}`).toBe(200); + } +} + +export async function readDashboardTagTriggerApiTags(page: Page) { + const response = await page.request.get("/api/recording-tags"); + expect(response.status()).toBe(200); + const body = (await response.json()) as { tags?: SeededTag[] }; + return body.tags ?? []; +} + +export async function readDashboardTagTriggerDatabaseState( + userId: string, +): Promise { + assertIsolatedDatabase(LIBRARY_DB); + const library = createClient({ url: databaseUrl(LIBRARY_DB) }); + try { + const [ + recordings, + tags, + assignments, + productAssignments, + untagged, + ] = await Promise.all([ + executeWithBusyRetry(() => + library.execute({ + sql: "SELECT COUNT(*) AS count FROM recordings WHERE user_id = ?", + args: [userId], + }), + ), + executeWithBusyRetry(() => + library.execute({ + sql: "SELECT name FROM recording_tags WHERE user_id = ? ORDER BY name", + args: [userId], + }), + ), + executeWithBusyRetry(() => + library.execute({ + sql: "SELECT COUNT(*) AS count FROM recording_tag_assignments WHERE user_id = ?", + args: [userId], + }), + ), + executeWithBusyRetry(() => + library.execute({ + sql: ` + SELECT COUNT(*) AS count + FROM recording_tag_assignments assignments + JOIN recording_tags tags ON tags.id = assignments.tag_id + WHERE assignments.user_id = ? AND tags.name = ? + `, + args: [userId, "产品周会"], + }), + ), + executeWithBusyRetry(() => + library.execute({ + sql: ` + SELECT COUNT(*) AS count + FROM recordings recordings + WHERE recordings.user_id = ? + AND NOT EXISTS ( + SELECT 1 + FROM recording_tag_assignments assignments + WHERE assignments.user_id = recordings.user_id + AND assignments.recording_id = recordings.id + ) + `, + args: [userId], + }), + ), + ]); + + return { + assignmentCount: Number(assignments.rows[0]?.count ?? 0), + productAssignments: Number( + productAssignments.rows[0]?.count ?? 0, + ), + recordingCount: Number(recordings.rows[0]?.count ?? 0), + tagCount: tags.rows.length, + tagNames: tags.rows + .map((row) => row.name) + .filter((name): name is string => typeof name === "string"), + untaggedCount: Number(untagged.rows[0]?.count ?? 0), + }; + } finally { + await library.close(); + } +} + +export function expectedDashboardTagTriggerDatabaseState(): DashboardTagTriggerDatabaseState { + return { + assignmentCount: 3, + productAssignments: 2, + recordingCount: 6, + tagCount: 6, + tagNames: DASHBOARD_TAG_TRIGGER_TAGS.map((tag) => tag.name).sort(), + untaggedCount: 3, + }; +} + +export async function seedDashboardTagTriggerData(page: Page) { + const userId = await getDashboardTagTriggerUserId(); + await cleanupDashboardTagTriggerSeed(userId); + await seedSixRecordings(userId); + const tags = await createSixTagsThroughApi(page); + await assignTagsThroughApi(page, tags); + + expect( + (await readDashboardTagTriggerApiTags(page)) + .map((tag) => tag.name) + .sort(), + ).toEqual(DASHBOARD_TAG_TRIGGER_TAGS.map((tag) => tag.name).sort()); + expect(await readDashboardTagTriggerDatabaseState(userId)).toEqual( + expectedDashboardTagTriggerDatabaseState(), + ); + + return { tags, userId }; +} diff --git a/e2e/helpers/recording-list-completion-database.ts b/e2e/helpers/recording-list-completion-database.ts new file mode 100644 index 00000000..8113f457 --- /dev/null +++ b/e2e/helpers/recording-list-completion-database.ts @@ -0,0 +1,866 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createClient, type Client, type Row } from "@libsql/client"; +import type { BrowserContext } from "@playwright/test"; + +const E2E_MARKER = ".betterainote-e2e-root"; +const EXPECTED_MARKER = "BetterAINote E2E disposable root v1\n"; + +type BrowserStateSnapshot = { + cookies: Awaited>; + storage: Record; +}; + +type CompletionSnapshot = { + browser: BrowserStateSnapshot | null; + settingsRows: Row[]; +}; + +export type CompletionSeed = { + expectedNameOrder: string[]; + expectedNewestOrder: string[]; + expectedOldestOrder: string[]; + ids: string[]; + sharedTimestampIds: string[]; + tags: { + alpha: { id: string; name: string }; + beta: { id: string; name: string }; + }; +}; + +export type RecordingListCompletionFixture = { + captureBrowserState: (state: BrowserStateSnapshot) => void; + clearSeed: () => Promise; + dispose: () => Promise; + getUserId: () => Promise; + holdLibraryWriteLock: () => Promise<() => Promise>; + readOwnedState: () => Promise<{ recordings: string[]; tags: string[] }>; + restoreBrowserState: () => BrowserStateSnapshot | null; + runId: string; + seed: (userId: string) => Promise; + withLibrarySchemaOutage: (operation: () => Promise) => Promise; +}; + +function requiredPath(name: string) { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`${name} is required for recording-list completion E2E`); + } + return path.resolve(process.cwd(), value); +} + +function assertManagedRoot(root: string) { + const marker = path.join(root, E2E_MARKER); + if ( + !existsSync(marker) || + readFileSync(marker, "utf8") !== EXPECTED_MARKER + ) { + throw new Error("Recording-list completion requires a managed E2E root"); + } +} + +function assertInside(root: string, filePath: string) { + const relative = path.relative(root, filePath); + if ( + relative === "" || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new Error("Recording-list completion database escaped its E2E root"); + } +} + +function siblingDatabasePath(databasePath: string, suffix: string) { + const parsed = path.parse(databasePath); + return path.join(parsed.dir, `${parsed.name}-${suffix}${parsed.ext || ".db"}`); +} + +function client(filePath: string) { + return createClient({ url: pathToFileURL(filePath).href }); +} + +async function rowsForRun(database: Client, table: string, runId: string) { + return database.execute({ + sql: `SELECT id FROM ${table} WHERE id LIKE ? ORDER BY id`, + args: [`${runId}-%`], + }); +} + +type SchemaToken = { + end: number; + kind: "backtick" | "bracket" | "double" | "symbol" | "word"; + start: number; + text: string; +}; + +function schemaTokens(sql: string) { + const tokens: SchemaToken[] = []; + let index = 0; + while (index < sql.length) { + const current = sql[index]; + const next = sql[index + 1]; + if (/\s/.test(current)) { + index += 1; + continue; + } + if (current === "-" && next === "-") { + const end = sql.indexOf("\n", index + 2); + index = end < 0 ? sql.length : end + 1; + continue; + } + if (current === "/" && next === "*") { + const end = sql.indexOf("*/", index + 2); + index = end < 0 ? sql.length : end + 2; + continue; + } + if (current === "'") { + index += 1; + while (index < sql.length) { + if (sql[index] === "'" && sql[index + 1] === "'") { + index += 2; + continue; + } + const done = sql[index] === "'"; + index += 1; + if (done) break; + } + continue; + } + if (current === '"' || current === "`" || current === "[") { + const start = index; + const delimiter = current === "[" ? "]" : current; + index += 1; + while (index < sql.length) { + if (sql[index] === delimiter && sql[index + 1] === delimiter) { + index += 2; + continue; + } + const done = sql[index] === delimiter; + index += 1; + if (done) break; + } + tokens.push({ + end: index, + kind: + current === '"' + ? "double" + : current === "`" + ? "backtick" + : "bracket", + start, + text: sql.slice(start, index), + }); + continue; + } + if (/[A-Za-z_]/.test(current)) { + const start = index; + index += 1; + while (/[A-Za-z0-9_$]/.test(sql[index] ?? "")) index += 1; + tokens.push({ + end: index, + kind: "word", + start, + text: sql.slice(start, index), + }); + continue; + } + tokens.push({ + end: index + 1, + kind: "symbol", + start: index, + text: current, + }); + index += 1; + } + return tokens; +} + +function isKeyword(token: SchemaToken | undefined, keyword: string) { + return token?.kind === "word" && token.text.toUpperCase() === keyword; +} + +function afterIfNotExists(tokens: readonly SchemaToken[], start: number) { + return isKeyword(tokens[start], "IF") && + isKeyword(tokens[start + 1], "NOT") && + isKeyword(tokens[start + 2], "EXISTS") + ? start + 3 + : start; +} + +function qualifiedTargetIndex(tokens: readonly SchemaToken[], start: number) { + return tokens[start + 1]?.text === "." ? start + 2 : start; +} + +function isExactRecordingsTarget(token: SchemaToken | undefined) { + return ( + token?.text === "recordings" || + token?.text === '"recordings"' || + token?.text === "`recordings`" + ); +} + +export function normalizeRecordingSchemaSql(sql: string) { + const tokens = schemaTokens(sql); + const targetIndexes = new Set(); + for (let index = 0; index < tokens.length; index += 1) { + if (isKeyword(tokens[index], "REFERENCES")) { + targetIndexes.add(qualifiedTargetIndex(tokens, index + 1)); + continue; + } + if (!isKeyword(tokens[index], "CREATE")) continue; + let cursor = index + 1; + if ( + isKeyword(tokens[cursor], "TEMP") || + isKeyword(tokens[cursor], "TEMPORARY") + ) { + cursor += 1; + } + if (isKeyword(tokens[cursor], "TABLE")) { + cursor = afterIfNotExists(tokens, cursor + 1); + targetIndexes.add(qualifiedTargetIndex(tokens, cursor)); + continue; + } + if (isKeyword(tokens[cursor], "UNIQUE")) cursor += 1; + if (!isKeyword(tokens[cursor], "INDEX")) continue; + cursor = afterIfNotExists(tokens, cursor + 1); + while ( + cursor < tokens.length && + tokens[cursor]?.text !== "(" && + !isKeyword(tokens[cursor], "ON") + ) { + cursor += 1; + } + if (isKeyword(tokens[cursor], "ON")) { + targetIndexes.add(qualifiedTargetIndex(tokens, cursor + 1)); + } + } + + const replacements = [...targetIndexes] + .map((index) => tokens[index]) + .filter(isExactRecordingsTarget) + .filter( + (token): token is SchemaToken => + token !== undefined && token.text !== "recordings", + ) + .sort((left, right) => right.start - left.start); + let normalized = sql; + for (const token of replacements) { + normalized = `${normalized.slice(0, token.start)}recordings${normalized.slice(token.end)}`; + } + return normalized; +} + +export function assertRecordingSchemaNormalizationMatrix() { + for (const variants of [ + [ + "CREATE TABLE recordings (id TEXT)", + 'CREATE TABLE "recordings" (id TEXT)', + "CREATE TABLE `recordings` (id TEXT)", + ], + [ + "CREATE INDEX recording_idx ON recordings (id)", + 'CREATE INDEX recording_idx ON "recordings" (id)', + "CREATE INDEX recording_idx ON `recordings` (id)", + ], + [ + "CREATE TABLE child (recording_id TEXT REFERENCES recordings(id))", + 'CREATE TABLE child (recording_id TEXT REFERENCES "recordings"(id))', + "CREATE TABLE child (recording_id TEXT REFERENCES `recordings`(id))", + ], + ]) { + if (new Set(variants.map(normalizeRecordingSchemaSql)).size !== 1) { + throw new Error( + "Recording-list completion schema target normalization failed", + ); + } + } + for (const preserved of [ + 'CREATE TABLE other ("recordings" TEXT)', + "CREATE TABLE other (note TEXT DEFAULT 'recordings')", + "CREATE TABLE other (id TEXT) -- recordings", + "CREATE TABLE other (id TEXT) /* recordings */", + "CREATE TABLE [recordings] (id TEXT)", + "CREATE TABLE Recordings (id TEXT)", + "CREATE TABLE recordings_archive (id TEXT)", + ]) { + if (normalizeRecordingSchemaSql(preserved) !== preserved) { + throw new Error( + "Recording-list completion schema normalization widened scope", + ); + } + } + const canonicalTable = normalizeRecordingSchemaSql( + "CREATE TABLE recordings (id TEXT)", + ); + for (const distinctTarget of [ + "CREATE TABLE [recordings] (id TEXT)", + "CREATE TABLE Recordings (id TEXT)", + "CREATE TABLE recordings_archive (id TEXT)", + ]) { + if (normalizeRecordingSchemaSql(distinctTarget) === canonicalTable) { + throw new Error( + "Recording-list completion schema normalization merged distinct targets", + ); + } + } +} + +assertRecordingSchemaNormalizationMatrix(); + +async function librarySchemaSignature(database: Client) { + const result = await database.execute(` + SELECT type, name, tbl_name, sql + FROM sqlite_schema + WHERE tbl_name = 'recordings' + OR sql LIKE '%recordings%' + ORDER BY type, name + `); + return result.rows.map((row) => ({ + ...row, + sql: + typeof row.sql === "string" + ? normalizeRecordingSchemaSql(row.sql) + : row.sql, + })); +} + +async function libraryOwnedRows(database: Client, runId: string) { + const tables = [ + "recordings", + "recording_tags", + "recording_tag_assignments", + "transcription_jobs", + ] as const; + const rows = await Promise.all( + tables.map(async (table) => { + const result = await database.execute({ + sql: `SELECT * FROM ${table} WHERE id LIKE ? ORDER BY id`, + args: [`${runId}-%`], + }); + return [table, result.rows.map((row) => ({ ...row }))] as const; + }), + ); + return Object.fromEntries(rows); +} + +function toMilliseconds(date: Date) { + return date.getTime(); +} + +function localDate(now: Date, dayOffset: number, hour: number) { + const value = new Date(now); + value.setHours(hour, 0, 0, 0); + value.setDate(value.getDate() + dayOffset); + return value; +} + +export async function createRecordingListCompletionFixture(): Promise { + const root = requiredPath("PLAYWRIGHT_E2E_ROOT"); + const corePath = requiredPath("DATABASE_PATH"); + assertManagedRoot(root); + assertInside(root, corePath); + const libraryPath = siblingDatabasePath(corePath, "library"); + const transcriptsPath = siblingDatabasePath(corePath, "transcripts"); + const runId = `recording-list-${randomUUID()}`; + const core = client(corePath); + const library = client(libraryPath); + const transcripts = client(transcriptsPath); + const snapshot: CompletionSnapshot = { browser: null, settingsRows: [] }; + let fixtureUserId: string | null = null; + let settingsSnapshotCaptured = false; + let disposed = false; + let cleanupPromise: Promise | null = null; + let seedCleanupPromise: Promise | null = null; + let activeOutage: + | { + outageTable: string; + ownedBefore: Awaited>; + restorationPromise: Promise | null; + signature: Awaited>; + } + | null = null; + + async function restoreActiveOutage() { + const outage = activeOutage; + if (!outage) return; + if (outage.restorationPromise) { + await outage.restorationPromise; + return; + } + + const attempt = (async () => { + const management = client(libraryPath); + try { + const tables = await management.execute({ + sql: `SELECT name + FROM sqlite_schema + WHERE type = 'table' AND name IN (?, ?) + ORDER BY name`, + args: ["recordings", outage.outageTable], + }); + const names = tables.rows.map((row) => String(row.name)); + const hasRecordings = names.includes("recordings"); + const hasOutageTable = names.includes(outage.outageTable); + if (hasRecordings === hasOutageTable) { + throw new Error( + "Recording-list completion outage table state is ambiguous", + ); + } + if (hasOutageTable) { + await management.execute( + `ALTER TABLE ${outage.outageTable} RENAME TO recordings`, + ); + } + + const restoredSignature = + await librarySchemaSignature(management); + const ownedAfter = await libraryOwnedRows( + management, + runId, + ); + if ( + JSON.stringify(restoredSignature) !== + JSON.stringify(outage.signature) || + JSON.stringify(ownedAfter) !== + JSON.stringify(outage.ownedBefore) + ) { + throw new Error( + "Recording-list completion schema restore readback failed", + ); + } + } finally { + management.close(); + } + })(); + outage.restorationPromise = attempt; + try { + await attempt; + if (activeOutage === outage) activeOutage = null; + } catch (error) { + if (outage.restorationPromise === attempt) { + outage.restorationPromise = null; + } + throw error; + } + } + + async function getUserId() { + const result = await core.execute({ + sql: "SELECT id FROM users WHERE email = ? LIMIT 1", + args: ["playwright-admin@example.com"], + }); + const userId = result.rows[0]?.id; + if (typeof userId !== "string") { + throw new Error("Managed E2E login user is unavailable"); + } + fixtureUserId = userId; + if (!settingsSnapshotCaptured) { + const settings = await core.execute({ + sql: "SELECT * FROM user_settings WHERE user_id = ?", + args: [userId], + }); + snapshot.settingsRows = [...settings.rows]; + settingsSnapshotCaptured = true; + } + return userId; + } + + async function seed(userId: string): Promise { + fixtureUserId = userId; + const now = new Date(); + const alpha = { + id: `${runId}-tag-alpha`, + name: `Alpha ${runId.slice(-8)}`, + }; + const beta = { + id: `${runId}-tag-beta`, + name: `Beta ${runId.slice(-8)}`, + }; + const ids = Array.from({ length: 24 }, (_, index) => + `${runId}-recording-${String(index + 1).padStart(2, "0")}`, + ); + const sameTimestamp = localDate(now, 0, 9); + const starts = ids.map((_id, index) => { + if (index < 3) return sameTimestamp; + if (index < 8) return localDate(now, 0, 8 - index); + if (index < 12) return localDate(now, -1, 16 - index); + if (index < 18) return localDate(now, -(index - 10), 10); + return localDate(now, -(index + 1), 10); + }); + const names = ids.map((_id, index) => + index === 0 + ? `2 review ${runId.slice(-8)}` + : index === 1 + ? `10 Review ${runId.slice(-8)}` + : `Recording ${String(index + 1).padStart(2, "0")} ${runId.slice(-8)}`, + ); + + await library.batch([ + ...[alpha, beta].map((tag, index) => ({ + sql: `INSERT INTO recording_tags + (id, user_id, name, color, icon, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + args: [ + tag.id, + userId, + tag.name, + index === 0 ? "blue" : "purple", + index === 0 ? "grid" : "star", + toMilliseconds(now), + toMilliseconds(now), + ], + })), + ...ids.map((id, index) => ({ + sql: `INSERT INTO recordings ( + id, user_id, source_provider, source_recording_id, + source_version, source_metadata, provider_device_id, + filename, duration, start_time, end_time, filesize, + file_md5, storage_type, storage_path, downloaded_at, + upstream_trashed, upstream_deleted, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + id, + userId, + index % 2 === 0 ? "ticnote" : "plaud", + `${id}-source`, + "1", + "{}", + `${runId}-device`, + names[index], + 60_000 + index * 1_000, + toMilliseconds(starts[index]), + toMilliseconds(starts[index]) + 60_000, + 1_024 + index, + id, + "local", + "", + toMilliseconds(now), + 0, + index === 4 ? 1 : 0, + toMilliseconds(now), + toMilliseconds(now), + ], + })), + ...ids.flatMap((id, index) => { + const tagIds = + index === 0 + ? [alpha.id, beta.id] + : index < 5 + ? [alpha.id] + : index < 8 + ? [beta.id] + : []; + return tagIds.map((tagId) => ({ + sql: `INSERT INTO recording_tag_assignments + (id, user_id, recording_id, tag_id, created_at) + VALUES (?, ?, ?, ?, ?)`, + args: [ + `${runId}-assignment-${index}-${tagId.endsWith("alpha") ? "a" : "b"}`, + userId, + id, + tagId, + toMilliseconds(now), + ], + })); + }), + ...[ + { index: 1, status: "pending" }, + { index: 2, status: "running" }, + { index: 3, status: "failed" }, + ].map(({ index, status }) => ({ + sql: `INSERT INTO transcription_jobs + (id, user_id, recording_id, status, force, attempts, + requested_at, created_at, updated_at) + VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?)`, + args: [ + `${runId}-job-${index}`, + userId, + ids[index], + status, + toMilliseconds(now), + toMilliseconds(now), + toMilliseconds(now), + ], + })), + ]); + await transcripts.execute({ + sql: `INSERT INTO transcriptions + (id, recording_id, user_id, text, detected_language, + transcription_type, provider, model, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + `${runId}-transcription`, + ids[0], + userId, + "Synthetic completion fixture transcript", + "en", + "server", + "fixture", + "fixture", + toMilliseconds(now), + ], + }); + + const newest = ids + .map((id, index) => ({ id, start: starts[index].getTime() })) + .sort( + (left, right) => + right.start - left.start || + left.id.localeCompare(right.id), + ) + .map((entry) => entry.id); + const oldest = ids + .map((id, index) => ({ id, start: starts[index].getTime() })) + .sort( + (left, right) => + left.start - right.start || + left.id.localeCompare(right.id), + ) + .map((entry) => entry.id); + const byName = ids + .map((id, index) => ({ id, name: names[index] })) + .sort( + (left, right) => { + const leftName = left.name.replace( + /[A-Z]/g, + (character) => character.toLowerCase(), + ); + const rightName = right.name.replace( + /[A-Z]/g, + (character) => character.toLowerCase(), + ); + return ( + (leftName < rightName + ? -1 + : leftName > rightName + ? 1 + : 0) || left.id.localeCompare(right.id) + ); + }, + ) + .map((entry) => entry.id); + return { + expectedNameOrder: byName, + expectedNewestOrder: newest, + expectedOldestOrder: oldest, + ids, + sharedTimestampIds: ids.slice(0, 3), + tags: { alpha, beta }, + }; + } + + async function readOwnedState() { + const [recordings, tags] = await Promise.all([ + rowsForRun(library, "recordings", runId), + rowsForRun(library, "recording_tags", runId), + ]); + return { + recordings: recordings.rows.map((row) => String(row.id)), + tags: tags.rows.map((row) => String(row.id)), + }; + } + + async function deleteSeedRows() { + await transcripts.execute({ + sql: "DELETE FROM transcriptions WHERE id LIKE ?", + args: [`${runId}-%`], + }); + await library.batch([ + { + sql: "DELETE FROM transcription_jobs WHERE id LIKE ?", + args: [`${runId}-%`], + }, + { + sql: "DELETE FROM recording_tag_assignments WHERE id LIKE ?", + args: [`${runId}-%`], + }, + { + sql: "DELETE FROM recordings WHERE id LIKE ?", + args: [`${runId}-%`], + }, + { + sql: "DELETE FROM recording_tags WHERE id LIKE ?", + args: [`${runId}-%`], + }, + ]); + } + + async function clearSeed() { + if (seedCleanupPromise) { + await seedCleanupPromise; + return; + } + const attempt = (async () => { + await restoreActiveOutage(); + await deleteSeedRows(); + })(); + seedCleanupPromise = attempt; + try { + await attempt; + } catch (error) { + if (seedCleanupPromise === attempt) seedCleanupPromise = null; + throw error; + } + } + + async function cleanup() { + if (disposed) return; + if (cleanupPromise) { + await cleanupPromise; + return; + } + const attempt = (async () => { + await clearSeed(); + const errors: unknown[] = []; + if (fixtureUserId) { + try { + await core.execute({ + sql: "DELETE FROM user_settings WHERE user_id = ?", + args: [fixtureUserId], + }); + if (snapshot.settingsRows[0]) { + const row = snapshot.settingsRows[0]; + const columns = Object.keys(row); + await core.execute({ + sql: `INSERT INTO user_settings (${columns.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`, + args: columns.map((column) => row[column]), + }); + } + const restored = await core.execute({ + sql: "SELECT * FROM user_settings WHERE user_id = ?", + args: [fixtureUserId], + }); + if ( + JSON.stringify(restored.rows) !== + JSON.stringify(snapshot.settingsRows) + ) { + throw new Error( + "Recording-list completion settings restore readback failed", + ); + } + } catch (error) { + errors.push(error); + } + } + try { + const remaining = await readOwnedState(); + if (remaining.recordings.length || remaining.tags.length) { + throw new Error( + "Recording-list completion cleanup readback failed", + ); + } + } catch (error) { + errors.push(error); + } + for (const database of [core, library, transcripts]) { + try { + database.close(); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + throw new AggregateError( + errors, + "Recording-list completion cleanup failed", + ); + } + disposed = true; + })(); + cleanupPromise = attempt; + try { + await attempt; + } catch (error) { + if (cleanupPromise === attempt) cleanupPromise = null; + throw error; + } + } + + return { + captureBrowserState(state) { + snapshot.browser = state; + }, + clearSeed, + dispose: cleanup, + getUserId, + async holdLibraryWriteLock() { + const lock = client(libraryPath); + await lock.execute("PRAGMA busy_timeout = 50"); + await lock.execute("BEGIN EXCLUSIVE"); + let released = false; + return async () => { + if (released) return; + released = true; + try { + await lock.execute("ROLLBACK"); + } finally { + lock.close(); + } + }; + }, + readOwnedState, + restoreBrowserState: () => snapshot.browser, + runId, + seed, + async withLibrarySchemaOutage( + operation: () => Promise, + ): Promise { + if (activeOutage) { + throw new Error( + "Recording-list completion schema outage is already active", + ); + } + const signature = await librarySchemaSignature(library); + const ownedBefore = await libraryOwnedRows(library, runId); + const outageTable = `${runId.replaceAll("-", "_")}_recordings`; + if (!/^[A-Za-z0-9_]+$/.test(outageTable)) { + throw new Error( + "Recording-list completion generated an unsafe outage table", + ); + } + await library.execute( + `ALTER TABLE recordings RENAME TO ${outageTable}`, + ); + activeOutage = { + outageTable, + ownedBefore, + restorationPromise: null, + signature, + }; + + let restoreError: unknown; + let outcome: + | { status: "error"; error: unknown } + | { status: "success"; value: T }; + try { + try { + outcome = { + status: "success", + value: await operation(), + }; + } catch (error) { + outcome = { error, status: "error" }; + } + } finally { + try { + await restoreActiveOutage(); + } catch (error) { + restoreError = error; + } + } + + if (outcome.status === "error" && restoreError !== undefined) { + throw new AggregateError( + [outcome.error, restoreError], + "Recording-list completion outage and restore both failed", + ); + } + if (restoreError !== undefined) throw restoreError; + if (outcome.status === "error") throw outcome.error; + return outcome.value; + }, + }; +} diff --git a/e2e/helpers/recording-pagination-database.ts b/e2e/helpers/recording-pagination-database.ts new file mode 100644 index 00000000..7cffa060 --- /dev/null +++ b/e2e/helpers/recording-pagination-database.ts @@ -0,0 +1,245 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createClient, type Client } from "@libsql/client"; + +const PLAYWRIGHT_USER_EMAIL = "playwright-admin@example.com"; + +export const PAGINATION_RECORDING_COUNT = 21; +export const PAGINATION_RECORDING_ID_PREFIX = "e2e-recording-pagination-"; +export const PAGINATION_FILTER_TERM = "pagination-filter-target"; + +function requireResolvedEnvPath(name: string) { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`${name} is required for pagination E2E`); + } + return path.resolve(process.cwd(), value); +} + +const E2E_ROOT = requireResolvedEnvPath("PLAYWRIGHT_E2E_ROOT"); +const CORE_DB = requireResolvedEnvPath("DATABASE_PATH"); + +function assertE2EPath(filePath: string) { + const resolvedPath = path.resolve(filePath); + if ( + resolvedPath !== E2E_ROOT && + !resolvedPath.startsWith(`${E2E_ROOT}${path.sep}`) + ) { + throw new Error(`Refusing non-E2E database path: ${resolvedPath}`); + } +} + +function siblingDatabasePath(databasePath: string, suffix: string) { + const parsed = path.parse(databasePath); + return path.join(parsed.dir, `${parsed.name}-${suffix}${parsed.ext || ".db"}`); +} + +const LIBRARY_DB = siblingDatabasePath(CORE_DB, "library"); +const TRANSCRIPTS_DB = siblingDatabasePath(CORE_DB, "transcripts"); +const VOICEPRINTS_DB = siblingDatabasePath(CORE_DB, "voiceprints"); +const SEARCH_DB = siblingDatabasePath(CORE_DB, "search"); + +function createDatabaseClient(filePath: string) { + assertE2EPath(filePath); + return createClient({ url: pathToFileURL(filePath).href }); +} + +async function withClients( + callback: (clients: { + library: Client; + search: Client; + transcripts: Client; + voiceprints: Client; + }) => Promise, +) { + const clients = { + library: createDatabaseClient(LIBRARY_DB), + search: createDatabaseClient(SEARCH_DB), + transcripts: createDatabaseClient(TRANSCRIPTS_DB), + voiceprints: createDatabaseClient(VOICEPRINTS_DB), + }; + + try { + return await callback(clients); + } finally { + await Promise.all(Object.values(clients).map((client) => client.close())); + } +} + +export async function getPaginationTestUserId() { + const core = createDatabaseClient(CORE_DB); + try { + const result = await core.execute({ + sql: "SELECT id FROM users WHERE email = ? LIMIT 1", + args: [PLAYWRIGHT_USER_EMAIL], + }); + const userId = result.rows[0]?.id; + if (typeof userId !== "string") { + throw new Error("Playwright user was not created"); + } + return userId; + } finally { + await core.close(); + } +} + +export async function resetPaginationTestDatabase(userId: string) { + await withClients(async ({ library, search, transcripts, voiceprints }) => { + await voiceprints.execute({ + sql: "DELETE FROM recording_speakers WHERE user_id = ?", + args: [userId], + }); + + await transcripts.batch([ + { + sql: "DELETE FROM source_artifact_segments WHERE user_id = ?", + args: [userId], + }, + { + sql: "DELETE FROM transcript_segments WHERE user_id = ?", + args: [userId], + }, + { + sql: "DELETE FROM source_artifacts WHERE user_id = ?", + args: [userId], + }, + { + sql: "DELETE FROM transcriptions WHERE user_id = ?", + args: [userId], + }, + ]); + + await library.batch([ + { + sql: "DELETE FROM transcription_jobs WHERE user_id = ?", + args: [userId], + }, + { + sql: "DELETE FROM recording_tag_assignments WHERE user_id = ?", + args: [userId], + }, + { + sql: "DELETE FROM recording_tags WHERE user_id = ?", + args: [userId], + }, + { + sql: "DELETE FROM recordings WHERE user_id = ?", + args: [userId], + }, + { + sql: "DELETE FROM source_devices WHERE user_id = ?", + args: [userId], + }, + ]); + + await search.execute({ + sql: ` + DELETE FROM search_content_fts + WHERE rowid IN ( + SELECT rowid FROM search_chunks WHERE user_id = ? + ) + `, + args: [userId], + }); + await search.batch([ + { + sql: "DELETE FROM search_chunks WHERE user_id = ?", + args: [userId], + }, + { + sql: "DELETE FROM search_documents WHERE user_id = ?", + args: [userId], + }, + { + sql: "DELETE FROM search_name2id WHERE user_id = ?", + args: [userId], + }, + { + sql: "DELETE FROM search_index_ranges WHERE user_id = ?", + args: [userId], + }, + { + sql: "DELETE FROM search_tombstones WHERE user_id = ?", + args: [userId], + }, + { + sql: "DELETE FROM search_index_jobs WHERE user_id = ?", + args: [userId], + }, + ]); + }); +} + +export async function seedPaginationRecordings(userId: string) { + const library = createDatabaseClient(LIBRARY_DB); + const now = Date.now(); + + try { + const statements = Array.from( + { length: PAGINATION_RECORDING_COUNT }, + (_, offset) => { + const index = offset + 1; + const id = `${PAGINATION_RECORDING_ID_PREFIX}${index}`; + const filename = + index <= 2 + ? `E2E ${PAGINATION_FILTER_TERM} ${index}` + : `E2E pagination recording ${index}`; + + return { + sql: ` + INSERT INTO recordings ( + id, user_id, source_provider, source_recording_id, + source_version, source_metadata, provider_device_id, + filename, duration, start_time, end_time, filesize, + file_md5, storage_type, storage_path, downloaded_at, + upstream_trashed, upstream_deleted, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + args: [ + id, + userId, + "ticnote", + `${id}-source`, + "1", + "{}", + "e2e-pagination-device", + filename, + 60_000, + now - index * 60_000, + now - index * 60_000 + 60_000, + 1024, + id, + "local", + "", + now, + 0, + 0, + now, + now, + ], + }; + }, + ); + await library.batch(statements); + } finally { + await library.close(); + } +} + +export async function readPaginationDatabaseState(userId: string) { + const library = createDatabaseClient(LIBRARY_DB); + try { + const result = await library.execute({ + sql: ` + SELECT id + FROM recordings + WHERE user_id = ? + ORDER BY start_time DESC, id ASC + `, + args: [userId], + }); + return result.rows.map((row) => String(row.id)); + } finally { + await library.close(); + } +} diff --git a/e2e/library-search-backend.spec.ts b/e2e/library-search-backend.spec.ts index a65da2db..218301fd 100644 --- a/e2e/library-search-backend.spec.ts +++ b/e2e/library-search-backend.spec.ts @@ -368,12 +368,16 @@ async function countActiveSearchIndexJob(userId: string) { } async function openLibrarySearch(page: Page) { - const trigger = sotControl(page, "dashboard-search").first(); - const panel = sotPanel(page, "library-search"); + const trigger = page.getByRole("button", { name: /^(搜索|Search)$/ }); + const panel = page.getByRole("dialog", { + name: /^(搜索库|Search library)$/, + }); - await expect( - page.locator('[data-sot-surface="dashboard-workstation"]'), - ).toHaveAttribute("data-sot-state", "ready"); + await expect(page.locator('[data-surface="dashboard-workstation"]')).toHaveAttribute( + "data-state", + "ready", + ); + await expect(trigger).toHaveAttribute("data-control", "dashboard-search"); await expect(trigger).toBeVisible(); for (let attempt = 0; attempt < 3; attempt += 1) { await trigger.click(); @@ -399,18 +403,10 @@ async function openLibrarySearch(page: Page) { return panel; } -function sotControl(page: Page, name: string) { - return page.locator(`[data-sot-control="${name}"]`); -} - -function sotPanel(page: Page, name: string) { - return page.locator(`[data-sot-panel="${name}"]`); -} - -function sotSearchResult(page: Page, type: string, index = 0) { +function librarySearchResult(page: Page, type: string, index = 0) { return page.locator( - `[data-sot-control="library-search-result"][data-sot-result-type="${type}"][data-sot-result-index="${index}"]`, - ); + `[data-control="library-search-result"][data-result-type="${type}"]`, + ).nth(index); } test("library search uses the real /api/search route against the seeded local read model", async ({ @@ -434,19 +430,20 @@ test("library search uses the real /api/search route against the seeded local re await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); const otherRecording = page.locator( - `[data-sot-recording-id="${SEARCH_OTHER_RECORDING_ID}"]`, + `[data-control="dashboard-recording-row"][data-recording-id="${SEARCH_OTHER_RECORDING_ID}"]`, ); const targetRecording = page.locator( - `[data-sot-recording-id="${SEARCH_TARGET_RECORDING_ID}"]`, + `[data-control="dashboard-recording-row"][data-recording-id="${SEARCH_TARGET_RECORDING_ID}"]`, ); await expect(otherRecording).toBeVisible(); await expect(targetRecording).toBeVisible(); await otherRecording.click(); - await expect(otherRecording).toHaveAttribute("data-sot-state", "selected"); - await expect(targetRecording).toHaveAttribute("data-sot-state", "idle"); + await expect(otherRecording).toHaveAttribute("data-state", "selected"); + await expect(targetRecording).toHaveAttribute("data-state", "idle"); const panel = await openLibrarySearch(page); - const input = panel.locator('[data-sot-control="library-search-input"]'); + await expect(panel).toHaveAttribute("data-panel", "library-search"); + const input = panel.getByRole("combobox"); const searchResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return ( @@ -490,15 +487,19 @@ test("library search uses the real /api/search route against the seeded local re ]), ); - const transcriptResult = sotSearchResult(page, "transcript", 0); + const transcriptResult = librarySearchResult(page, "transcript", 0); await expect(transcriptResult).toBeVisible(); + await expect(transcriptResult).toHaveAttribute( + "data-control", + "library-search-result", + ); await expect(transcriptResult).toContainText(SEARCH_TARGET_TITLE); await expect(transcriptResult).toContainText(SEARCH_BODY); await transcriptResult.click(); await expect(panel).toBeHidden(); - await expect(targetRecording).toHaveAttribute("data-sot-state", "selected"); - await expect(otherRecording).toHaveAttribute("data-sot-state", "idle"); + await expect(targetRecording).toHaveAttribute("data-state", "selected"); + await expect(otherRecording).toHaveAttribute("data-state", "idle"); }); test("library search shows the real backend indexing state while search index jobs are active", async ({ @@ -543,41 +544,28 @@ test("library search shows the real backend indexing state while search index jo await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); const panel = await openLibrarySearch(page); - const input = panel.locator('[data-sot-control="library-search-input"]'); + const input = panel.getByRole("combobox"); await input.fill(SEARCH_INDEXING_QUERY); await expect(panel).toHaveAttribute("data-state", "indexing"); - await expect(panel).toHaveAttribute("data-sot-state", "indexing"); - await expect(input).toHaveAttribute("data-sot-state", "indexing"); + await expect(input).toHaveAttribute("data-state", "indexing"); await expect(input).toHaveAttribute("aria-disabled", "true"); await expect(input).toHaveJSProperty("readOnly", true); const indexingState = panel.locator( - '[data-sot-part="library-search-indexing"][data-sot-state="indexing"]', + '[data-part="library-search-indexing"][data-state="indexing"]', ); await expect(indexingState).toBeVisible(); await expect(indexingState).toContainText("正在重建本地搜索索引"); await expect(indexingState).toContainText("0 / 1"); - await expect( - panel.locator('[data-sot-part="library-search-state-skeleton"]'), - ).toBeVisible(); + await expect(panel.getByRole("progressbar")).toBeVisible(); - const scopeControls = panel.locator( - '[data-sot-control="library-search-scope"]', - ); + const scopeControls = panel + .getByRole("radiogroup", { name: /^(检索范围|Search scope)$/ }) + .getByRole("radio"); await expect(scopeControls).toHaveCount(5); - for (const scope of [ - "all", - "recording", - "transcript", - "speaker", - "tag", - ]) { - await expect( - panel.locator( - `[data-sot-control="library-search-scope"][data-sot-scope="${scope}"]`, - ), - ).toBeDisabled(); + for (const scopeControl of await scopeControls.all()) { + await expect(scopeControl).toBeDisabled(); } } finally { await cleanupActiveSearchIndexJob(userId); diff --git a/e2e/login-image-runtime.spec.ts b/e2e/login-image-runtime.spec.ts new file mode 100644 index 00000000..77e5d4db --- /dev/null +++ b/e2e/login-image-runtime.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from "@playwright/test"; + +const nextImageWarning = /next\/image|image with src|custom loader/i; + +test("login loads its local logo without Next Image warnings", async ({ page }) => { + const imageWarnings: string[] = []; + + page.on("console", (message) => { + if ( + (message.type() === "warning" || message.type() === "error") && + nextImageWarning.test(message.text()) + ) { + imageWarnings.push(message.text()); + } + }); + + await page.goto("/login", { waitUntil: "networkidle" }); + + await expect(page.getByText("登录 BetterAINote", { exact: true })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "邮箱" })).toBeVisible(); + const logo = page.locator('img[src="/assets/logo-mark-steel.svg"]'); + await expect(logo).toBeVisible(); + await expect(logo).toHaveAttribute("width", "36"); + await expect(logo).toHaveAttribute("height", "36"); + await expect(imageWarnings).toEqual([]); +}); diff --git a/e2e/playback-settings.spec.ts b/e2e/playback-settings.spec.ts index f11bae21..af6ce47b 100644 --- a/e2e/playback-settings.spec.ts +++ b/e2e/playback-settings.spec.ts @@ -6,13 +6,11 @@ import { } from "./helpers/shadcn-select"; function settingsShell(page: Page) { - return page.locator('[data-sot-surface="settings-shell"]'); + return page.getByRole("dialog", { name: /^(设置|Settings)$/ }); } -function settingsSection(page: Page, section: string) { - return page.locator( - `[data-sot-surface="settings-section"][data-sot-section="${section}"]`, - ); +function settingsSection(page: Page) { + return page.getByRole("region", { name: /^(杂项|Misc)$/ }); } function waitForSettingsPut( @@ -40,7 +38,6 @@ function waitForSettingsPut( async function expectShadcnSliderValue(slider: Locator, value: number) { await expect(slider).toHaveAttribute("data-slot", "slider"); - await expect(slider).toHaveAttribute("data-sot-state", "ready"); await expect(slider.getByRole("slider")).toHaveAttribute( "aria-valuenow", String(value), @@ -86,22 +83,12 @@ test("misc settings persist sync and playback controls immediately, then reload" await page.goto("/settings#misc", { waitUntil: "domcontentloaded" }); const shell = settingsShell(page); - const section = settingsSection(page, "misc"); - const syncSwitch = section.locator( - '[data-sot-control="sync-auto-enabled"]', - ); - const syncIntervalInput = section.locator( - '[data-sot-control="sync-interval-seconds"]', - ); - const playbackSpeedSelect = section.locator( - '[data-sot-control="playback-speed"]', - ); - const autoPlaySwitch = section.locator( - '[data-sot-control="playback-auto-next"]', - ); - const volumeSlider = section.locator( - '[data-sot-control="playback-volume"]', - ); + const section = settingsSection(page); + const syncSwitch = section.locator("#sync-auto-enabled"); + const syncIntervalInput = section.locator("#sync-interval-seconds"); + const playbackSpeedSelect = section.locator("#playback-speed"); + const autoPlaySwitch = section.locator("#playback-auto-next"); + const volumeSlider = section.locator("#playback-volume"); const miscHeading = section.getByRole("heading", { name: /^(杂项|Misc)$/, exact: true, @@ -111,31 +98,23 @@ test("misc settings persist sync and playback controls immediately, then reload" exact: true, }); - await expect(shell).toHaveAttribute("data-sot-section", "misc"); - await expect(section).toHaveAttribute("data-sot-state", "ready"); + await expect(shell).toBeVisible(); await expect(section).toHaveAttribute("aria-busy", "false"); await expect(miscHeading).toBeVisible(); await expect(playbackTitle).toBeVisible(); await expect(section.locator("[data-save-actions]")).toHaveCount(0); await expect(section.locator("[data-save-action]")).toHaveCount(0); await expect( - section.locator('[data-sot-control="settings-save"]'), + section.getByRole("button", { name: /^(保存|Save)$/ }), ).toHaveCount(0); - await expect(syncSwitch).toHaveAttribute("data-sot-state", "checked"); + await expect(syncSwitch).toHaveAttribute("aria-checked", "true"); await expect(syncIntervalInput).toHaveValue("300"); await expectShadcnSelectTrigger(playbackSpeedSelect, { label: "默认速度", text: "1x", }); - await expect(playbackSpeedSelect).toHaveAttribute( - "data-sot-state", - "ready", - ); await expectShadcnSliderValue(volumeSlider, 75); - await expect(autoPlaySwitch).toHaveAttribute( - "data-sot-state", - "unchecked", - ); + await expect(autoPlaySwitch).toHaveAttribute("aria-checked", "false"); await expect(page).toHaveURL(/\/settings#misc$/); const syncToggleResponse = waitForSettingsPut( @@ -147,7 +126,7 @@ test("misc settings persist sync and playback controls immediately, then reload" ); await syncSwitch.click(); await syncToggleResponse; - await expect(syncSwitch).toHaveAttribute("data-sot-state", "unchecked"); + await expect(syncSwitch).toHaveAttribute("aria-checked", "false"); const syncIntervalResponse = waitForSettingsPut( page, @@ -195,24 +174,21 @@ test("misc settings persist sync and playback controls immediately, then reload" ); await autoPlaySwitch.click(); await autoNextResponse; - await expect(autoPlaySwitch).toHaveAttribute("data-sot-state", "checked"); - await expect(section).toHaveAttribute("data-sot-state", "ready"); + await expect(autoPlaySwitch).toHaveAttribute("aria-checked", "true"); + await expect(section).toHaveAttribute("aria-busy", "false"); await page.reload({ waitUntil: "domcontentloaded" }); - await expect(settingsShell(page)).toHaveAttribute( - "data-sot-section", - "misc", - ); + await expect(settingsShell(page)).toBeVisible(); await expect(miscHeading).toBeVisible(); await expect(playbackTitle).toBeVisible(); - await expect(syncSwitch).toHaveAttribute("data-sot-state", "unchecked"); + await expect(syncSwitch).toHaveAttribute("aria-checked", "false"); await expect(syncIntervalInput).toHaveValue("120"); await expectShadcnSelectTrigger(playbackSpeedSelect, { label: "默认速度", text: "1.5x", }); await expectShadcnSliderValue(volumeSlider, 42); - await expect(autoPlaySwitch).toHaveAttribute("data-sot-state", "checked"); + await expect(autoPlaySwitch).toHaveAttribute("aria-checked", "true"); await expect(section.locator("[data-save-actions]")).toHaveCount(0); await expect(section.locator("[data-save-action]")).toHaveCount(0); }); diff --git a/e2e/recording-list-completion-real-backend.spec.ts b/e2e/recording-list-completion-real-backend.spec.ts new file mode 100644 index 00000000..88cdb83e --- /dev/null +++ b/e2e/recording-list-completion-real-backend.spec.ts @@ -0,0 +1,1314 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { + expect, + type BrowserContext, + type Page, + type Response, + test, +} from "@playwright/test"; +import { ensureSignedIn } from "./helpers/auth"; +import { + assertCanonicalSotReferenceUnchanged, + resolveVerifiedCanonicalSotReference, + snapshotCanonicalSotReference, + type CanonicalSotReference, + type CanonicalSotReferenceSnapshot, +} from "./helpers/canonical-sot-reference"; +import { + createRecordingListCompletionFixture, + type CompletionSeed, + type RecordingListCompletionFixture, +} from "./helpers/recording-list-completion-database"; + +type QueryPayload = { + facets: { + timeline: { + all: number; + earlier: number; + last7: number; + today: number; + yesterday: number; + }; + tags: { + all: number; + items: Array<{ + color: string; + count: number; + icon: string; + id: string; + name: string; + }>; + untagged: number; + }; + }; + pagination: { page: number; pageSize: number; total: number }; + recordings: Array<{ + id: string; + tags: Array<{ + color: string; + icon: string; + id: string; + name: string; + }>; + }>; +}; + +type BrowserSnapshot = Parameters< + RecordingListCompletionFixture["captureBrowserState"] +>[0]; + +const REQUIRED_LIBRARY_BUSY_TIMEOUT_MS = 1_000; + +function assertManagedLibraryBusyTimeout() { + if ( + process.env.BETTERAINOTE_LIBRARY_BUSY_TIMEOUT_MS !== + String(REQUIRED_LIBRARY_BUSY_TIMEOUT_MS) + ) { + throw new Error( + "Recording-list completion requires the managed library busy timeout", + ); + } +} + +const list = (page: Page) => + page.locator('[data-surface="dashboard-recording-list"]'); +const listRows = (page: Page) => + list(page).locator('[data-control="dashboard-recording-row"]'); +const row = (page: Page, id: string) => + list(page).locator( + `[data-control="dashboard-recording-row"][data-recording-id="${id}"]`, + ); +const detail = (page: Page) => page.locator('[data-panel="dashboard-detail"]'); +const nextPage = (page: Page) => + page.locator('[data-control="recording-list-next-page"]'); +const previousPage = (page: Page) => + page.locator('[data-control="recording-list-prev-page"]'); +const listStateTitle = (page: Page) => + list(page).locator('[data-part="recording-list-state-title"]'); +const DETAIL_TABBABLE_SELECTOR = [ + 'button:not([disabled]):visible', + 'a[href]:visible', + 'input:not([disabled]):visible', + 'select:not([disabled]):visible', + 'textarea:not([disabled]):visible', + '[contenteditable="true"]:visible', + '[tabindex]:not([tabindex="-1"]):not([disabled]):visible', +].join(", "); + +let fixture: RecordingListCompletionFixture | null = null; +let seed: CompletionSeed | null = null; +let browserSnapshot: BrowserSnapshot | null = null; +let canonical: CanonicalSotReference; +let canonicalBefore: CanonicalSotReferenceSnapshot; + +async function browserStorageSession(page: Page, context: BrowserContext) { + const session = await context.newCDPSession(page); + return { + origin: new URL(page.url()).origin, + session, + }; +} + +async function captureBrowserState(page: Page, context: BrowserContext) { + await page.goto("/register", { waitUntil: "domcontentloaded" }); + const { origin, session } = await browserStorageSession(page, context); + try { + const result = (await session.send("DOMStorage.getDOMStorageItems", { + storageId: { isLocalStorage: true, securityOrigin: origin }, + })) as { entries: Array<[string, string]> }; + return { + cookies: await context.cookies(), + storage: Object.fromEntries(result.entries), + } satisfies BrowserSnapshot; + } finally { + await session.detach(); + } +} + +async function readBrowserState(page: Page, context: BrowserContext) { + const { origin, session } = await browserStorageSession(page, context); + try { + const result = (await session.send("DOMStorage.getDOMStorageItems", { + storageId: { isLocalStorage: true, securityOrigin: origin }, + })) as { entries: Array<[string, string]> }; + return { + cookies: await context.cookies(), + storage: Object.fromEntries(result.entries), + } satisfies BrowserSnapshot; + } finally { + await session.detach(); + } +} + +async function restoreBrowserState( + page: Page, + context: BrowserContext, + snapshot: BrowserSnapshot, +) { + if (!page.url().startsWith("http")) { + await page.goto("/register", { waitUntil: "domcontentloaded" }); + } + const { origin, session } = await browserStorageSession(page, context); + try { + const storageId = { isLocalStorage: true, securityOrigin: origin }; + await session.send("DOMStorage.clear", { storageId }); + for (const [key, value] of Object.entries(snapshot.storage)) { + await session.send("DOMStorage.setDOMStorageItem", { + key, + storageId, + value, + }); + } + } finally { + await session.detach(); + } + await context.clearCookies(); + if (snapshot.cookies.length > 0) { + await context.addCookies(snapshot.cookies); + } + expect(await readBrowserState(page, context)).toEqual(snapshot); +} + +async function query(page: Page, search: string) { + const response = await page.request.get(`/api/recordings/query?${search}`); + expect(response.status()).toBe(200); + expect(response.headers()["cache-control"]).toContain("private, no-store"); + return (await response.json()) as QueryPayload; +} + +async function queryAllIds(page: Page, sort: "newest" | "oldest" | "name") { + const ids: string[] = []; + for (const pageNumber of [1, 2, 3]) { + const payload = await query( + page, + new URLSearchParams({ + includeTranscript: "1", + page: String(pageNumber), + pageSize: "10", + sort, + }).toString(), + ); + ids.push(...payload.recordings.map((recording) => recording.id)); + } + return ids; +} + +function recordingQueryResponse( + page: Page, + predicate: (url: URL) => boolean, + timeout?: number, +) { + return page.waitForResponse( + (response: Response) => { + const url = new URL(response.url()); + return ( + response.request().method() === "GET" && + url.pathname === "/api/recordings/query" && + predicate(url) + ); + }, + timeout === undefined ? undefined : { timeout }, + ); +} + +async function saveDisplay( + page: Page, + update: Record, +) { + const response = await page.request.put("/api/settings/display", { + data: update, + }); + expect(response.ok()).toBe(true); +} + +async function openDashboard(page: Page) { + await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); + await expect(list(page)).toHaveAttribute("data-list-state", "ready"); + await expect(listRows(page)).toHaveCount(10); +} + +async function readComputedMaterial( + page: Page, + selector: string, + properties: readonly string[], +) { + const session = await page.context().newCDPSession(page); + try { + await session.send("DOM.enable"); + await session.send("CSS.enable"); + const document = (await session.send("DOM.getDocument")) as { + root: { nodeId: number }; + }; + const match = (await session.send("DOM.querySelector", { + nodeId: document.root.nodeId, + selector, + })) as { nodeId: number }; + if (match.nodeId === 0) { + throw new Error("Recording-list material selector was not found"); + } + const result = (await session.send("CSS.getComputedStyleForNode", { + nodeId: match.nodeId, + })) as { computedStyle: Array<{ name: string; value: string }> }; + const values = new Map( + result.computedStyle.map(({ name, value }) => [name, value]), + ); + return Object.fromEntries( + properties.map((property) => [property, values.get(property)]), + ); + } finally { + await session.detach(); + } +} + +async function strictPixelAt(page: Page, locator: ReturnType) { + const box = await locator.boundingBox(); + if (!box) throw new Error("Recording-list pixel oracle is not visible"); + return page.screenshot({ + clip: { height: 1, width: 1, x: box.x + 2, y: box.y + 2 }, + }); +} + +function pngWidth(image: Buffer) { + if (image.subarray(1, 4).toString("ascii") !== "PNG") { + throw new Error("Recording-list overflow oracle requires PNG output"); + } + return image.readUInt32BE(16); +} + +async function canonicalPaginatedFirstTemplate() { + const source = await readFile( + path.join(canonical.root, "ui_kits/web/index.html"), + "utf8", + ); + const startMarker = + '