2026-10-31 16:47:42 +03:00
import { mkdir , rm , writeFile } from "node:fs/promises" ;
2026-10-31 16:20:49 +03:00
import path from "node:path" ;
import { test as base , expect , type Route } from "@playwright/test" ;
import type { ModelCatalogEntry , ModelCatalogResponse } from "../src/core/contracts/models" ;
import type { UserSettings } from "../src/core/settings" ;
import type { ChatGPTStatus } from "../src/core/contracts/chatgpt" ;
import type { NativeSubscriptionStatus } from "../src/core/contracts/subscriptions" ;
import type { ProviderKeyInfo } from "../components/settings/keys-section" ;
/** Setup/teardown owns only the UUID run directory beneath this worktree. */
export default function setup() {
const root = process . env . LH_E2E_RUN_DIR ;
if ( ! root || path . dirname ( root ) !== path . resolve ( ".playwright-data" ) || ! /^run-[a-f0-9-]{36}$/ . test ( path . basename ( root ))) {
throw new Error ( "Unsafe/missing Playwright data directory; refusing to run or clean up." );
}
return async () => { await rm ( root , { recursive : true , force : true }); };
}
export const syntheticModel : ModelCatalogEntry = {
id : "synthetic-smoke-model" , name : "Synthetic smoke model" , provider : "openai" ,
reasoning : false , input : [ "text" , "image" ], contextWindow : 8192 , maxTokens : 1024 ,
cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 }, supportedLevels : [ "off" ],
};
const provider : ProviderKeyInfo = {
id : "openai" , envVar : "OPENAI_API_KEY" , label : "OpenAI" ,
};
export interface HarnessState {
keyPresent : boolean ;
settings : UserSettings | null ;
writes : { path : string ; body : unknown }[];
blocked : string [];
generation : string [];
catalogFailure : boolean ;
2026-10-31 16:47:42 +03:00
models? : ModelCatalogEntry [];
modelsWrite : { status : number ; wait? : Promise < void > };
transcription : { configured : boolean ; status : number ; body : { text? : string ; message? : string } };
seedGeneral : ( general : NonNullable < UserSettings [ "general" ] >) => Promise < void >;
2026-10-31 16:20:49 +03:00
}
const json = ( route : Route , body : unknown , status = 200 ) => route . fulfill ({ status , json : body });
export const test = base . extend < { harness : HarnessState } > ({
harness : async ({ context }, use ) => {
2026-10-31 16:47:42 +03:00
// Layout reads general settings on the server, before browser routes exist.
// Bridge ONLY synthetic general settings to the webServer's UUID-owned data.
setup (); // validate run directory before any filesystem access
const settingsFile = path . join ( process . env . LH_E2E_RUN_DIR ! , "data" , "settings.json" );
await mkdir ( path . dirname ( settingsFile ), { recursive : true });
2026-10-31 16:20:49 +03:00
const state : HarnessState = {
keyPresent : false , settings : null , writes : [], blocked : [], generation : [], catalogFailure : false ,
2026-10-31 16:47:42 +03:00
modelsWrite : { status : 200 },
transcription : { configured : true , status : 200 , body : { text : "Synthetic transcript" } },
seedGeneral : async ( general ) => {
state . settings = { ... state . settings , version : 1 , general };
await writeFile ( settingsFile , JSON . stringify ({ version : 1 , general }), "utf8" );
},
2026-10-31 16:20:49 +03:00
};
2026-10-31 16:47:42 +03:00
await state . seedGeneral ({});
2026-10-31 16:20:49 +03:00
await context . routeWebSocket ( "**/*" , ( socket ) => {
const url = new URL ( socket . url ());
if ( url . origin === "ws://127.0.0.1:3005" && url . pathname . startsWith ( "/_next/" )) {
socket . connectToServer (); // Next dev HMR only; no provider sockets.
return ;
}
state . blocked . push ( `WS ${ url . origin }${ url . pathname } ` );
socket . close ({ code : 1008 , reason : "Offline test boundary" });
});
// Installed on the CONTEXT before a page navigates, including popups.
// Never let a missed provider fixture fall through to a live endpoint.
await context . route ( "**/*" , async ( route ) => {
const request = route . request ();
const url = new URL ( request . url ());
const method = request . method ();
const p = url . pathname ;
const block = async () => {
state . blocked . push ( ` ${ method } ${ url . origin }${ p } ` ); // no query strings/payloads in diagnostics
await route . abort ( "blockedbyclient" );
};
if ( url . origin !== "http://127.0.0.1:3005" ) return block ();
if ( ! p . startsWith ( "/api/" )) {
if ( method === "GET" || method === "HEAD" ) return route . continue ();
return block ();
}
2026-10-31 16:47:42 +03:00
if (( /\/(turn|generate|transcribe)(\/|$)/ . test ( p ) && p !== "/api/stt/transcribe" ) || ( p . startsWith ( "/api/chats" ) && method !== "GET" )) {
2026-10-31 16:20:49 +03:00
state . generation . push ( ` ${ method } ${ p } ` );
return block ();
}
if ( p === "/api/keys" && method === "GET" ) return json ( route , {
keys : state.keyPresent ? [{ id : provider.envVar , info : null , present : true , source : "file" , masked : "synthetic…only (41 chars)" , shadowedByEnv : false }] : [],
providers : [ provider ],
});
if ( p === "/api/keys/OPENAI_API_KEY" && method === "PUT" ) {
const body : unknown = request . postDataJSON ();
state . writes . push ({ path : p , body });
if ( JSON . stringify ( body ) !== JSON . stringify ({ value : "synthetic-smoke-key-not-a-credential" })) return json ( route , { error : "Only the synthetic fixture key is accepted" }, 400 );
state . keyPresent = true ;
return json ( route , { status : { id : provider.envVar , info : null , present : true , source : "file" , masked : "synthetic…only (41 chars)" , shadowedByEnv : false } });
}
if ( p === "/api/models" && method === "GET" ) {
if ( state . catalogFailure ) return json ( route , { error : "Synthetic catalog unavailable" }, 503 );
const slots = {
strong : state.settings?.models?.strong?.model ? { ... state . settings . models . strong , model : state.settings.models.strong.model } : null ,
cheap : state.settings?.models?.cheap?.model ? { ... state . settings . models . cheap , model : state.settings.models.cheap.model } : null ,
vision : state.settings?.models?.vision?.model ? { ... state . settings . models . vision , model : state.settings.models.vision.model } : null ,
};
const catalog : ModelCatalogResponse = {
2026-10-31 16:47:42 +03:00
models : state.models ?? ( state . keyPresent ? [ syntheticModel ] : []), defaults : slots.strong , slots ,
2026-10-31 16:20:49 +03:00
rolePins : { researcher : slots.cheap , demoBuilder : slots.cheap , visualMaker : slots.vision , extractor : slots.cheap , auditor : slots.cheap },
providers : [{ id : "openai" , envVar : provider.envVar , keyPresent : state.keyPresent }],
};
return json ( route , catalog );
}
if ( p === "/api/settings" && method === "GET" ) return json ( route , { settings : state.settings });
if ( p === "/api/settings/models" && method === "PUT" ) {
const body = request . postDataJSON () as NonNullable < UserSettings [ "models" ] >;
state . writes . push ({ path : p , body });
2026-10-31 16:47:42 +03:00
await state . modelsWrite . wait ;
if ( state . modelsWrite . status !== 200 ) return json ( route , { error : "Synthetic settings rejection" }, state . modelsWrite . status );
2026-10-31 16:20:49 +03:00
state . settings = { ... state . settings , version : 1 , models : body };
return json ( route , { settings : state.settings });
}
2026-10-31 16:47:42 +03:00
if ( p === "/api/settings/general" && method === "PUT" ) {
const body = request . postDataJSON () as UserSettings [ "general" ];
state . writes . push ({ path : p , body });
await state . seedGeneral ( body ?? {});
return json ( route , { settings : state.settings });
}
if ( p === "/api/search" && method === "GET" ) return json ( route , { available : false , engine : null , reason : "No synthetic search key" });
if ( p === "/api/settings/prompts" && method === "GET" ) return json ( route , { prober : [], teacher : [] });
if ( p === "/api/stt/transcribe" ) {
if ( method === "GET" ) return json ( route , { configured : state.transcription.configured });
if ( method === "POST" ) return json ( route , state . transcription . body , state . transcription . status );
}
2026-10-31 16:20:49 +03:00
if ( p === "/api/subscriptions" && method === "GET" ) {
const chatgpt : ChatGPTStatus = { available : false , pending : false , allowBackgroundUse : false , activeId : null , accounts : [], error : null };
const native : NativeSubscriptionStatus [] = [ "github-copilot" , "xai" ]. map (( providerId ) => ({
providerId : providerId as NativeSubscriptionStatus [ "providerId" ], available : false , connected : false ,
pending : false , allowBackgroundUse : false , connectionId : null , epoch : 0 , authorityRevision : 0 , revision : 0 ,
}));
return json ( route , { chatgpt , native });
}
if ( p === "/api/custom-provider" && method === "GET" ) return json ( route , {
baseUrl : "" , authMode : "keyless" , models : [], overrides : {}, manualModels : [],
keyPresent : false , keySource : "none" , keyReuseRequired : false ,
});
if ( p === "/api/stt/token" && method === "GET" ) return json ( route , { message : "No DEEPGRAM_API_KEY — synthetic offline fixture." }, 503 );
// Real, read-only application contracts against empty fixture-owned data.
if ( method === "GET" && [ "/api/activity" , "/api/chats" , "/api/events" , "/api/graph" , "/api/graph/status" , "/api/graph/costs" , "/api/graph/render-settings" ]. includes ( p )) return route . continue ();
return block ();
});
2026-10-31 16:47:42 +03:00
try { await use ( state ); }
finally { await rm ( settingsFile , { force : true }); }
2026-10-31 16:20:49 +03:00
expect ( state . blocked , "Unexpected network request (blocked before dispatch)" ). toEqual ([]);
expect ( state . generation , "Setup/settings must not generate or send" ). toEqual ([]);
},
page : async ({ page , harness }, use ) => {
void harness ; // force interception before the test receives its page
await use ( page );
},
});
export { expect };