import { randomUUID } from 'node:crypto' import { expect, test } from '@playwright/test' import { createClient, type SupabaseClient } from '@supabase/supabase-js' const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY const serviceRoleKey = process.env.E2E_SUPABASE_SERVICE_ROLE_KEY const hasSupabaseFixtureAccess = Boolean(supabaseUrl && anonKey && serviceRoleKey) test.describe('Dashboard, dictionary and command Supabase parity', () => { test.skip(!hasSupabaseFixtureAccess, 'Local Supabase fixture credentials are required') test.describe.configure({ mode: 'serial' }) test.setTimeout(90_000) const runId = randomUUID() const ownerEmail = `web-parity-owner-${runId}@d3ro.test` const otherEmail = `web-parity-other-${runId}@d3ro.test` const password = `WebParity-${runId}-Aa1!` const recentTitle = `DASHBOARD RECENT ${runId}` const foreignHistoryTitle = `FOREIGN HISTORY ${runId}` const foreignDictionaryWord = `FOREIGN-DICTIONARY-${runId}` const needleWord = `NEEDLE-${runId}` const oldestWord = `OLDEST-${runId}` const conflictWord = `CONFLICT-${runId}` const remoteConflictWord = `REMOTE-CONFLICT-${runId}` const createdWord = `CREATED-${runId}` const historyIds = Array.from({ length: 5 }, () => randomUUID()) const dictionaryIds = Array.from({ length: 22 }, () => randomUUID()) const conflictId = dictionaryIds[2] const foreignInstructionId = randomUUID() const foreignInstructionName = `FOREIGN-INSTRUCTION-${runId}` let admin: SupabaseClient let ownerId = '' let otherId = '' async function login(page: import('@playwright/test').Page): Promise { await page.goto('/login') await page.getByPlaceholder('user@studio.com').fill(ownerEmail) await page.getByPlaceholder('••••••••').fill(password) await page.getByRole('button', { name: '로그인', exact: true }).click() await page.waitForURL(/\/dashboard/, { timeout: 15_000 }) } test.beforeAll(async () => { admin = createClient(supabaseUrl!, serviceRoleKey!, { auth: { autoRefreshToken: false, persistSession: false } }) const ownerResult = await admin.auth.admin.createUser({ email: ownerEmail, password, email_confirm: true }) if (ownerResult.error || !ownerResult.data.user) throw ownerResult.error ?? new Error('Owner fixture was not created') ownerId = ownerResult.data.user.id const otherResult = await admin.auth.admin.createUser({ email: otherEmail, password, email_confirm: true }) if (otherResult.error || !otherResult.data.user) throw otherResult.error ?? new Error('Other fixture was not created') otherId = otherResult.data.user.id const now = Date.now() const { error: historyError } = await admin.from('history').insert([ { id: historyIds[0], user_id: ownerId, title: recentTitle, original_text: `Owner current transcript ${runId}`, duration: 61, word_count: 3, status: 'completed', created_at: new Date(now).toISOString() }, { id: historyIds[1], user_id: ownerId, title: `YESTERDAY ${runId}`, original_text: `Owner yesterday transcript ${runId}`, duration: 62, word_count: 4, status: 'completed', created_at: new Date(now - 24 * 60 * 60 * 1_000).toISOString() }, { id: historyIds[2], user_id: ownerId, title: `OLDER ${runId}`, original_text: `Owner older transcript ${runId}`, duration: 63, word_count: 5, status: 'completed', created_at: new Date(now - 2 * 24 * 60 * 60 * 1_000).toISOString() }, { id: historyIds[3], user_id: ownerId, title: `ERROR ROW ${runId}`, original_text: `Excluded failed transcript ${runId}`, duration: 9_999, word_count: 9_999, status: 'error', error_code: 'fixture', created_at: new Date(now - 500).toISOString() }, { id: historyIds[4], user_id: otherId, title: foreignHistoryTitle, original_text: foreignHistoryTitle, duration: 99, word_count: 99, status: 'completed', created_at: new Date(now - 1_000).toISOString() } ]) if (historyError) throw historyError const { error: subscriptionError } = await admin .from('subscriptions') .update({ tier: 'pro', provider: 'admin', status: 'active', overage_credits: 7 }) .eq('user_id', ownerId) if (subscriptionError) throw subscriptionError const dictionaryRows = dictionaryIds.map((id, index) => ({ id, user_id: ownerId, word: index === 0 ? needleWord : index === 2 ? conflictWord : index === 21 ? oldestWord : `WORD-${index}-${runId}`, pronunciation: index === 0 ? `니들-${runId}` : null, category: index % 2 === 0 ? 'technical' : 'user', usage_count: index, updated_at: new Date(now - index * 60_000).toISOString() })) const { error: dictionaryError } = await admin.from('dictionary').insert(dictionaryRows) if (dictionaryError) throw dictionaryError const { error: foreignDictionaryError } = await admin.from('dictionary').insert({ user_id: otherId, word: foreignDictionaryWord, category: 'user' }) if (foreignDictionaryError) throw foreignDictionaryError const { error: foreignInstructionError } = await admin.from('custom_instructions').insert({ id: foreignInstructionId, user_id: otherId, name: foreignInstructionName, description: 'Must remain hidden by RLS', prompt: 'Never visible {{text}}', sort_order: 100 }) if (foreignInstructionError) throw foreignInstructionError }) test.afterAll(async () => { if (ownerId) await admin.auth.admin.deleteUser(ownerId) if (otherId) await admin.auth.admin.deleteUser(otherId) }) test('dashboard renders exact RPC aggregates and subscription, then recovers from an RPC failure', async ({ page }) => { await login(page) await expect(page.getByText('내 음성 작업 현황')).toBeVisible() await expect(page.getByTestId('dashboard-stat-전체 단어 수')).toContainText('12') await expect(page.getByTestId('dashboard-subscription')).toContainText('PRO') await expect(page.getByTestId('dashboard-subscription')).toContainText('관리자 부여') await expect(page.getByTestId('dashboard-subscription')).toContainText('7') await expect(page.getByText(recentTitle)).toBeVisible() await expect(page.getByText(foreignHistoryTitle)).toHaveCount(0) await page.route('**/rest/v1/rpc/mobile_dashboard_stats', async (route) => { await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ message: 'fixture failure' }) }) }) await page.reload() await expect(page.getByText('대시보드 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.')).toBeVisible() await page.unroute('**/rest/v1/rpc/mobile_dashboard_stats') await page.getByRole('button', { name: '다시 시도' }).click() await expect(page.getByText(recentTitle)).toBeVisible() }) test('dictionary uses RLS CRUD, filters, pagination, duplicate protection and optimistic rollback', async ({ page }) => { await login(page) await page.goto('/dictionary') await expect(page.getByText('CUSTOM DICTIONARY')).toBeVisible() await expect(page.getByText(foreignDictionaryWord)).toHaveCount(0) await expect(page.getByText(oldestWord)).toHaveCount(0) await page.getByRole('button', { name: '더 불러오기' }).click() await expect(page.getByText(oldestWord)).toBeVisible() await page.getByLabel('커스텀 사전 검색').fill('NEEDLE') await expect(page.getByText(needleWord)).toBeVisible() await expect(page.getByText(oldestWord)).toHaveCount(0) await page.getByLabel('커스텀 사전 검색').fill('') await page.getByText('기술', { exact: true }).first().click() await expect(page.getByText(needleWord)).toBeVisible() await expect(page.getByText(`WORD-1-${runId}`)).toHaveCount(0) await page.getByText('전체', { exact: true }).click() await page.getByRole('button', { name: '단어 추가' }).click() await page.getByRole('textbox', { name: '단어', exact: true }).fill(createdWord) await page.getByLabel('발음 (선택)').fill(`크리에이티드-${runId}`) await page.getByRole('button', { name: '저장' }).click() await expect(page.getByText(createdWord)).toBeVisible() await expect.poll(async () => { const { count } = await admin.from('dictionary').select('id', { count: 'exact', head: true }).eq('user_id', ownerId).eq('word', createdWord) return count }).toBe(1) await page.getByRole('button', { name: '단어 추가' }).click() await page.getByRole('textbox', { name: '단어', exact: true }).fill(createdWord.toLocaleLowerCase()) await page.getByRole('button', { name: '저장' }).click() await expect(page.getByText('같은 분류에 동일한 단어가 이미 있습니다.')).toBeVisible() await page.getByRole('button', { name: `${conflictWord} 편집` }).click() const { error: remoteUpdateError } = await admin.from('dictionary').update({ word: remoteConflictWord }).eq('id', conflictId) if (remoteUpdateError) throw remoteUpdateError await page.getByRole('textbox', { name: '단어', exact: true }).fill(`ATTEMPTED-${runId}`) await page.getByRole('button', { name: '저장' }).click() await expect(page.getByText('다른 기기에서 변경된 단어입니다. 목록을 새로고침한 뒤 다시 시도해 주세요.')).toBeVisible() await expect(page.getByText(conflictWord)).toBeVisible() await page.getByRole('button', { name: '새로고침' }).click() await expect(page.getByText(remoteConflictWord)).toBeVisible() await page.route('**/rest/v1/dictionary*', async (route) => { if (route.request().method() === 'DELETE') { await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ message: 'fixture delete failure' }) }) } else { await route.continue() } }) page.once('dialog', (dialog) => dialog.accept()) await page.getByRole('button', { name: `${createdWord} 삭제` }).click() await expect(page.getByText(createdWord)).toBeVisible() await expect(page.getByText('사전을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.')).toBeVisible() await page.unroute('**/rest/v1/dictionary*') const { count: retainedCount } = await admin.from('dictionary').select('id', { count: 'exact', head: true }).eq('user_id', ownerId).eq('word', createdWord) expect(retainedCount).toBe(1) page.once('dialog', (dialog) => dialog.accept()) await page.getByRole('button', { name: `${createdWord} 삭제` }).click() await expect(page.getByText(createdWord)).toHaveCount(0) await expect.poll(async () => { const { count } = await admin.from('dictionary').select('id', { count: 'exact', head: true }).eq('user_id', ownerId).eq('word', createdWord) return count }).toBe(0) const ownerClient = createClient(supabaseUrl!, anonKey!, { auth: { persistSession: false, autoRefreshToken: false } }) const signIn = await ownerClient.auth.signInWithPassword({ email: ownerEmail, password }) if (signIn.error) throw signIn.error const foreignRead = await ownerClient.from('dictionary').select('id').eq('word', foreignDictionaryWord) expect(foreignRead.error).toBeNull() expect(foreignRead.data).toEqual([]) const foreignWrite = await ownerClient.from('dictionary').update({ word: `STOLEN-${runId}` }).eq('user_id', otherId).select('id') expect(foreignWrite.error).toBeNull() expect(foreignWrite.data).toEqual([]) await ownerClient.auth.signOut({ scope: 'local' }) }) test('commands sync immutable built-ins, custom CRUD/order/CAS/active state and the real llm-proxy', async ({ page }) => { await login(page) const bootstrapClient = createClient(supabaseUrl!, anonKey!, { auth: { persistSession: false, autoRefreshToken: false } }) const bootstrapSignIn = await bootstrapClient.auth.signInWithPassword({ email: ownerEmail, password }) if (bootstrapSignIn.error) throw bootstrapSignIn.error const [bootstrapA, bootstrapB] = await Promise.all([ bootstrapClient.rpc('bootstrap_custom_instructions'), bootstrapClient.rpc('bootstrap_custom_instructions') ]) expect(bootstrapA.error).toBeNull() expect(bootstrapB.error).toBeNull() expect(bootstrapA.data).toHaveLength(4) expect(bootstrapB.data).toHaveLength(4) await bootstrapClient.auth.signOut({ scope: 'local' }) await page.goto('/commands') await expect(page.getByText('SYNCED COMMANDS (4)')).toBeVisible() await expect(page.getByText('Translate to English', { exact: true }).first()).toBeVisible() await expect(page.getByText('BUILT-IN · READ ONLY').first()).toBeVisible() await expect(page.getByRole('button', { name: 'Translate to English 편집' })).toHaveCount(0) await expect(page.getByText(foreignInstructionName)).toHaveCount(0) const firstCustomName = `CUSTOM-A-${runId}` const secondCustomName = `CUSTOM-B-${runId}` const editedSecondName = `CUSTOM-B-EDITED-${runId}` const remoteFirstName = `CUSTOM-A-REMOTE-${runId}` const attemptedFirstName = `CUSTOM-A-ATTEMPTED-${runId}` const firstPrompt = `FIRST PROMPT ${runId} {{text}}` await page.getByRole('button', { name: '사용자 명령 추가' }).click() await page.getByLabel('명령 이름').fill(firstCustomName) await page.getByLabel('명령 설명').fill('첫 번째 사용자 명령') await page.getByLabel('명령 프롬프트').fill(firstPrompt) await page.getByRole('button', { name: '저장' }).click() await expect(page.getByText(firstCustomName, { exact: true }).first()).toBeVisible() await page.getByRole('button', { name: '사용자 명령 추가' }).click() await page.getByLabel('명령 이름').fill(secondCustomName) await page.getByLabel('명령 설명').fill('두 번째 사용자 명령') await page.getByLabel('명령 프롬프트').fill(`SECOND PROMPT ${runId}`) await page.getByRole('button', { name: '저장' }).click() await expect(page.getByText(secondCustomName)).toBeVisible() await expect.poll(async () => { const { data } = await admin.from('custom_instructions').select('id,revision,sort_order').eq('user_id', ownerId).eq('name', firstCustomName).maybeSingle() return data }).not.toBeNull() const { data: firstInstruction } = await admin.from('custom_instructions').select('id,revision,sort_order').eq('user_id', ownerId).eq('name', firstCustomName).single() const { data: secondInstruction } = await admin.from('custom_instructions').select('id,revision,sort_order').eq('user_id', ownerId).eq('name', secondCustomName).single() if (!firstInstruction || !secondInstruction) throw new Error('Custom command fixtures were not created') await page.getByRole('button', { name: `${firstCustomName} 명령 활성화` }).click() await expect(page.getByText('ACTIVE SYNCED INSTRUCTION').locator('..')).toContainText(firstCustomName) await expect.poll(async () => { const { data } = await admin.from('user_settings').select('active_instruction_id').eq('user_id', ownerId).single() return data?.active_instruction_id }).toBe(firstInstruction.id) await page.reload() await expect(page.getByText('ACTIVE SYNCED INSTRUCTION').locator('..')).toContainText(firstCustomName) await page.getByRole('button', { name: `${secondCustomName} 편집` }).click() await page.getByLabel('명령 이름').fill(editedSecondName) await page.getByRole('button', { name: '저장' }).click() await expect(page.getByText(editedSecondName)).toBeVisible() await expect.poll(async () => { const { data } = await admin.from('custom_instructions').select('revision').eq('id', secondInstruction.id).single() return data?.revision }).toBe(2) await page.getByRole('button', { name: `${firstCustomName} 아래로` }).click() await expect.poll(async () => { const { data } = await admin.from('custom_instructions').select('sort_order').eq('id', firstInstruction.id).single() return Number(data?.sort_order) }).toBeGreaterThan(Number(secondInstruction.sort_order)) const customCardTexts = await page.locator('[data-testid^="command-card-"]').allTextContents() expect(customCardTexts.findIndex((text) => text.includes(editedSecondName))).toBeLessThan(customCardTexts.findIndex((text) => text.includes(firstCustomName))) await page.getByRole('button', { name: `${firstCustomName} 편집` }).click() const { error: remoteInstructionError } = await admin .from('custom_instructions') .update({ name: remoteFirstName, prompt: `REMOTE PROMPT ${runId} {{text}}` }) .eq('id', firstInstruction.id) if (remoteInstructionError) throw remoteInstructionError await page.getByLabel('명령 이름').fill(attemptedFirstName) await page.getByRole('button', { name: '저장' }).click() await expect(page.getByText('다른 기기에서 변경된 명령입니다. 새로고침한 뒤 다시 시도해 주세요.')).toBeVisible() await expect(page.getByText(firstCustomName, { exact: true }).first()).toBeVisible() await page.getByRole('button', { name: '새로고침' }).click() await expect(page.getByText(remoteFirstName, { exact: true }).first()).toBeVisible() await expect(page.getByText('ACTIVE SYNCED INSTRUCTION').locator('..')).toContainText(remoteFirstName) const marker = `COMMAND INPUT ${runId}` await page.getByLabel('명령 테스트 입력').fill(marker) const requestPromise = page.waitForRequest((request) => request.url().endsWith('/functions/v1/llm-proxy') && request.method() === 'POST') const responsePromise = page.waitForResponse((response) => response.url().endsWith('/functions/v1/llm-proxy')) await page.getByRole('button', { name: '실행', exact: true }).click() const [request, response] = await Promise.all([requestPromise, responsePromise]) const body = request.postDataJSON() as { messages?: Array<{ content?: string }>; stream?: boolean } expect(body.stream).toBe(false) expect(body.messages?.[0]?.content).toContain(`REMOTE PROMPT ${runId}`) expect(body.messages?.[0]?.content).toContain(marker) if (response.ok()) { await expect(page.getByTestId('command-output')).not.toBeEmpty() await expect(page.getByTestId('command-error')).toHaveCount(0) } else { expect(response.status()).toBeGreaterThanOrEqual(400) await expect(page.getByTestId('command-error')).toBeVisible() await expect(page.getByTestId('command-output')).toHaveCount(0) } page.once('dialog', (dialog) => dialog.accept()) await page.getByRole('button', { name: `${editedSecondName} 삭제` }).click() await expect(page.getByText(editedSecondName)).toHaveCount(0) await expect.poll(async () => { const { count } = await admin.from('custom_instructions').select('id', { count: 'exact', head: true }).eq('id', secondInstruction.id) return count }).toBe(0) const ownerClient = createClient(supabaseUrl!, anonKey!, { auth: { persistSession: false, autoRefreshToken: false } }) const signIn = await ownerClient.auth.signInWithPassword({ email: ownerEmail, password }) if (signIn.error) throw signIn.error const foreignRead = await ownerClient.from('custom_instructions').select('id').eq('id', foreignInstructionId) expect(foreignRead.error).toBeNull() expect(foreignRead.data).toEqual([]) const foreignActivation = await ownerClient.rpc('set_active_custom_instruction', { instruction_id: foreignInstructionId }) expect(foreignActivation.error).not.toBeNull() const builtinWrite = await ownerClient .from('custom_instructions') .update({ name: `MUTATED-BUILTIN-${runId}` }) .eq('user_id', ownerId) .eq('builtin_key', 'translate_en') .select('id') expect(builtinWrite.error).toBeNull() expect(builtinWrite.data).toEqual([]) await ownerClient.auth.signOut({ scope: 'local' }) }) })