Skip to content

Commit 44f73f2

Browse files
committed
test: refactor view_journal tests to validate ui-lifecycle and ui-size-change messages
1 parent 2f1d796 commit 44f73f2

File tree

6 files changed

+360
-85
lines changed

6 files changed

+360
-85
lines changed

exercises/03.complex/02.problem.ready/test/index.test.ts

Lines changed: 54 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { invariant } from '@epic-web/invariant'
12
import { Client } from '@modelcontextprotocol/sdk/client'
23
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
34
import { chromium } from 'playwright'
@@ -41,58 +42,78 @@ async function setupClient() {
4142
}
4243
}
4344

44-
test('view_journal sends iframe response', async () => {
45+
test('journal viewer sends ui-lifecycle-iframe-ready message', async () => {
4546
await using setup = await setupClient()
4647
const { client } = setup
4748

4849
const result = await client.callTool({ name: 'view_journal' }).catch((e) => {
4950
throw new Error('🚨 view_journal tool call failed', { cause: e })
5051
})
5152

52-
const content = z.array(z.object({}).passthrough()).parse(result.content)
53+
invariant(Array.isArray(result.content), '🚨 content is not an array')
5354

54-
expect(
55-
content,
56-
'🚨 content returned from view_journal tool does not match the expected format',
57-
).toEqual([
58-
{
59-
type: 'resource',
60-
resource: {
61-
uri: expect.stringMatching(/^ui:\/\/view-journal\/\d+$/),
62-
mimeType: 'text/uri-list',
63-
text: `http://localhost:${mcpServerPort}/ui/journal-viewer`,
64-
},
65-
},
66-
])
6755
const { resource } = z
6856
.object({ resource: z.object({ text: z.string() }) })
69-
.parse(content[0])
57+
.parse(result.content[0])
7058

7159
const urlString = resource.text
7260

7361
await using browserSetup = await setupBrowser()
7462
const { page } = browserSetup
63+
64+
await page.addInitScript(() => {
65+
// one per document; created before app code runs
66+
window.__uiReadyDeferred = new Promise((resolve) => {
67+
window.__resolveUiReady = resolve
68+
})
69+
70+
window.addEventListener('message', (event: MessageEvent) => {
71+
if (event?.data?.type === 'ui-lifecycle-iframe-ready') {
72+
window.__resolveUiReady?.(event.data)
73+
}
74+
})
75+
})
76+
7577
await page.goto(urlString)
7678

77-
const readyMessage = Promise.race([
79+
const message = await Promise.race([
7880
page.evaluate(() => {
79-
return new Promise<{ type: string }>((resolve) => {
80-
// @ts-expect-error - window is defined in this context
81-
window.addEventListener(
82-
'message',
83-
(event: MessageEvent) => {
84-
if (event.data?.type === 'ui-lifecycle-iframe-ready') {
85-
resolve(event.data)
86-
}
87-
},
88-
{ once: true },
89-
)
90-
})
91-
}),
92-
new Promise((r, reject) => {
93-
setTimeout(() => reject('🚨 iframe ready message not received'), 500)
81+
return window.__uiReadyDeferred
9482
}),
83+
new Promise((r, reject) =>
84+
setTimeout(() => reject('🚨 timed out waiting for message'), 3000),
85+
),
9586
])
9687

97-
expect(await readyMessage).toEqual({ type: 'ui-lifecycle-iframe-ready' })
88+
invariant(
89+
typeof message === 'object' && message !== null,
90+
'🚨 message not received',
91+
)
92+
93+
const parsedMessage = z
94+
.object({
95+
type: z.string(),
96+
})
97+
.parse(message)
98+
99+
expect(
100+
parsedMessage.type,
101+
'🚨 message type is not ui-lifecycle-iframe-ready',
102+
).toBe('ui-lifecycle-iframe-ready')
98103
})
104+
105+
declare global {
106+
interface Window {
107+
__uiReadyDeferred?: Promise<{
108+
type: string
109+
}>
110+
__resolveUiReady?: (data: { type: string }) => void
111+
addEventListener(
112+
type: string,
113+
listener: (event: MessageEvent) => void,
114+
options?: { once?: boolean },
115+
): void
116+
}
117+
118+
var window: Window & typeof globalThis
119+
}
Lines changed: 54 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { invariant } from '@epic-web/invariant'
12
import { Client } from '@modelcontextprotocol/sdk/client'
23
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
34
import { chromium } from 'playwright'
@@ -41,61 +42,78 @@ async function setupClient() {
4142
}
4243
}
4344

44-
test('view_journal sends iframe response', async () => {
45+
test('journal viewer sends ui-lifecycle-iframe-ready message', async () => {
4546
await using setup = await setupClient()
4647
const { client } = setup
4748

4849
const result = await client.callTool({ name: 'view_journal' }).catch((e) => {
4950
throw new Error('🚨 view_journal tool call failed', { cause: e })
5051
})
5152

52-
const content = z.array(z.object({}).passthrough()).parse(result.content)
53+
invariant(Array.isArray(result.content), '🚨 content is not an array')
5354

54-
expect(
55-
content,
56-
'🚨 content returned from view_journal tool does not match the expected format',
57-
).toEqual([
58-
{
59-
type: 'resource',
60-
resource: {
61-
uri: expect.stringMatching(/^ui:\/\/view-journal\/\d+$/),
62-
mimeType: 'text/uri-list',
63-
text: `http://localhost:${mcpServerPort}/ui/journal-viewer`,
64-
},
65-
},
66-
])
6755
const { resource } = z
6856
.object({ resource: z.object({ text: z.string() }) })
69-
.parse(content[0])
57+
.parse(result.content[0])
7058

7159
const urlString = resource.text
7260

7361
await using browserSetup = await setupBrowser()
7462
const { page } = browserSetup
63+
64+
await page.addInitScript(() => {
65+
// one per document; created before app code runs
66+
window.__uiReadyDeferred = new Promise((resolve) => {
67+
window.__resolveUiReady = resolve
68+
})
69+
70+
window.addEventListener('message', (event: MessageEvent) => {
71+
if (event?.data?.type === 'ui-lifecycle-iframe-ready') {
72+
window.__resolveUiReady?.(event.data)
73+
}
74+
})
75+
})
76+
7577
await page.goto(urlString)
7678

77-
const readyMessage = Promise.race([
79+
const message = await Promise.race([
7880
page.evaluate(() => {
79-
return new Promise<{ type: string }>((resolve) => {
80-
// @ts-expect-error - window is defined in this context
81-
window.addEventListener(
82-
'message',
83-
(event: MessageEvent) => {
84-
if (event.data?.type === 'ui-lifecycle-iframe-ready') {
85-
resolve(event.data)
86-
}
87-
},
88-
{ once: true },
89-
)
90-
})
91-
}),
92-
new Promise((r, reject) => {
93-
setTimeout(
94-
() => reject('🚨 timed out waiting for iframe ready message'),
95-
3_000,
96-
)
81+
return window.__uiReadyDeferred
9782
}),
83+
new Promise((r, reject) =>
84+
setTimeout(() => reject('🚨 timed out waiting for message'), 3000),
85+
),
9886
])
9987

100-
expect(await readyMessage).toEqual({ type: 'ui-lifecycle-iframe-ready' })
88+
invariant(
89+
typeof message === 'object' && message !== null,
90+
'🚨 message not received',
91+
)
92+
93+
const parsedMessage = z
94+
.object({
95+
type: z.string(),
96+
})
97+
.parse(message)
98+
99+
expect(
100+
parsedMessage.type,
101+
'🚨 message type is not ui-lifecycle-iframe-ready',
102+
).toBe('ui-lifecycle-iframe-ready')
101103
})
104+
105+
declare global {
106+
interface Window {
107+
__uiReadyDeferred?: Promise<{
108+
type: string
109+
}>
110+
__resolveUiReady?: (data: { type: string }) => void
111+
addEventListener(
112+
type: string,
113+
listener: (event: MessageEvent) => void,
114+
options?: { once?: boolean },
115+
): void
116+
}
117+
118+
var window: Window & typeof globalThis
119+
}

exercises/03.complex/03.problem.sizing/test/index.test.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
1+
import { invariant } from '@epic-web/invariant'
2+
import { Client } from '@modelcontextprotocol/sdk/client'
23
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
34
import { test, expect, inject } from 'vitest'
5+
import { z } from 'zod'
46

57
const mcpServerPort = inject('mcpServerPort')
68

@@ -27,10 +29,31 @@ async function setupClient() {
2729
}
2830
}
2931

30-
test('listing tools works', async () => {
32+
test('view_journal includes uiMetadata', async () => {
3133
await using setup = await setupClient()
3234
const { client } = setup
3335

34-
const result = await client.listTools()
35-
expect(result.tools.length).toBeGreaterThan(0)
36+
const result = await client.callTool({ name: 'view_journal' }).catch((e) => {
37+
throw new Error('🚨 view_journal tool call failed', { cause: e })
38+
})
39+
40+
invariant(Array.isArray(result.content), '🚨 content is not an array')
41+
42+
const content = z
43+
.object({
44+
resource: z.object({
45+
_meta: z.any().optional(),
46+
}),
47+
})
48+
.parse(result.content[0])
49+
50+
expect(
51+
content.resource._meta,
52+
`🚨 _meta is not present or is not the correct format, make sure to set uiMetadata and include a 'preferred-frame-size' property`,
53+
).toEqual({
54+
'mcpui.dev/ui-preferred-frame-size': [
55+
expect.stringMatching(/^\d+px$/),
56+
expect.stringMatching(/^\d+px$/),
57+
],
58+
})
3659
})

exercises/03.complex/03.solution.sizing/test/index.test.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
1+
import { invariant } from '@epic-web/invariant'
2+
import { Client } from '@modelcontextprotocol/sdk/client'
23
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
34
import { test, expect, inject } from 'vitest'
5+
import { z } from 'zod'
46

57
const mcpServerPort = inject('mcpServerPort')
68

@@ -27,10 +29,31 @@ async function setupClient() {
2729
}
2830
}
2931

30-
test('listing tools works', async () => {
32+
test('view_journal includes uiMetadata', async () => {
3133
await using setup = await setupClient()
3234
const { client } = setup
3335

34-
const result = await client.listTools()
35-
expect(result.tools.length).toBeGreaterThan(0)
36+
const result = await client.callTool({ name: 'view_journal' }).catch((e) => {
37+
throw new Error('🚨 view_journal tool call failed', { cause: e })
38+
})
39+
40+
invariant(Array.isArray(result.content), '🚨 content is not an array')
41+
42+
const content = z
43+
.object({
44+
resource: z.object({
45+
_meta: z.any().optional(),
46+
}),
47+
})
48+
.parse(result.content[0])
49+
50+
expect(
51+
content.resource._meta,
52+
`🚨 _meta is not present or is not the correct format, make sure to set uiMetadata and include a 'preferred-frame-size' property`,
53+
).toEqual({
54+
'mcpui.dev/ui-preferred-frame-size': [
55+
expect.stringMatching(/^\d+px$/),
56+
expect.stringMatching(/^\d+px$/),
57+
],
58+
})
3659
})

0 commit comments

Comments
 (0)