Skip to content
Merged
118 changes: 118 additions & 0 deletions e2e/saved-ui.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { expect, test } from './playwright-utils.ts'
import { createStableUserIdFromEmail } from '#worker/user-id.ts'
import { execFile as execFileCb } from 'node:child_process'
import { promisify } from 'node:util'

const execFile = promisify(execFileCb)

test.describe.configure({ mode: 'serial' })

async function seedSavedUi(options: {
appId: string
userId: string
title: string
description: string
code: string
}) {
const sql = [
'INSERT OR REPLACE INTO ui_artifacts (',
'id, user_id, title, description, keywords, source_type, source_code, search_text, created_at, updated_at',
') VALUES (',
`'${options.appId}',`,
`'${options.userId}',`,
`'${options.title.replaceAll("'", "''")}',`,
`'${options.description.replaceAll("'", "''")}',`,
`'["saved-ui"]',`,
"'html',",
`'${options.code.replaceAll("'", "''")}',`,
"'saved ui route e2e',",
'CURRENT_TIMESTAMP,',
'CURRENT_TIMESTAMP',
');',
].join(' ')

let lastError: unknown = null
for (const delayMs of [0, 150, 300, 600]) {
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
try {
await execFile(
'bun',
[
'--no-env-file',
'--env-file=packages/worker/.env',
'./wrangler-env.ts',
'd1',
'execute',
'APP_DB',
'--local',
'--persist-to',
'.wrangler/state/e2e',
'--command',
sql,
],
{
cwd: process.cwd(),
},
)
return
} catch (error) {
lastError = error
const message = error instanceof Error ? error.message : String(error)
if (!message.includes('database is locked')) {
throw error
}
}
}
throw lastError instanceof Error
? lastError
: new Error('Unable to seed saved UI into the e2e database.')
}

test('authenticated user can open their hosted saved ui route', async ({
page,
login,
}) => {
const appId = `saved-ui-${Date.now()}`
await seedSavedUi({
appId,
userId: '1',
title: 'Hosted saved ui',
description: 'Saved ui route test artifact',
code: '<main><h1>Hosted saved ui</h1><p>Saved UI route e2e body</p></main>',
})
await login()

await page.goto(`/ui/${appId}`)

await expect(
page.getByRole('heading', { name: 'Hosted saved ui' }),
).toBeVisible()
// Shell page is one iframe; the saved app document is nested inside
// `[data-generated-ui-frame]` (see generated-ui-shell entry HTML).
const shellFrame = page.frameLocator('[data-saved-ui-shell]')
const appFrame = shellFrame.frameLocator('[data-generated-ui-frame]')
await expect(
appFrame.getByRole('heading', { name: 'Hosted saved ui' }),
).toBeVisible()
await expect(appFrame.getByText('Saved UI route e2e body')).toBeVisible()
})

test('saved ui route redirects unauthenticated users to login', async ({
page,
}) => {
const appId = `saved-ui-redirect-${Date.now()}`
const emailOwnerId = await createStableUserIdFromEmail('me@kentcdodds.com')
await seedSavedUi({
appId,
userId: emailOwnerId,
title: 'Redirect saved ui',
description: 'Saved ui auth redirect test',
code: '<main><h1>Redirect saved ui</h1></main>',
})

await page.context().clearCookies()
await page.goto(`/ui/${appId}`)
await expect(page).toHaveURL(/\/login\?redirectTo=%2Fui%2F/)
})
21 changes: 11 additions & 10 deletions packages/worker/client/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ export function App(handle: Handle) {
}

