Skip to content

Commit c795da3

Browse files
committed
test(website): cover live production approval flows
1 parent 9a45849 commit c795da3

1 file changed

Lines changed: 141 additions & 3 deletions

File tree

apps/website/e2e/platform-production-smoke.spec.ts

Lines changed: 141 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, test } from '@playwright/test';
1+
import { expect, test, type Page, type Response } from '@playwright/test';
22
import { readFileSync } from 'node:fs';
33
import { join } from 'node:path';
44
import { validateRuntimeParentOrigins } from '@threadplane/cockpit-runtime-bridge';
@@ -13,7 +13,10 @@ import {
1313
*
1414
* Requires:
1515
* EXAMPLES_URL - e.g. https://examples.threadplane.ai
16-
* OPENAI_API_KEY - optional; enables the single live-provider canary
16+
* OPENAI_API_KEY - optional; enables the canonical LangGraph telemetry canary
17+
*
18+
* AG-UI canaries always exercise the deployed services' own provider keys.
19+
* They must not skip when the test runner has no local provider key.
1720
*
1821
* Run:
1922
* PRODUCTION_SMOKE=true \
@@ -25,6 +28,8 @@ const EXAMPLES_URL =
2528
process.env['EXAMPLES_URL'] ?? 'https://examples.threadplane.ai';
2629
const DEMO_URL = process.env['DEMO_URL'] ?? 'https://demo.threadplane.ai';
2730
const WEBSITE_URL = process.env['WEBSITE_URL'] ?? 'https://threadplane.ai';
31+
const AG_UI_DEMO_URL =
32+
process.env['AG_UI_DEMO_URL'] ?? 'https://ag-ui.threadplane.ai';
2833
// Playwright transpiles specs to CJS, so `import.meta.url` here compiles to a
2934
// `require` the ESM-loaded output cannot resolve and the whole file fails to
3035
// load. `__dirname` is what the emitted module actually has. Don't "modernise"
@@ -393,8 +398,141 @@ test.describe('examples langgraph proxy hardening', () => {
393398
});
394399
});
395400

