@@ -2,6 +2,7 @@ import { createElement } from "react";
22import { renderToStaticMarkup } from "react-dom/server" ;
33import { describe , expect , it } from "vite-plus/test" ;
44import {
5+ EnvironmentId ,
56 ProviderDriverKind ,
67 ProviderInstanceId ,
78 type ServerProvider ,
@@ -11,6 +12,52 @@ import { DEFAULT_TIMESTAMP_FORMAT } from "@t3tools/contracts/settings";
1112
1213import { deriveProviderModelsForDisplay , ProviderInstanceCard } from "./ProviderInstanceCard" ;
1314
15+ /**
16+ * The inner markup of every `tag` element carrying `inert=""`. These tests
17+ * render to a string with no DOM available, so nesting is resolved by
18+ * balancing open and close tags.
19+ */
20+ function inertRegions ( markup : string , tag : string ) : ReadonlyArray < string > {
21+ const regions : Array < string > = [ ] ;
22+ const fenceOpen = `<${ tag } inert=""` ;
23+ const anyOpen = `<${ tag } ` ;
24+ const close = `</${ tag } >` ;
25+ let cursor = 0 ;
26+ for ( ; ; ) {
27+ const start = markup . indexOf ( fenceOpen , cursor ) ;
28+ if ( start === - 1 ) return regions ;
29+ const bodyStart = markup . indexOf ( ">" , start ) + 1 ;
30+ let depth = 1 ;
31+ let index = bodyStart ;
32+ while ( depth > 0 ) {
33+ const nextOpen = markup . indexOf ( anyOpen , index ) ;
34+ const nextClose = markup . indexOf ( close , index ) ;
35+ if ( nextClose === - 1 ) break ;
36+ if ( nextOpen !== - 1 && nextOpen < nextClose ) {
37+ depth += 1 ;
38+ index = nextOpen + anyOpen . length ;
39+ } else {
40+ depth -= 1 ;
41+ index = nextClose + close . length ;
42+ }
43+ }
44+ regions . push ( markup . slice ( bodyStart , Math . max ( bodyStart , index - close . length ) ) ) ;
45+ cursor = index ;
46+ }
47+ }
48+
49+ /**
50+ * The opening tag of the button carrying `ariaLabel`, with its class attribute
51+ * stripped so an assertion on `disabled=""` cannot be satisfied by the
52+ * `disabled:` variants baked into the button's class list.
53+ */
54+ function buttonTag ( markup : string , ariaLabel : string ) : string {
55+ const at = markup . indexOf ( `aria-label="${ ariaLabel } "` ) ;
56+ if ( at === - 1 ) return "" ;
57+ const start = markup . lastIndexOf ( "<button" , at ) ;
58+ return markup . slice ( start , markup . indexOf ( ">" , at ) + 1 ) . replace ( / c l a s s = " [ ^ " ] * " / g, "" ) ;
59+ }
60+
1461describe ( "deriveProviderModelsForDisplay" , ( ) => {
1562 it ( "uses current config custom models instead of stale live custom rows" , ( ) => {
1663 const liveModels : ReadonlyArray < ServerProviderModel > = [
@@ -89,4 +136,146 @@ describe("deriveProviderModelsForDisplay", () => {
89136 expect ( markup ) . not . toContain ( "Account email" ) ;
90137 expect ( markup ) . not . toContain ( "developer@example.com" ) ;
91138 } ) ;
139+
140+ it ( "surfaces a failed probe message in both the list row and the editor" , ( ) => {
141+ const instanceId = ProviderInstanceId . make ( "codex_work" ) ;
142+ const driver = ProviderDriverKind . make ( "codex" ) ;
143+ const message =
144+ "Codex app-server provider probe failed: Cannot create Codex shadow home entry 'auth.json' because '/home/me/.codex-t3/work/auth.json' already exists and is not a symlink." ;
145+ const liveProvider : ServerProvider = {
146+ instanceId,
147+ driver,
148+ enabled : true ,
149+ installed : true ,
150+ version : null ,
151+ status : "error" ,
152+ auth : { status : "unknown" } ,
153+ checkedAt : "2026-08-28T12:00:00.000Z" ,
154+ models : [ ] ,
155+ slashCommands : [ ] ,
156+ skills : [ ] ,
157+ message,
158+ } ;
159+ const props = {
160+ instanceId,
161+ instance : { driver } ,
162+ driverOption : undefined ,
163+ liveProvider,
164+ timestampFormat : DEFAULT_TIMESTAMP_FORMAT ,
165+ onUpdate : ( ) => undefined ,
166+ hiddenModels : [ ] ,
167+ favoriteModels : [ ] ,
168+ modelOrder : [ ] ,
169+ onHiddenModelsChange : ( ) => undefined ,
170+ onFavoriteModelsChange : ( ) => undefined ,
171+ onModelOrderChange : ( ) => undefined ,
172+ } as const ;
173+
174+ for ( const mode of [ "list" , "editor" ] as const ) {
175+ const markup = renderToStaticMarkup ( createElement ( ProviderInstanceCard , { ...props , mode } ) ) ;
176+ expect ( markup ) . toContain ( "Unavailable" ) ;
177+ expect ( markup ) . toContain ( "is not a symlink" ) ;
178+ }
179+ } ) ;
180+
181+ // Fork-only affordances that upstream's card has no equivalent of. The
182+ // upstream cleanup rebuilt the editor header around an inert fence for the
183+ // write actions, so this guards three things at once: the controls still
184+ // render, each chevron's disabled state tracks its handler rather than being
185+ // stuck off, and the fence lands on the write actions instead of on the
186+ // status line's email reveal.
187+ it ( "fences the drain-order chevrons and the in-app sign-in without freezing the email reveal" , ( ) => {
188+ const instanceId = ProviderInstanceId . make ( "claude_work" ) ;
189+ const driver = ProviderDriverKind . make ( "claudeAgent" ) ;
190+ const liveProvider : ServerProvider = {
191+ instanceId,
192+ driver,
193+ enabled : true ,
194+ installed : true ,
195+ version : "1.0.0" ,
196+ status : "warning" ,
197+ // Signed out but with a known address, so the sign-in button and the
198+ // redacted email both render and can be checked on opposite sides of
199+ // the fence.
200+ auth : { status : "unauthenticated" , email : "work@example.com" } ,
201+ checkedAt : "2026-08-28T12:00:00.000Z" ,
202+ models : [ ] ,
203+ slashCommands : [ ] ,
204+ skills : [ ] ,
205+ } ;
206+
207+ const render = (
208+ drainOrder : {
209+ readonly position : number ;
210+ readonly total : number ;
211+ readonly onMoveUp ?: ( ( ) => void ) | undefined ;
212+ readonly onMoveDown ?: ( ( ) => void ) | undefined ;
213+ } ,
214+ readOnly : boolean ,
215+ ) =>
216+ renderToStaticMarkup (
217+ createElement ( ProviderInstanceCard , {
218+ instanceId,
219+ environmentId : EnvironmentId . make ( "env-1" ) ,
220+ instance : { driver, displayName : "Work account" } ,
221+ driverOption : undefined ,
222+ liveProvider,
223+ mode : "editor" ,
224+ readOnly,
225+ timestampFormat : DEFAULT_TIMESTAMP_FORMAT ,
226+ drainOrder,
227+ onUpdate : ( ) => undefined ,
228+ hiddenModels : [ ] ,
229+ favoriteModels : [ ] ,
230+ modelOrder : [ ] ,
231+ onHiddenModelsChange : ( ) => undefined ,
232+ onFavoriteModelsChange : ( ) => undefined ,
233+ onModelOrderChange : ( ) => undefined ,
234+ } ) ,
235+ ) ;
236+
237+ // Middle of three accounts, so both chevrons carry a handler. A chevron
238+ // that had gone permanently disabled fails here instead of passing on the
239+ // strength of its label alone.
240+ const middle = render (
241+ { position : 1 , total : 3 , onMoveUp : ( ) => undefined , onMoveDown : ( ) => undefined } ,
242+ false ,
243+ ) ;
244+ expect ( buttonTag ( middle , "Use Work account earlier (currently 2 of 3)" ) ) . not . toContain (
245+ 'disabled=""' ,
246+ ) ;
247+ expect ( buttonTag ( middle , "Use Work account later (currently 2 of 3)" ) ) . not . toContain (
248+ 'disabled=""' ,
249+ ) ;
250+ expect ( middle ) . toContain ( "Sign in" ) ;
251+ expect ( inertRegions ( middle , "span" ) ) . toHaveLength ( 0 ) ;
252+
253+ // First of three: only "later" has a handler, so "earlier" is disabled by
254+ // its position at the end of the list.
255+ const first = render ( { position : 0 , total : 3 , onMoveDown : ( ) => undefined } , false ) ;
256+ expect ( buttonTag ( first , "Use Work account earlier (currently 1 of 3)" ) ) . toContain (
257+ 'disabled=""' ,
258+ ) ;
259+ expect ( buttonTag ( first , "Use Work account later (currently 1 of 3)" ) ) . not . toContain (
260+ 'disabled=""' ,
261+ ) ;
262+
263+ // Read-only sessions freeze the write actions inside inert fences. The
264+ // status line stays outside them so the email reveal keeps working, which
265+ // is the whole reason the blanket header wrapper was split up.
266+ const readOnly = render (
267+ { position : 1 , total : 3 , onMoveUp : ( ) => undefined , onMoveDown : ( ) => undefined } ,
268+ true ,
269+ ) ;
270+ const fences = inertRegions ( readOnly , "span" ) ;
271+ expect (
272+ fences . some ( ( region ) => region . includes ( "Use Work account earlier (currently 2 of 3)" ) ) ,
273+ ) . toBe ( true ) ;
274+ expect (
275+ fences . some ( ( region ) => region . includes ( "Use Work account later (currently 2 of 3)" ) ) ,
276+ ) . toBe ( true ) ;
277+ expect ( fences . some ( ( region ) => region . includes ( "Sign in" ) ) ) . toBe ( true ) ;
278+ expect ( readOnly ) . toContain ( 'aria-label="Toggle account email visibility"' ) ;
279+ expect ( fences . some ( ( region ) => region . includes ( "Toggle account email visibility" ) ) ) . toBe ( false ) ;
280+ } ) ;
92281} ) ;
0 commit comments