- Default fresh browser sessions to encrypted credential persistence with an explicit session-only opt-out - Keep API-key links left-aligned, legible, and limited to their text click target - Update credential guidance and browser coverage
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
|
|
|
import { CanvasHelper } from '#tests/helpers/canvas'
|
|
|
|
const USE_REAL_LLM = process.env.TEST_REAL_LLM === '1'
|
|
const OPENROUTER_KEY = process.env.OPENROUTER_API_KEY ?? ''
|
|
|
|
let page: Page
|
|
let canvas: CanvasHelper
|
|
|
|
test.describe.configure({ mode: 'serial' })
|
|
|
|
test.beforeAll(async ({ browser }) => {
|
|
page = await browser.newPage()
|
|
await page.goto('/')
|
|
canvas = new CanvasHelper(page)
|
|
await canvas.waitForInit()
|
|
|
|
if (!USE_REAL_LLM) {
|
|
await injectMockTransport(page)
|
|
}
|
|
})
|
|
|
|
test.afterAll(async () => {
|
|
await page.close()
|
|
})
|
|
|
|
async function injectMockTransport(page: Page) {
|
|
await page.evaluate(() => {
|
|
const setChatTransport = window.openPencil?.setChatTransport
|
|
if (!setChatTransport) throw new Error('Transport override not available')
|
|
|
|
let msgCounter = 0
|
|
|
|
setChatTransport(() => ({
|
|
async sendMessages({
|
|
messages
|
|
}: {
|
|
messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }>
|
|
}) {
|
|
const lastUser = [...messages].reverse().find((m) => m.role === 'user')
|
|
const text = lastUser?.parts?.find((p) => p.type === 'text')?.text ?? ''
|
|
const msgId = `mock-msg-${++msgCounter}`
|
|
const lowerText = text.toLowerCase()
|
|
const wantsTool = lowerText.includes('frame') || lowerText.includes('rectangle')
|
|
|
|
if (lowerText.includes('missing agent')) {
|
|
throw new Error(
|
|
'"claude-agent-acp" is not installed. Install it with: npm i -g @agentclientprotocol/claude-agent-acp'
|
|
)
|
|
}
|
|
|
|
return new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue({ type: 'start', messageId: msgId })
|
|
|
|
if (wantsTool) {
|
|
const toolCallId = `call-${msgId}`
|
|
controller.enqueue({
|
|
type: 'tool-input-start',
|
|
toolCallId,
|
|
toolName: 'create_shape'
|
|
})
|
|
controller.enqueue({
|
|
type: 'tool-input-delta',
|
|
toolCallId,
|
|
inputTextDelta:
|
|
'{"type":"FRAME","x":100,"y":100,"width":200,"height":150,"name":"Card"}'
|
|
})
|
|
controller.enqueue({
|
|
type: 'tool-input-available',
|
|
toolCallId,
|
|
toolName: 'create_shape',
|
|
input: { type: 'FRAME', x: 100, y: 100, width: 200, height: 150, name: 'Card' }
|
|
})
|
|
controller.enqueue({
|
|
type: 'tool-output-available',
|
|
toolCallId,
|
|
toolName: 'create_shape',
|
|
output: {
|
|
id: '0:99',
|
|
type: 'FRAME',
|
|
x: 100,
|
|
y: 100,
|
|
width: 200,
|
|
height: 150,
|
|
name: 'Card'
|
|
}
|
|
})
|
|
}
|
|
|
|
const words = wantsTool
|
|
? ['Created', 'a', 'frame', 'called', '"Card".']
|
|
: `I'll help you with: "${text}". Here's a mock response.`.split(' ')
|
|
|
|
controller.enqueue({ type: 'text-start', id: 'text-1' })
|
|
for (const word of words) {
|
|
controller.enqueue({ type: 'text-delta', id: 'text-1', delta: word + ' ' })
|
|
}
|
|
controller.enqueue({ type: 'text-end', id: 'text-1' })
|
|
controller.enqueue({ type: 'finish', finishReason: 'stop' })
|
|
controller.close()
|
|
}
|
|
})
|
|
},
|
|
async reconnectToStream() {
|
|
return null
|
|
}
|
|
}))
|
|
})
|
|
}
|
|
|
|
function chatTab() {
|
|
return page.getByRole('tab', { name: 'AI' })
|
|
}
|
|
|
|
function designTab() {
|
|
return page.getByRole('tab', { name: 'Design' })
|
|
}
|
|
|
|
function chatInput() {
|
|
return page.locator('input[placeholder="Describe a change…"]')
|
|
}
|
|
|
|
function apiKeyInput() {
|
|
return page.getByTestId('provider-settings-api-key')
|
|
}
|
|
|
|
test('⌘J switches to AI tab', async () => {
|
|
await designTab().waitFor()
|
|
await page.keyboard.press('Meta+j')
|
|
await expect(chatTab()).toHaveAttribute('data-state', 'active')
|
|
})
|
|
|
|
test('⌘J switches back to Design tab', async () => {
|
|
await page.keyboard.press('Meta+j')
|
|
await expect(designTab()).toHaveAttribute('data-state', 'active')
|
|
})
|
|
|
|
test('clicking AI tab directs provider setup to unified settings', async () => {
|
|
await chatTab().click()
|
|
await expect(page.getByText('Connect an AI provider to start chatting.')).toBeVisible()
|
|
await expect(page.getByTestId('provider-setup-open-settings')).toBeVisible()
|
|
await expect(apiKeyInput()).toBeHidden()
|
|
})
|
|
|
|
test('saving API key in unified settings shows chat interface', async () => {
|
|
const key = USE_REAL_LLM ? OPENROUTER_KEY : 'sk-or-test-key-12345'
|
|
await page.getByTestId('provider-setup-open-settings').click()
|
|
await expect(page.getByTestId('app-settings-dialog')).toBeVisible()
|
|
await expect(page.getByTestId('settings-remember-credentials')).toHaveAttribute(
|
|
'data-state',
|
|
'checked'
|
|
)
|
|
await expect(page.getByTestId('settings-credential-backend')).toContainText(
|
|
'encrypted browser storage'
|
|
)
|
|
await page.locator('[data-model-id]').first().click()
|
|
await page.getByTestId('settings-model-provider').click()
|
|
await page.getByRole('option', { name: 'OpenRouter' }).click()
|
|
await page.getByLabel('Name').fill('Claude Sonnet')
|
|
await apiKeyInput().fill(key)
|
|
await page.getByRole('button', { name: 'Save model' }).click()
|
|
await page.getByTestId('app-settings-done').click()
|
|
|
|
await expect(chatInput()).toBeVisible()
|
|
await expect(page.getByText('Describe what you want to create or change.')).toBeVisible()
|
|
})
|
|
|
|
test('empty input has disabled send button', async () => {
|
|
const sendButton = page.locator('button[type="submit"]')
|
|
await expect(sendButton).toBeDisabled()
|
|
})
|
|
|
|
test('typing enables send button', async () => {
|
|
await chatInput().fill('Make a red rectangle')
|
|
const sendButton = page.locator('button[type="submit"]')
|
|
await expect(sendButton).toBeEnabled()
|
|
})
|
|
|
|
test('Enter submits message and clears input', async () => {
|
|
await chatInput().fill('Hello there')
|
|
await chatInput().press('Enter')
|
|
|
|
await expect(page.getByText('Hello there', { exact: true })).toBeVisible({ timeout: 5000 })
|
|
await expect(chatInput()).toHaveValue('')
|
|
})
|
|
|
|
test('assistant responds', async () => {
|
|
if (USE_REAL_LLM) {
|
|
await expect(page.locator('.chat-markdown, [class*="rounded-tl-md"]').first()).toBeVisible({
|
|
timeout: 30000
|
|
})
|
|
} else {
|
|
await expect(page.getByText('mock response', { exact: false })).toBeVisible({ timeout: 5000 })
|
|
}
|
|
})
|
|
|
|
test('model selector is visible and clickable', async () => {
|
|
const trigger = page.getByTestId('chat-model-selector')
|
|
await expect(trigger).toBeVisible()
|
|
await trigger.click()
|
|
|
|
await expect(page.getByRole('option', { name: /Claude Sonnet 4\.6/ })).toBeVisible()
|
|
await expect(page.getByText('Best for design')).toBeVisible()
|
|
await expect(page.getByText('Free').first()).toBeVisible()
|
|
|
|
await page.getByRole('option', { name: /Claude Sonnet 4\.6/ }).click()
|
|
await expect(page.getByRole('option', { name: /Claude Sonnet 4\.6/ })).toBeHidden()
|
|
})
|
|
|
|
test('tool calls render in assistant message', async () => {
|
|
await chatInput().fill('Create a frame')
|
|
await chatInput().press('Enter')
|
|
|
|
if (USE_REAL_LLM) {
|
|
await expect(page.locator('.chat-markdown, [class*="rounded-tl-md"]').first()).toBeVisible({
|
|
timeout: 30000
|
|
})
|
|
} else {
|
|
await expect(page.getByText('Create Shape')).toBeVisible({ timeout: 5000 })
|
|
await expect(page.getByText('Done')).toBeVisible()
|
|
await expect(page.getByText('Created a frame', { exact: false })).toBeVisible()
|
|
}
|
|
})
|
|
|
|
test('switching tabs preserves chat', async () => {
|
|
const selectedModel = page.getByRole('option', { name: /Claude Sonnet 4\.6/ })
|
|
if (await selectedModel.isVisible().catch(() => false)) {
|
|
await selectedModel.click()
|
|
}
|
|
await designTab().click({ timeout: 10000 })
|
|
await expect(designTab()).toHaveAttribute('data-state', 'active')
|
|
|
|
await chatTab().click()
|
|
await expect(page.getByText('Hello there', { exact: true })).toBeVisible({ timeout: 10000 })
|
|
})
|
|
|
|
test('OpenRouter accepts a custom model ID from provider settings', async () => {
|
|
const customModel = 'meta-llama/llama-3.3-70b-instruct'
|
|
|
|
await page.keyboard.press('Escape')
|
|
await page.getByTestId('provider-settings-trigger').click()
|
|
await page.locator('[data-model-id]').first().click()
|
|
const customModelInput = page.getByTestId('provider-settings-custom-model')
|
|
await expect(customModelInput).toBeVisible()
|
|
await customModelInput.fill(customModel)
|
|
await page.getByRole('button', { name: 'Save model' }).click()
|
|
await page.getByTestId('app-settings-done').click()
|
|
|
|
await expect(page.getByTestId('chat-custom-model-label')).toContainText(customModel)
|
|
await expect(page.getByTestId('chat-model-selector')).toBeHidden()
|
|
|
|
await page.getByTestId('provider-settings-trigger').click()
|
|
await page.locator('[data-model-id]').first().click()
|
|
await page.getByTestId('provider-settings-custom-model').fill('')
|
|
await page.getByRole('button', { name: 'Save model' }).click()
|
|
await page.getByTestId('app-settings-done').click()
|
|
|
|
await expect(page.getByTestId('chat-model-selector')).toBeVisible()
|
|
})
|
|
|
|
test('transport errors show an actionable toast', async () => {
|
|
await chatInput().fill('Trigger missing agent error')
|
|
await chatInput().press('Enter')
|
|
|
|
await expect(
|
|
page.getByTestId('toast-item').filter({
|
|
hasText: 'Install it with: npm i -g @agentclientprotocol/claude-agent-acp'
|
|
})
|
|
).toBeVisible({ timeout: 5000 })
|
|
})
|
|
|
|
test('"Get API key" link opens external URL via window.open', async () => {
|
|
await page.getByTestId('provider-settings-trigger').click()
|
|
await page.locator('[data-model-id]').first().click()
|
|
await page.getByTestId('provider-settings-clear-key').click()
|
|
await page.getByRole('button', { name: 'Back' }).click()
|
|
await page.getByTestId('app-settings-done').click()
|
|
await expect(page.getByTestId('provider-setup-open-settings')).toBeVisible()
|
|
await page.getByTestId('provider-setup-open-settings').click()
|
|
await page.locator('[data-model-id]').first().click()
|
|
await page.getByTestId('settings-model-provider').click()
|
|
await page.getByRole('option', { name: 'OpenRouter' }).click()
|
|
|
|
const link = page.getByRole('button', { name: 'Get API key →' })
|
|
await expect(link).toBeVisible()
|
|
|
|
// Intercept window.open to verify it's called with the right URL
|
|
const openedUrls: string[] = []
|
|
await page.exposeFunction('mockWindowOpen', (url: string) => openedUrls.push(url))
|
|
await page.evaluate(() => {
|
|
window.openPencil ??= {}
|
|
window.openPencil.test = { ...window.openPencil.test, savedOpen: window.open }
|
|
window.open = (url: string | URL) => {
|
|
window.mockWindowOpen?.(String(url))
|
|
return null
|
|
}
|
|
})
|
|
|
|
await link.click()
|
|
|
|
await expect(() => {
|
|
expect(openedUrls.length).toBeGreaterThan(0)
|
|
expect(openedUrls[0]).toMatch(/^https:\/\//)
|
|
}).toPass({ timeout: 3000 })
|
|
|
|
// Restore
|
|
await page.evaluate(() => {
|
|
const savedOpen = window.openPencil?.test?.savedOpen
|
|
if (savedOpen) window.open = savedOpen
|
|
})
|
|
})
|