401+
// A provider authentication error can close an HTTP-200 stream before an
402+
// interrupt or reply arrives. Require protocol completion, not just transport
403+
// success, so this fails promptly with a useful diagnostic in that case.
404+
async function completedAgentEvents(response: Response) {
405+
expect(response.status(), response.url()).toBe(200);
406+
const events = (await response.text())
407+
.split(/\r?\n/)
408+
.filter((line) => line.startsWith('data:'))
409+
.map((line) => JSON.parse(line.slice(5).trim()) as Record<string, unknown>);
410+
const types = events.map((event) => event['type']);
411+
expect(types, 'Agent returned RUN_ERROR').not.toContain('RUN_ERROR');
412+
expect(
413+
types,
414+
'Agent stream ended without RUN_FINISHED; check deployed provider credentials and runtime logs'
415+
).toContain('RUN_FINISHED');
416+
return events;
417+
}
418+
419+
function nextAgentResponse(page: Page) {
420+
return page.waitForResponse(
421+
(response) =>
422+
response.request().method() === 'POST' &&
423+
new URL(response.url()).pathname.endsWith('/agent'),
424+
{ timeout: 60_000 }
425+
);
426+
}
427+
428+
test.describe('Production: live AG-UI provider canaries', () => {
429+
// These are synthetic demo operations. Run sequentially and bound retries
430+
// separately from dev-server startup retries to limit live provider usage.
431+
test.describe.configure({ mode: 'default', timeout: 120_000, retries: 1 });
432+
433+
for (const runtime of ['refund', 'mastra'] as const) {
434+
for (const approved of [true, false]) {
435+
const action = approved ? 'Approve' : 'Cancel';
436+
test(`${runtime}: ${action} completes the live approval flow`, async ({
437+
page,
438+
}) => {
439+
const mastra = runtime === 'mastra';
440+
await page.goto(
441+
`${EXAMPLES_URL}/${mastra ? 'runtimes/mastra' : 'ag-ui/interrupts'}/`
442+
);
443+
const initialResponse = nextAgentResponse(page);
444+
await page
445+
.getByText(
446+
mastra ? 'Reserve the campsite' : 'Refund a duplicate charge',
447+
{ exact: true }
448+
)
449+
.click();
450+
const initialEvents = await completedAgentEvents(await initialResponse);
451+
expect(initialEvents).toEqual(
452+
expect.arrayContaining([
453+
expect.objectContaining({ type: 'CUSTOM', name: 'on_interrupt' }),
454+
])
455+
);
456+
const dialog = page.locator('dialog.chat-approval-card');
457+
await expect(dialog).toBeVisible();
458+
await expect(dialog).toContainText(
459+
mastra ? 'Reservation approval required' : 'Refund approval required'
460+
);
461+
await expect(dialog).toContainText(
462+
mastra ? 'North Pines' : 'cus_a8x2k'
463+
);
464+
await expect(dialog).toContainText(mastra ? '$90.00' : '$47.50');
465+
466+
const resumedResponse = nextAgentResponse(page);
467+
await dialog.getByRole('button', { name: action, exact: true }).click();
468+
const response = await resumedResponse;
469+
const command = response.request().postDataJSON()
470+
.forwardedProps.command;
471+
expect(command.resume.approved).toBe(approved);
472+
const events = await completedAgentEvents(response);
473+
await expect(dialog).not.toBeVisible();
474+
const reply = page
475+
.locator('chat-message[data-role="assistant"]')
476+
.last();
477+
478+
if (mastra) {
479+
expect(command.interruptEvent.toolCallId).toEqual(expect.any(String));
480+
expect(command.interruptEvent.runId).toEqual(expect.any(String));
481+
expect(events).toEqual(
482+
expect.arrayContaining([
483+
expect.objectContaining({
484+
type: 'TOOL_CALL_RESULT',
485+
toolCallId: command.interruptEvent.toolCallId,
486+
content: expect.stringContaining(
487+
approved ? 'Reserved North Pines' : 'Nothing was booked.'
488+
),
489+
}),
490+
])
491+
);
492+
const cancellation =
493+
/declined|cancelled|canceled|not.{0,20}(booked|completed|confirmed|reserved)|nothing.{0,20}booked/i;
494+
await expect(reply).toContainText(
495+
approved ? /reserved|confirmed|booked/i : cancellation
496+
);
497+
if (approved) await expect(reply).not.toContainText(cancellation);
498+
else await expect(reply).not.toContainText('TP-0288');
499+
} else {
500+
await expect(reply).toContainText(
501+
approved
502+
? 'Refund of $47.50 issued to'
503+
: 'Refund cancelled by operator. No charge issued.'
504+
);
505+
if (approved) await expect(reply).toContainText('cus_a8x2k');
506+
else await expect(reply).not.toContainText('Refund ID:');
507+
}
508+
});
509+
}
510+
}
511+
512+
test('AG-UI demo completes a live reply', async ({ page }) => {
513+
await page.goto(`${AG_UI_DEMO_URL}/embed`);
514+
await page
515+
.locator('textarea[name="messageText"]')
516+
.fill('Say hello in one sentence.');
517+
const response = nextAgentResponse(page);
518+
await page.getByRole('button', { name: /send message/i }).click();
519+
const events = await completedAgentEvents(await response);
520+
expect(events).toEqual(
521+
expect.arrayContaining([
522+
expect.objectContaining({
523+
type: 'TEXT_MESSAGE_CONTENT',
524+
delta: expect.stringMatching(/\S/),
525+
}),
526+
])
527+
);
528+
await expect(
529+
page.locator('chat-message[data-role="assistant"]').last()
530+
).toContainText(/\S/);
531+
});
532+
});
533+
396534
test.describe('AG-UI demo (ag-ui.threadplane.ai)', () => {
397-
const DEMO = process.env['AG_UI_DEMO_URL'] ?? 'https://ag-ui.threadplane.ai';
535+
const DEMO = AG_UI_DEMO_URL;
398536

399537
test('demo SPA is reachable', async ({ page }) => {
400538
const res = await page.goto(`${DEMO}/`);

0 commit comments

Comments
 (0)