return () => {
const isChatLayout = currentPathname.startsWith('/chat')
const isWideLayout =
currentPathname.startsWith('/chat') || currentPathname.startsWith('/ui/')
const sessionEmail = session?.email ?? ''
const isSessionReady = sessionStatus === 'ready'
const isLoggedIn = isSessionReady && Boolean(sessionEmail)
Expand All @@ -103,23 +104,23 @@ export function App(handle: Handle) {
return (
<main
css={{
maxWidth: isChatLayout ? 'none' : '52rem',
maxWidth: isWideLayout ? 'none' : '52rem',
width: '100%',
margin: isChatLayout ? 0 : '0 auto',
padding: isChatLayout
margin: isWideLayout ? 0 : '0 auto',
padding: isWideLayout
? `${spacing.lg} ${spacing.xl} ${spacing.sm}`
: spacing['2xl'],
minHeight: isChatLayout ? '100vh' : undefined,
minHeight: isWideLayout ? '100vh' : undefined,
fontFamily: typography.fontFamily,
boxSizing: 'border-box',
[mq.tablet]: isChatLayout
[mq.tablet]: isWideLayout
? {
padding: `${spacing.sm} ${spacing.sm} 0`,
}
: {
padding: spacing.xl,
},
[mq.mobile]: isChatLayout
[mq.mobile]: isWideLayout
? {
padding: `${spacing.md} ${spacing.md} ${spacing.sm}`,
}
Expand All @@ -134,16 +135,16 @@ export function App(handle: Handle) {
alignItems: 'center',
gap: spacing.md,
flexWrap: 'wrap',
marginBottom: isChatLayout ? spacing.lg : spacing.xl,
[mq.tablet]: isChatLayout
marginBottom: isWideLayout ? spacing.lg : spacing.xl,
[mq.tablet]: isWideLayout
? {
gap: spacing.sm,
marginBottom: spacing.sm,
}
: {},
[mq.mobile]: {
gap: spacing.sm,
marginBottom: isChatLayout ? spacing.md : spacing.lg,
marginBottom: isWideLayout ? spacing.md : spacing.lg,
},
}}
>
Expand Down
37 changes: 36 additions & 1 deletion packages/worker/client/mcp-apps/generated-ui-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,7 @@ ${inlineModuleSource}
const result = (await hostBridge.callTool({
name: 'ui_load_app_source',
arguments: { app_id: appId },
timeoutMs: 15_000,
})) as HostToolResult | null
if (!result || result.isError) {
throw new Error('Failed to load saved app source.')
Expand Down Expand Up @@ -801,17 +802,36 @@ ${inlineModuleSource}
typeof payload.requestId === 'string' ? payload.requestId : null
const code = typeof payload.code === 'string' ? payload.code : null
if (requestId && code && event.source) {
let didTimeout = false
const targetWindow = event.source as WindowProxy
const timeoutId = window.setTimeout(() => {
didTimeout = true
targetWindow.postMessage(
{
type: `${childMessagePrefix}execute-result`,
payload: {
requestId,
result: null,
errorMessage: 'Code execution timed out.',
},
},
'*',
)
}, 90_000)
void hostBridge
.callTool({
name: 'execute',
arguments: { code },
timeoutMs: 90_000,
})
.then((result) => {
if (didTimeout) return
window.clearTimeout(timeoutId)
const errorMessage = getHostToolErrorMessage(result)
const structuredContent = isRecord(result?.structuredContent)
? result.structuredContent
: null
;(event.source as WindowProxy).postMessage(
targetWindow.postMessage(
{
type: `${childMessagePrefix}execute-result`,
payload: {
Expand All @@ -826,6 +846,21 @@ ${inlineModuleSource}
'*',
)
})
.catch(() => {
if (didTimeout) return
window.clearTimeout(timeoutId)
targetWindow.postMessage(
{
type: `${childMessagePrefix}execute-result`,
payload: {
requestId,
result: null,
errorMessage: 'Code execution failed.',
},
},
'*',
)
})
}
return
}
Expand Down
7 changes: 5 additions & 2 deletions packages/worker/client/mcp-apps/widget-host-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type WidgetHostBridge = {
callTool(input: {
name: string
arguments?: Record<string, unknown>
timeoutMs?: number
}): Promise<ServerToolResult | null>
openLink(url: string): Promise<boolean>
requestDisplayMode(mode: DisplayMode): Promise<DisplayMode | null>
Expand Down Expand Up @@ -205,14 +206,15 @@ export function createWidgetHostBridge(
function sendBridgeRequest(
method: string,
params: Record<string, unknown>,
timeoutMs = requestTimeoutMs,
): Promise<BridgeResponseMessage> {
return new Promise<BridgeResponseMessage>((resolve, reject) => {
requestCounter += 1
const requestId = toRequestId(`${appInfo.name}-bridge-${requestCounter}`)
const timeoutId = globalThis.setTimeout(() => {
pendingRequests.delete(requestId)
reject(new Error('Bridge request timed out'))
}, requestTimeoutMs)
}, timeoutMs)

pendingRequests.set(requestId, {
resolve,
Expand Down Expand Up @@ -305,6 +307,7 @@ export function createWidgetHostBridge(
async function callTool(input: {
name: string
arguments?: Record<string, unknown>
timeoutMs?: number
}) {
const bridgeReady = await initialize()
if (!bridgeReady) return null
Expand All @@ -313,7 +316,7 @@ export function createWidgetHostBridge(
const response = await sendBridgeRequest('tools/call', {
name: input.name,
...(input.arguments ? { arguments: input.arguments } : {}),
})
}, input.timeoutMs)
const result = response.result
if (!result) return null
return {
Expand Down
2 changes: 2 additions & 0 deletions packages/worker/client/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import { LoginRoute } from './login.tsx'
import { OAuthAuthorizeRoute } from './oauth-authorize.tsx'
import { OAuthCallbackRoute } from './oauth-callback.tsx'
import { ResetPasswordRoute } from './reset-password.tsx'
import { SavedUiRoute } from './saved-ui.tsx'

export const clientRoutes = {
'/': <HomeRoute />,
'/chat': <ChatRoute />,
'/chat/:threadId': <ChatRoute />,
'/ui/:id': <SavedUiRoute />,
'/account': <AccountRoute />,
'/login': <LoginRoute />,
'/signup': <LoginRoute />,
Expand Down
Loading
Loading