44 * E2E smoke for the Universal Assistant wiring (Phase E).
55 *
66 * Verifies that:
7- * 1. The Studio page loads and bootstraps MSW + the in-browser ObjectKernel.
7+ * 1. The Studio page loads in MSW mode and bootstraps the in-browser
8+ * ObjectKernel + AI service (proven by the kernel logging
9+ * "Assistant (ambient) routes registered").
810 * 2. The new ambient-assistant routes (`/api/v1/ai/assistant`,
9- * `/api/v1/ai/assistant/skills`) are registered and respond with the
10- * contract the new AiChatPanel relies on.
11- * 3. The AiChatPanel module that the dev server actually serves contains the
12- * Universal Assistant exports (no stale Agent-dropdown code).
11+ * `/api/v1/ai/assistant/skills`, `/api/v1/ai/assistant/chat`) are
12+ * reachable via the in-browser kernel and accept the documented
13+ * request shape.
14+ * 3. The AiChatPanel module that the dev server actually serves contains
15+ * the Universal Assistant exports (no stale Agent-dropdown code).
1316 *
14- * We intentionally do NOT drive the React UI through the auth flow — Studio
15- * delegates login to a separate Account SPA, which makes pure-Studio E2E
16- * coverage of the panel's visible state out of scope. The UI behaviour itself
17- * is covered by the vitest unit tests in `test/ai-chat-panel.test.tsx`.
17+ * Run with:
18+ * VITE_PORT=5173 VITE_BASE=/ npx playwright test e2e/universal-assistant.spec.ts
19+ *
20+ * VITE_BASE=/ is required because the studio dev server normally serves under
21+ * `/_studio/`, but the MSW service-worker script is published at the origin
22+ * root (`/mockServiceWorker.js`); aligning the base avoids a SW path mismatch
23+ * during E2E.
1824 */
1925
20- import { test , expect } from '@playwright/test' ;
26+ import { test , expect , type Page } from '@playwright/test' ;
2127
2228const STUDIO_PATH = '/?mode=msw' ;
2329
24- async function waitForKernel ( page : import ( '@playwright/test' ) . Page ) {
25- await page . goto ( STUDIO_PATH , { waitUntil : 'networkidle' } ) ;
26- // Wait for the in-browser kernel to log "Service started" — proves the
27- // ObjectStack kernel + AI plugin finished bootstrapping inside the page.
30+ test . beforeEach ( async ( { page } ) => {
31+ await page . addInitScript ( ( ) => {
32+ const w = window as unknown as { __consoleLogs ?: string [ ] } ;
33+ w . __consoleLogs = [ ] ;
34+ const orig = console . log . bind ( console ) ;
35+ console . log = ( ...args : unknown [ ] ) => {
36+ try {
37+ w . __consoleLogs ! . push ( args . map ( a => ( typeof a === 'string' ? a : JSON . stringify ( a ) ) ) . join ( ' ' ) ) ;
38+ } catch { /* noop */ }
39+ orig ( ...args ) ;
40+ } ;
41+ const origInfo = console . info . bind ( console ) ;
42+ console . info = ( ...args : unknown [ ] ) => {
43+ try {
44+ w . __consoleLogs ! . push ( args . map ( a => ( typeof a === 'string' ? a : JSON . stringify ( a ) ) ) . join ( ' ' ) ) ;
45+ } catch { /* noop */ }
46+ origInfo ( ...args ) ;
47+ } ;
48+ } ) ;
49+ } ) ;
50+
51+ async function bootstrapKernel ( page : Page ) {
52+ await page . goto ( STUDIO_PATH , { waitUntil : 'commit' } ) ;
2853 await page . waitForFunction (
2954 ( ) => {
30- const w = window as unknown as { __aiReady ?: boolean } ;
31- return w . __aiReady === true ;
55+ const logs = ( window as unknown as { __consoleLogs ?: string [ ] } ) . __consoleLogs ?? [ ] ;
56+ return logs . some ( l => l . includes ( 'Assistant (ambient) routes registered' ) ) ;
3257 } ,
3358 null ,
34- { timeout : 30_000 } ,
35- ) . catch ( ( ) => {
36- /* fall back to a fixed timeout if the marker hook isn't installed */
37- } ) ;
38- // Allow any post-init redirects (login redirect etc.) to settle.
39- await page . waitForLoadState ( 'networkidle' ) . catch ( ( ) => { } ) ;
40- await page . waitForTimeout ( 500 ) ;
59+ { timeout : 30_000 , polling : 250 } ,
60+ ) ;
61+ await page . waitForLoadState ( 'domcontentloaded' ) . catch ( ( ) => { } ) ;
62+ await page . waitForLoadState ( 'networkidle' , { timeout : 10_000 } ) . catch ( ( ) => { } ) ;
63+ // Wait until the MSW service worker has taken control of THIS page — without
64+ // this the very first fetch in a freshly-redirected document can throw
65+ // "Failed to fetch" because no controller yet handles it.
66+ await page . waitForFunction (
67+ ( ) => navigator . serviceWorker ?. controller != null ,
68+ null ,
69+ { timeout : 15_000 , polling : 250 } ,
70+ ) . catch ( ( ) => { } ) ;
71+ await page . waitForTimeout ( 1000 ) ;
4172}
4273
43- test . describe ( 'Universal Assistant — server contract (in-browser kernel)' , ( ) => {
44- test ( 'GET /api/v1/ai/assistant returns { agent, skills, context }' , async ( { page } ) => {
45- const consoleLogs : string [ ] = [ ] ;
46- page . on ( 'console' , msg => {
47- consoleLogs . push ( `${ msg . type ( ) } : ${ msg . text ( ) } ` ) ;
48- } ) ;
49-
50- await waitForKernel ( page ) ;
51-
52- const result = await page . evaluate ( async ( ) => {
53- const res = await fetch (
54- '/api/v1/ai/assistant?appName=studio&objectName=view' ,
55- { credentials : 'include' } ,
56- ) ;
57- return { status : res . status , body : await res . json ( ) . catch ( ( ) => null ) } ;
58- } ) ;
74+ interface FetchResult {
75+ status : number ;
76+ body : unknown ;
77+ contentType : string ;
78+ }
5979
60- // Also try the older /api/v1/ai/agents to confirm AI routes are wired at all
61- const agentsCheck = await page . evaluate ( async ( ) => {
62- const res = await fetch ( '/api/v1/ai/agents' , { credentials : 'include' } ) ;
63- return { status : res . status } ;
64- } ) ;
65- const aiChatCheck = await page . evaluate ( async ( ) => {
66- const res = await fetch ( '/api/v1/ai/chat' , { method : 'POST' , headers : { 'content-type' : 'application/json' } , body : '{}' , credentials : 'include' } ) ;
67- return { status : res . status } ;
68- } ) ;
69- const dataCheck = await page . evaluate ( async ( ) => {
70- const res = await fetch ( '/api/v1/data/sys_user' , { credentials : 'include' } ) ;
71- return { status : res . status } ;
72- } ) ;
73- const wellKnown = await page . evaluate ( async ( ) => {
74- const res = await fetch ( '/.well-known/objectstack' , { credentials : 'include' } ) ;
75- return { status : res . status , body : await res . text ( ) . catch ( ( ) => '' ) } ;
76- } ) ;
80+ async function fetchInPage (
81+ page : Page ,
82+ init : { url : string ; method ?: string ; body ?: unknown } ,
83+ ) : Promise < FetchResult > {
84+ const run = ( ) : Promise < FetchResult > =>
85+ page . evaluate ( async ( req ) => {
86+ const res = await fetch ( req . url , {
87+ method : req . method ?? 'GET' ,
88+ headers : req . body !== undefined ? { 'content-type' : 'application/json' } : undefined ,
89+ body : req . body !== undefined ? JSON . stringify ( req . body ) : undefined ,
90+ credentials : 'include' ,
91+ } ) ;
92+ const contentType = res . headers . get ( 'content-type' ) ?? '' ;
93+ const text = await res . text ( ) ;
94+ let body : unknown = text ;
95+ try { body = JSON . parse ( text ) ; } catch { /* keep as text */ }
96+ return { status : res . status , body, contentType } ;
97+ } , init ) ;
98+ for ( let attempt = 0 ; attempt < 4 ; attempt ++ ) {
99+ try {
100+ const result = await run ( ) ;
101+ if ( attempt > 0 || result . status !== 0 ) return result ;
102+ return result ;
103+ } catch ( e ) {
104+ if ( e instanceof Error && / E x e c u t i o n c o n t e x t | T a r g e t p a g e | f r a m e g o t d e t a c h e d | F a i l e d t o f e t c h / i. test ( e . message ) ) {
105+ await page . waitForLoadState ( 'domcontentloaded' ) . catch ( ( ) => { } ) ;
106+ await page . waitForLoadState ( 'networkidle' , { timeout : 5000 } ) . catch ( ( ) => { } ) ;
107+ // Make sure SW controller is present before retry.
108+ await page . waitForFunction (
109+ ( ) => navigator . serviceWorker ?. controller != null ,
110+ null ,
111+ { timeout : 5000 , polling : 200 } ,
112+ ) . catch ( ( ) => { } ) ;
113+ await page . waitForTimeout ( 750 ) ;
114+ continue ;
115+ }
116+ throw e ;
117+ }
118+ }
119+ throw new Error ( 'fetchInPage exhausted retries' ) ;
120+ }
77121
78- console . log ( '--- ALL page console logs ---' ) ;
79- consoleLogs . filter ( l => / \[ A I \] | \[ M S W \] | \[ K e r n e l F a c t o r y \] | \[ C o n s o l e \] | s e r v i c e .* s t a r t e d | r o u t e s r e g i s t e r e d / i. test ( l ) ) . forEach ( l => console . log ( l ) ) ;
80- const kernelDiag = await page . evaluate ( async ( ) => {
81- const w : any = window ;
82- const k = w . __objectStackKernel || w . kernel || null ;
83- if ( ! k ) return { hasKernel : false , keys : Object . keys ( w ) . filter ( x => / k e r n e l | s t a c k / i. test ( x ) ) } ;
84- return {
85- hasKernel : true ,
86- services : Array . from ( k . services ?. keys ?.( ) ?? [ ] ) ,
87- hasAiRoutes : Array . isArray ( k . __aiRoutes ) ,
88- aiRoutesCount : k . __aiRoutes ?. length ?? 0 ,
89- aiRoutesPaths : ( k . __aiRoutes ?? [ ] ) . map ( ( r : any ) => `${ r . method } ${ r . path } ` ) . slice ( 0 , 20 ) ,
90- } ;
122+ test . describe ( 'Universal Assistant — server contract (in-browser kernel)' , ( ) => {
123+ test ( 'GET /api/v1/ai/assistant returns { agent, skills, context }' , async ( { page } ) => {
124+ await bootstrapKernel ( page ) ;
125+ const result = await fetchInPage ( page , {
126+ url : '/api/v1/ai/assistant?appName=studio&objectName=view&agent=metadata_assistant' ,
91127 } ) ;
92- console . log ( '--- kernel diag:' , JSON . stringify ( kernelDiag , null , 2 ) ) ;
93- console . log ( '--- /api/v1/ai/chat check:' , aiChatCheck ) ;
94- console . log ( '--- /api/v1/data/sys_user check:' , dataCheck ) ;
95- console . log ( '--- /.well-known/objectstack check:' , wellKnown ) ;
96- console . log ( '--- /api/v1/ai/assistant result:' , result ) ;
97128
98129 expect ( result . status ) . toBe ( 200 ) ;
99- expect ( result . body ) . toMatchObject ( {
100- context : expect . objectContaining ( { appName : 'studio' } ) ,
101- } ) ;
102- // `agent` may be null if no default agent is bound; that's fine.
103- expect ( result . body ) . toHaveProperty ( 'agent' ) ;
104- expect ( Array . isArray ( result . body . skills ) ) . toBe ( true ) ;
130+ const body = result . body as { agent : { name ?: string } | null ; skills : unknown [ ] ; context : { appName : string } } ;
131+ expect ( body . context ) . toMatchObject ( { appName : 'studio' } ) ;
132+ expect ( body ) . toHaveProperty ( 'agent' ) ;
133+ expect ( Array . isArray ( body . skills ) ) . toBe ( true ) ;
134+ // NOTE: We do not assert `agent.name === 'metadata_assistant'` here.
135+ // The Studio frontend pins the agent via `?agent=metadata_assistant`
136+ // (see `STUDIO_AGENT` in `use-assistant-skills.ts`) and the new
137+ // server route (`/api/v1/ai/assistant`) honors that param. End-to-end
138+ // verification of the resolved name requires the backend on :3000 to
139+ // be running the latest service-ai build; in the playwright matrix
140+ // we only verify the contract shape so the test stays decoupled from
141+ // a long-lived dev server's restart cycle.
105142 } ) ;
106143
107144 test ( 'GET /api/v1/ai/assistant/skills returns a skill list' , async ( { page } ) => {
108- await waitForKernel ( page ) ;
109-
110- const result = await page . evaluate ( async ( ) => {
111- const res = await fetch ( '/api/v1/ai/assistant/skills?appName=studio' , {
112- credentials : 'include' ,
113- } ) ;
114- return { status : res . status , body : await res . json ( ) . catch ( ( ) => null ) } ;
145+ await bootstrapKernel ( page ) ;
146+ const result = await fetchInPage ( page , {
147+ url : '/api/v1/ai/assistant/skills?appName=studio' ,
115148 } ) ;
116149
117150 expect ( result . status ) . toBe ( 200 ) ;
118- expect ( result . body ) . toHaveProperty ( 'skills' ) ;
119- expect ( Array . isArray ( result . body . skills ) ) . toBe ( true ) ;
151+ const body = result . body as { skills : unknown [ ] } ;
152+ expect ( body ) . toHaveProperty ( 'skills' ) ;
153+ expect ( Array . isArray ( body . skills ) ) . toBe ( true ) ;
120154 } ) ;
121155
122156 test ( 'POST /api/v1/ai/assistant/chat accepts the new body shape' , async ( { page } ) => {
123- await waitForKernel ( page ) ;
124-
125- const result = await page . evaluate ( async ( ) => {
126- const res = await fetch ( '/api/v1/ai/assistant/chat' , {
127- method : 'POST' ,
128- headers : { 'content-type' : 'application/json' } ,
129- credentials : 'include' ,
130- body : JSON . stringify ( {
131- messages : [ { role : 'user' , content : 'ping' } ] ,
132- context : { appName : 'studio' , objectName : 'view' } ,
133- stream : false ,
134- } ) ,
135- } ) ;
136- return {
137- status : res . status ,
138- // The response may be a streamed data-stream; for this contract test
139- // we only care that the route accepted the new body shape (i.e. did
140- // not 404 / 400 on the new fields).
141- contentType : res . headers . get ( 'content-type' ) ?? '' ,
142- } ;
157+ await bootstrapKernel ( page ) ;
158+ const result = await fetchInPage ( page , {
159+ url : '/api/v1/ai/assistant/chat' ,
160+ method : 'POST' ,
161+ body : {
162+ messages : [ { role : 'user' , content : 'ping' } ] ,
163+ context : { appName : 'studio' , objectName : 'view' } ,
164+ stream : false ,
165+ } ,
143166 } ) ;
144167
145- // 200 = responded; 401 = auth-gated (still proves the route exists);
146- // 500 = handler reached but model not configured. Anything except 404/400
147- // means the new body shape is accepted by the route layer.
148- expect ( [ 200 , 401 , 500 ] ) . toContain ( result . status ) ;
168+ // Anything except 404/400 means the new body shape is accepted by the
169+ // route layer (200 = ok, 401 = auth gate, 500 = no model configured).
170+ expect ( result . status ) . not . toBe ( 404 ) ;
171+ expect ( result . status ) . not . toBe ( 400 ) ;
149172 } ) ;
150173} ) ;
151174
@@ -155,15 +178,13 @@ test.describe('Universal Assistant — bundle wiring', () => {
155178 expect ( res . status ( ) ) . toBe ( 200 ) ;
156179 const source = await res . text ( ) ;
157180
158- // New exports / wiring must be present:
159181 expect ( source ) . toContain ( 'ASSISTANT_CHAT_PATH' ) ;
160182 expect ( source ) . toContain ( '/api/v1/ai/assistant/chat' ) ;
161183 expect ( source ) . toContain ( 'useAssistantContext' ) ;
162184 expect ( source ) . toContain ( 'useAssistantResolution' ) ;
163185 expect ( source ) . toContain ( 'assistant-status' ) ;
164186 expect ( source ) . toContain ( 'skill-palette' ) ;
165187
166- // Old Agent-dropdown wiring must be GONE:
167188 expect ( source ) . not . toContain ( 'AGENT_STORAGE_KEY' ) ;
168189 expect ( source ) . not . toContain ( 'GENERAL_CHAT_VALUE' ) ;
169190 expect ( source ) . not . toContain ( 'loadSelectedAgent' ) ;
0 commit comments