@@ -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
@@ -128,6 +131,47 @@ const registerIdentityIntegration = (client: Client, slug: IntegrationSlug, base
128131 } ,
129132 } ) ;
130133
134+ /** Like `serveIdentityApi`, but with a `revoke()` that flips the key off so a
135+ * saved connection's previously-good key stops working mid-session (the editor
136+ * scenario's healthy -> expired transition). */
137+ const serveMutableIdentityApi = ( validToken : string ) =>
138+ Effect . acquireRelease (
139+ Effect . callback < {
140+ readonly url : string ;
141+ readonly revoke : ( ) => void ;
142+ readonly close : ( ) => void ;
143+ } > ( ( resume ) => {
144+ let live = true ;
145+ const server = createServer ( ( request , response ) => {
146+ const authorized = live && request . headers [ "authorization" ] === `Bearer ${ validToken } ` ;
147+ if ( request . method === "GET" && ( request . url ?? "" ) . startsWith ( "/me" ) ) {
148+ response . writeHead ( authorized ? 200 : 401 , { "content-type" : "application/json" } ) ;
149+ response . end ( JSON . stringify ( authorized ? { email : IDENTITY } : { error : "x" } ) ) ;
150+ return ;
151+ }
152+ response . writeHead ( 404 , { "content-type" : "application/json" } ) ;
153+ response . end ( JSON . stringify ( { error : "not_found" } ) ) ;
154+ } ) ;
155+ server . listen ( 0 , "127.0.0.1" , ( ) => {
156+ const address = server . address ( ) ;
157+ const port = typeof address === "object" && address ? address . port : 0 ;
158+ resume (
159+ Effect . succeed ( {
160+ url : `http://127.0.0.1:${ port } ` ,
161+ revoke : ( ) => {
162+ live = false ;
163+ } ,
164+ close : ( ) => {
165+ server . close ( ) ;
166+ server . closeAllConnections ( ) ;
167+ } ,
168+ } ) ,
169+ ) ;
170+ } ) ;
171+ } ) ,
172+ ( server ) => Effect . sync ( server . close ) ,
173+ ) ;
174+
131175/** The stored operation name for the GET probe (openapi prefixes it by tag),
132176 * discovered the same way the editor does: from the ranked candidate list. */
133177const getMeOperation = ( client : Client , slug : IntegrationSlug ) =>
@@ -571,3 +615,160 @@ scenario(
571615 } ) ,
572616 ) ,
573617) ;
618+
619+ // ===========================================================================
620+ // Edit sheet WITH identity (identity layer): pick the operation + identity field
621+ // by mouse, live-preview the response, then drive "Check now" on a saved
622+ // connection healthy -> expired once the upstream revokes the key.
623+ // ===========================================================================
624+
625+ scenario (
626+ "Health checks (UI) · edit sheet with identity: preview the response, then healthy then expired" ,
627+ { } ,
628+ Effect . scoped (
629+ Effect . gen ( function * ( ) {
630+ const target = yield * Target ;
631+ const browser = yield * Browser ;
632+ const { client : makeClient } = yield * Api ;
633+ const identity = yield * target . newIdentity ( ) ;
634+ const client = yield * makeClient ( api , identity ) ;
635+ const goodToken = `gk_${ randomBytes ( 8 ) . toString ( "hex" ) } ` ;
636+ const server = yield * serveMutableIdentityApi ( goodToken ) ;
637+ const slug = newSlug ( "hc-ui-id" ) ;
638+ const name = ConnectionName . make ( "main" ) ;
639+
640+ yield * Effect . ensuring (
641+ Effect . gen ( function * ( ) {
642+ yield * registerIdentityIntegration ( client , slug , server . url ) ;
643+ const operation = yield * getMeOperation ( client , slug ) ;
644+ yield * client . connections . create ( {
645+ payload : {
646+ owner : "org" ,
647+ name,
648+ integration : slug ,
649+ template : TEMPLATE ,
650+ value : goodToken ,
651+ } ,
652+ } ) ;
653+
654+ yield * browser . session ( identity , async ( { page, step } ) => {
655+ const connections = page . locator ( "section" ) . filter ( {
656+ has : page . getByRole ( "heading" , { level : 3 , name : "Connections" } ) ,
657+ } ) ;
658+ const menuTrigger = connections . locator ( 'button[aria-haspopup="menu"]' ) ;
659+
660+ await step ( "Open the integration's connections" , async ( ) => {
661+ await page . goto ( `/integrations/${ slug } ` , { waitUntil : "networkidle" } ) ;
662+ await connections . getByText ( "main" , { exact : true } ) . waitFor ( ) ;
663+ await page . getByRole ( "heading" , { level : 3 , name : "Health check" } ) . waitFor ( ) ;
664+ } ) ;
665+
666+ await step (
667+ "Pick the GET identity call and its email identity field by mouse" ,
668+ async ( ) => {
669+ await page . getByRole ( "button" , { name : "Set up" } ) . click ( ) ;
670+ await clickComboboxOption ( page , "health-check-operation" , "getMe" ) ;
671+ await clickComboboxOption ( page , "health-check-identity" , "email" ) ;
672+ } ,
673+ ) ;
674+
675+ await step ( "Live preview a pasted key: status, response, and identity" , async ( ) => {
676+ const sheet = page . getByRole ( "dialog" ) ;
677+ await page . locator ( "#health-check-preview-key" ) . fill ( goodToken ) ;
678+ await sheet . getByRole ( "button" , { name : "Preview" , exact : true } ) . click ( ) ;
679+ await sheet . getByText ( "Response" , { exact : true } ) . waitFor ( { timeout : 30_000 } ) ;
680+ await sheet . getByText ( "Resolves to:" ) . waitFor ( ) ;
681+ await sheet . getByText ( IDENTITY ) . first ( ) . waitFor ( ) ;
682+ } ) ;
683+
684+ await step ( "Save the health check" , async ( ) => {
685+ await page . getByRole ( "button" , { name : "Save" , exact : true } ) . click ( ) ;
686+ await page . locator ( "#health-check-operation" ) . waitFor ( { state : "hidden" } ) ;
687+ } ) ;
688+
689+ await step ( "Check the live connection: healthy, and whose account it is" , async ( ) => {
690+ await menuTrigger . click ( ) ;
691+ await page . getByRole ( "menuitem" , { name : "Check now" } ) . click ( ) ;
692+ await connections . getByText ( IDENTITY ) . waitFor ( { timeout : 30_000 } ) ;
693+ await connections . getByLabel ( "Status: Healthy" ) . waitFor ( ) ;
694+ } ) ;
695+
696+ await step ( "The upstream revokes the key: the connection reads expired" , async ( ) => {
697+ server . revoke ( ) ;
698+ await menuTrigger . click ( ) ;
699+ await page . getByRole ( "menuitem" , { name : "Check now" } ) . click ( ) ;
700+ await connections . getByText ( "Expired" , { exact : true } ) . waitFor ( { timeout : 30_000 } ) ;
701+ await connections . getByLabel ( "Status: Expired" ) . waitFor ( ) ;
702+ } ) ;
703+ } ) ;
704+
705+ const stored = yield * client . integrations . healthCheckGet ( { params : { slug } } ) ;
706+ expect ( stored ) . toEqual ( { operation, identityField : "email" } ) ;
707+ } ) ,
708+ Effect . gen ( function * ( ) {
709+ yield * client . connections
710+ . remove ( { params : { owner : "org" , integration : slug , name } } )
711+ . pipe ( Effect . ignore ) ;
712+ yield * client . openapi . removeSpec ( { params : { slug } } ) . pipe ( Effect . ignore ) ;
713+ } ) ,
714+ ) ;
715+ } ) ,
716+ ) ,
717+ ) ;
718+
719+ // ===========================================================================
720+ // Add Connection, check configured WITH identity (identity layer): checking the
721+ // key derives the connection name from the probed identity.
722+ // ===========================================================================
723+
724+ scenario (
725+ "Health checks (UI) · Add Connection derives the connection name from the probed identity" ,
726+ { } ,
727+ Effect . scoped (
728+ Effect . gen ( function * ( ) {
729+ const target = yield * Target ;
730+ const browser = yield * Browser ;
731+ const { client : makeClient } = yield * Api ;
732+ const identity = yield * target . newIdentity ( ) ;
733+ const client = yield * makeClient ( api , identity ) ;
734+ const goodToken = `gk_${ randomBytes ( 8 ) . toString ( "hex" ) } ` ;
735+ const server = yield * serveIdentityApi ( goodToken ) ;
736+ const slug = newSlug ( "hc-ui-name" ) ;
737+
738+ yield * Effect . ensuring (
739+ Effect . gen ( function * ( ) {
740+ yield * registerIdentityIntegration ( client , slug , server . url ) ;
741+ const operation = yield * getMeOperation ( client , slug ) ;
742+ yield * client . integrations . healthCheckSet ( {
743+ params : { slug } ,
744+ payload : { spec : { operation, identityField : "email" } } ,
745+ } ) ;
746+
747+ yield * browser . session ( identity , async ( { page, step } ) => {
748+ const dialog = page . getByRole ( "dialog" ) ;
749+
750+ await step ( "Open the Add Connection modal" , async ( ) => {
751+ await page . goto ( `/integrations/${ slug } ` , { waitUntil : "networkidle" } ) ;
752+ await page . getByRole ( "button" , { name : "Add connection" , exact : true } ) . click ( ) ;
753+ await page . getByRole ( "heading" , { name : / A d d c o n n e c t i o n / } ) . waitFor ( ) ;
754+ } ) ;
755+
756+ await step ( "A valid key checks healthy and names the connection" , async ( ) => {
757+ await dialog . getByPlaceholder ( "paste the value / token" ) . fill ( goodToken ) ;
758+ await dialog . getByRole ( "button" , { name : "Check the key works" } ) . click ( ) ;
759+ await dialog . getByText ( "Healthy" ) . waitFor ( { timeout : 30_000 } ) ;
760+ await page . waitForFunction (
761+ ( expected ) =>
762+ ( document . querySelector ( "#connection-name" ) as HTMLInputElement | null ) ?. value ===
763+ expected ,
764+ IDENTITY ,
765+ { timeout : 10_000 } ,
766+ ) ;
767+ } ) ;
768+ } ) ;
769+ } ) ,
770+ client . openapi . removeSpec ( { params : { slug } } ) . pipe ( Effect . ignore ) ,
771+ ) ;
772+ } ) ,
773+ ) ,
774+ ) ;
0 commit comments