@@ -27,14 +27,17 @@ import type { HttpApiClient } from "effect/unstable/httpapi";
2727import type { Page } from "playwright" ;
2828import { composePluginApi } from "@executor-js/api/server" ;
2929import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api" ;
30- import { IntegrationSlug } from "@executor-js/sdk/shared" ;
30+ import { AuthTemplateSlug , ConnectionName , IntegrationSlug } from "@executor-js/sdk/shared" ;
3131
3232import { scenario } from "../src/scenario" ;
3333import { Api , Browser , Target } from "../src/services" ;
3434
3535const api = composePluginApi ( [ openApiHttpPlugin ( ) ] as const ) ;
3636type Client = HttpApiClient . ForApi < typeof api > ;
3737
38+ const TEMPLATE = AuthTemplateSlug . make ( "apiKey" ) ;
39+ const IDENTITY = "alice@example.com" ;
40+
3841const newSlug = ( prefix : string ) =>
3942 IntegrationSlug . make ( `${ prefix } -${ randomBytes ( 4 ) . toString ( "hex" ) } ` ) ;
4043
@@ -85,9 +88,7 @@ const serveIdentityApi = (validToken: string) =>
8588 const authorized = request . headers [ "authorization" ] === `Bearer ${ validToken } ` ;
8689 if ( request . method === "GET" && ( request . url ?? "" ) . startsWith ( "/me" ) ) {
8790 response . writeHead ( authorized ? 200 : 401 , { "content-type" : "application/json" } ) ;
88- response . end (
89- JSON . stringify ( authorized ? { email : "alice@example.com" } : { error : "x" } ) ,
90- ) ;
91+ response . end ( JSON . stringify ( authorized ? { email : IDENTITY } : { error : "x" } ) ) ;
9192 return ;
9293 }
9394 response . writeHead ( 404 , { "content-type" : "application/json" } ) ;
@@ -128,6 +129,57 @@ const registerIdentityIntegration = (client: Client, slug: IntegrationSlug, base
128129 } ,
129130 } ) ;
130131
132+ /** Like `serveIdentityApi`, but with a `revoke()` that flips the key off so a
133+ * saved connection's previously-good key stops working mid-session (the editor
134+ * scenario's healthy -> expired transition). */
135+ const serveMutableIdentityApi = ( validToken : string ) =>
136+ Effect . acquireRelease (
137+ Effect . callback < {
138+ readonly url : string ;
139+ readonly revoke : ( ) => void ;
140+ readonly close : ( ) => void ;
141+ } > ( ( resume ) => {
142+ let live = true ;
143+ const server = createServer ( ( request , response ) => {
144+ const authorized = live && request . headers [ "authorization" ] === `Bearer ${ validToken } ` ;
145+ if ( request . method === "GET" && ( request . url ?? "" ) . startsWith ( "/me" ) ) {
146+ response . writeHead ( authorized ? 200 : 401 , { "content-type" : "application/json" } ) ;
147+ response . end ( JSON . stringify ( authorized ? { email : IDENTITY } : { error : "x" } ) ) ;
148+ return ;
149+ }
150+ response . writeHead ( 404 , { "content-type" : "application/json" } ) ;
151+ response . end ( JSON . stringify ( { error : "not_found" } ) ) ;
152+ } ) ;
153+ server . listen ( 0 , "127.0.0.1" , ( ) => {
154+ const address = server . address ( ) ;
155+ const port = typeof address === "object" && address ? address . port : 0 ;
156+ resume (
157+ Effect . succeed ( {
158+ url : `http://127.0.0.1:${ port } ` ,
159+ revoke : ( ) => {
160+ live = false ;
161+ } ,
162+ close : ( ) => {
163+ server . close ( ) ;
164+ server . closeAllConnections ( ) ;
165+ } ,
166+ } ) ,
167+ ) ;
168+ } ) ;
169+ } ) ,
170+ ( server ) => Effect . sync ( server . close ) ,
171+ ) ;
172+
173+ /** The stored operation name for the GET probe (openapi prefixes it by tag),
174+ * discovered the same way the editor does: from the ranked candidate list. */
175+ const getMeOperation = ( client : Client , slug : IntegrationSlug ) =>
176+ Effect . gen ( function * ( ) {
177+ const candidates = yield * client . integrations . healthCheckCandidates ( { params : { slug } } ) ;
178+ const getMe = candidates . find ( ( candidate ) => candidate . method === "get" ) ;
179+ if ( ! getMe ) return yield * Effect . die ( "identity spec exposed no GET candidate" ) ;
180+ return getMe . operation ;
181+ } ) ;
182+
131183/** Select the combobox option whose visible text contains `optionText` by
132184 * keyboard (so it works inside a modal without a portaled-popup click). */
133185const selectComboboxOption = async ( page : Page , inputId : string , optionText : string ) => {
@@ -430,3 +482,162 @@ scenario(
430482 } ) ,
431483 ) ,
432484) ;
485+
486+ // ===========================================================================
487+ // 4. Edit sheet WITH identity: pick the operation + identity field, live-preview
488+ // the response, then drive "Check now" on a saved connection healthy ->
489+ // expired once the upstream revokes the key. (identity layer)
490+ // ===========================================================================
491+
492+ scenario (
493+ "Health checks (UI) · edit sheet with identity: preview the response, then healthy then expired" ,
494+ { } ,
495+ Effect . scoped (
496+ Effect . gen ( function * ( ) {
497+ const target = yield * Target ;
498+ const browser = yield * Browser ;
499+ const { client : makeClient } = yield * Api ;
500+ const identity = yield * target . newIdentity ( ) ;
501+ const client = yield * makeClient ( api , identity ) ;
502+ const goodToken = `gk_${ randomBytes ( 8 ) . toString ( "hex" ) } ` ;
503+ const server = yield * serveMutableIdentityApi ( goodToken ) ;
504+ const slug = newSlug ( "hc-ui-id" ) ;
505+ const name = ConnectionName . make ( "main" ) ;
506+
507+ yield * Effect . ensuring (
508+ Effect . gen ( function * ( ) {
509+ // Off camera: the integration and a saved connection holding the live
510+ // key. The health check is configured ON camera in the editor below.
511+ yield * registerIdentityIntegration ( client , slug , server . url ) ;
512+ const operation = yield * getMeOperation ( client , slug ) ;
513+ yield * client . connections . create ( {
514+ payload : {
515+ owner : "org" ,
516+ name,
517+ integration : slug ,
518+ template : TEMPLATE ,
519+ value : goodToken ,
520+ } ,
521+ } ) ;
522+
523+ yield * browser . session ( identity , async ( { page, step } ) => {
524+ const connections = page . locator ( "section" ) . filter ( {
525+ has : page . getByRole ( "heading" , { level : 3 , name : "Connections" } ) ,
526+ } ) ;
527+ const menuTrigger = connections . locator ( 'button[aria-haspopup="menu"]' ) ;
528+
529+ await step ( "Open the integration's connections" , async ( ) => {
530+ await page . goto ( `/integrations/${ slug } ` , { waitUntil : "networkidle" } ) ;
531+ await connections . getByText ( "main" , { exact : true } ) . waitFor ( ) ;
532+ await page . getByRole ( "heading" , { level : 3 , name : "Health check" } ) . waitFor ( ) ;
533+ } ) ;
534+
535+ await step ( "Pick the GET identity call and its email identity field" , async ( ) => {
536+ await page . getByRole ( "button" , { name : "Set up" } ) . click ( ) ;
537+ await selectComboboxOption ( page , "health-check-operation" , operation ) ;
538+ await selectComboboxOption ( page , "health-check-identity" , "email" ) ;
539+ } ) ;
540+
541+ await step ( "Live preview a pasted key: status, response, and identity" , async ( ) => {
542+ const sheet = page . getByRole ( "dialog" ) ;
543+ await page . locator ( "#health-check-preview-key" ) . fill ( goodToken ) ;
544+ await sheet . getByRole ( "button" , { name : "Preview" , exact : true } ) . click ( ) ;
545+ await sheet . getByText ( "Response" , { exact : true } ) . waitFor ( { timeout : 30_000 } ) ;
546+ await sheet . getByText ( "Resolves to:" ) . waitFor ( ) ;
547+ await sheet . getByText ( IDENTITY ) . first ( ) . waitFor ( ) ;
548+ } ) ;
549+
550+ await step ( "Save the health check" , async ( ) => {
551+ await page . getByRole ( "button" , { name : "Save" , exact : true } ) . click ( ) ;
552+ await page . locator ( "#health-check-operation" ) . waitFor ( { state : "hidden" } ) ;
553+ } ) ;
554+
555+ await step ( "Check the live connection: healthy, and whose account it is" , async ( ) => {
556+ await menuTrigger . click ( ) ;
557+ await page . getByRole ( "menuitem" , { name : "Check now" } ) . click ( ) ;
558+ await connections . getByText ( IDENTITY ) . waitFor ( { timeout : 30_000 } ) ;
559+ await connections . getByLabel ( "Status: Healthy" ) . waitFor ( ) ;
560+ } ) ;
561+
562+ await step ( "The upstream revokes the key: the connection reads expired" , async ( ) => {
563+ server . revoke ( ) ;
564+ await menuTrigger . click ( ) ;
565+ await page . getByRole ( "menuitem" , { name : "Check now" } ) . click ( ) ;
566+ await connections . getByText ( "Expired" , { exact : true } ) . waitFor ( { timeout : 30_000 } ) ;
567+ await connections . getByLabel ( "Status: Expired" ) . waitFor ( ) ;
568+ } ) ;
569+ } ) ;
570+
571+ const stored = yield * client . integrations . healthCheckGet ( { params : { slug } } ) ;
572+ expect ( stored ) . toEqual ( { operation, identityField : "email" } ) ;
573+ } ) ,
574+ Effect . gen ( function * ( ) {
575+ yield * client . connections
576+ . remove ( { params : { owner : "org" , integration : slug , name } } )
577+ . pipe ( Effect . ignore ) ;
578+ yield * client . openapi . removeSpec ( { params : { slug } } ) . pipe ( Effect . ignore ) ;
579+ } ) ,
580+ ) ;
581+ } ) ,
582+ ) ,
583+ ) ;
584+
585+ // ===========================================================================
586+ // 5. Add Connection, check configured WITH identity: checking the key derives
587+ // the connection name from the probed identity. (identity layer)
588+ // ===========================================================================
589+
590+ scenario (
591+ "Health checks (UI) · Add Connection derives the connection name from the probed identity" ,
592+ { } ,
593+ Effect . scoped (
594+ Effect . gen ( function * ( ) {
595+ const target = yield * Target ;
596+ const browser = yield * Browser ;
597+ const { client : makeClient } = yield * Api ;
598+ const identity = yield * target . newIdentity ( ) ;
599+ const client = yield * makeClient ( api , identity ) ;
600+ const goodToken = `gk_${ randomBytes ( 8 ) . toString ( "hex" ) } ` ;
601+ const server = yield * serveIdentityApi ( goodToken ) ;
602+ const slug = newSlug ( "hc-ui-name" ) ;
603+
604+ yield * Effect . ensuring (
605+ Effect . gen ( function * ( ) {
606+ // Configure a check WITH an identity field up front, so the Add
607+ // Connection modal probes against it (no inline picker needed).
608+ yield * registerIdentityIntegration ( client , slug , server . url ) ;
609+ const operation = yield * getMeOperation ( client , slug ) ;
610+ yield * client . integrations . healthCheckSet ( {
611+ params : { slug } ,
612+ payload : { spec : { operation, identityField : "email" } } ,
613+ } ) ;
614+
615+ yield * browser . session ( identity , async ( { page, step } ) => {
616+ const dialog = page . getByRole ( "dialog" ) ;
617+
618+ await step ( "Open the Add Connection modal" , async ( ) => {
619+ await page . goto ( `/integrations/${ slug } ` , { waitUntil : "networkidle" } ) ;
620+ await page . getByRole ( "button" , { name : "Add connection" , exact : true } ) . click ( ) ;
621+ await page . getByRole ( "heading" , { name : / A d d c o n n e c t i o n / } ) . waitFor ( ) ;
622+ } ) ;
623+
624+ await step ( "A valid key checks healthy and names the connection" , async ( ) => {
625+ await dialog . getByPlaceholder ( "paste the value / token" ) . fill ( goodToken ) ;
626+ await dialog . getByRole ( "button" , { name : "Check the key works" } ) . click ( ) ;
627+ await dialog . getByText ( "Healthy" ) . waitFor ( { timeout : 30_000 } ) ;
628+ // The probed identity auto-fills the display name.
629+ await page . waitForFunction (
630+ ( expected ) =>
631+ ( document . querySelector ( "#connection-name" ) as HTMLInputElement | null ) ?. value ===
632+ expected ,
633+ IDENTITY ,
634+ { timeout : 10_000 } ,
635+ ) ;
636+ } ) ;
637+ } ) ;
638+ } ) ,
639+ client . openapi . removeSpec ( { params : { slug } } ) . pipe ( Effect . ignore ) ,
640+ ) ;
641+ } ) ,
642+ ) ,
643+ ) ;
0 commit comments