@@ -1579,9 +1579,9 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
15791579 // ---------------------------------------------------------------------------
15801580
15811581 /**
1582- * Test connection to Chronicle API using current config tab values .
1583- * Reads URL, key, and campaign ID from the Config tab inputs, attempts
1584- * a lightweight API call, and shows the result inline.
1582+ * Test connection to Chronicle with multi-step diagnostics .
1583+ * Tests API reachability, authentication, campaign access, and system match.
1584+ * Shows per-step results inline.
15851585 * @private
15861586 */
15871587 async _onTestConnection ( ) {
@@ -1598,58 +1598,121 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
15981598 const campaignId = campInput ?. value ?. trim ( ) ;
15991599
16001600 if ( ! url || ! key || ! campaignId ) {
1601- if ( resultEl ) {
1602- resultEl . textContent = 'Fill in URL, key, and campaign ID first.' ;
1603- resultEl . className = 'config-test-result test-error' ;
1604- }
1601+ this . _renderTestResults ( resultEl , [
1602+ { icon : 'xmark' , text : 'Fill in URL, key, and campaign ID first.' } ,
1603+ ] ) ;
16051604 return ;
16061605 }
16071606
16081607 if ( resultEl ) {
1609- resultEl . textContent = 'Testing...' ;
1608+ resultEl . innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Testing...' ;
16101609 resultEl . className = 'config-test-result test-pending' ;
16111610 }
16121611
1612+ const baseUrl = url . replace ( / \/ + $ / , '' ) ;
1613+ const steps = [ ] ;
1614+
1615+ // Step 1: API reachable + auth + campaign
1616+ let resp ;
16131617 try {
1614- const testUrl = `${ url . replace ( / \/ + $ / , '' ) } /api/v1/campaigns/${ campaignId } /entity-types` ;
1615- const resp = await fetch ( testUrl , {
1618+ resp = await fetch ( `${ baseUrl } /api/v1/campaigns/${ campaignId } /entity-types` , {
16161619 method : 'GET' ,
1617- headers : {
1618- 'Authorization' : `Bearer ${ key } ` ,
1619- 'Accept' : 'application/json' ,
1620- } ,
1620+ headers : { 'Authorization' : `Bearer ${ key } ` , 'Accept' : 'application/json' } ,
16211621 } ) ;
1622-
1623- if ( resp . ok ) {
1624- if ( resultEl ) {
1625- resultEl . textContent = 'Connected successfully!' ;
1626- resultEl . className = 'config-test-result test-success' ;
1627- }
1622+ } catch ( err ) {
1623+ const isCors = err instanceof TypeError && / f a i l e d t o f e t c h | n e t w o r k e r r o r / i. test ( err . message || '' ) ;
1624+ if ( isCors ) {
1625+ steps . push ( { icon : 'xmark' , text : `CORS: Chronicle is blocking requests from this domain. Ask admin to add ${ window . location . origin } to CORS whitelist.` } ) ;
16281626 } else {
1629- const status = resp . status ;
1630- let hint = '' ;
1631- if ( status === 401 || status === 403 ) hint = 'Check API key and permissions.' ;
1632- else if ( status === 404 ) hint = 'Check campaign ID.' ;
1633- else hint = `HTTP ${ status } ` ;
1634- if ( resultEl ) {
1635- resultEl . textContent = `Connection failed: ${ hint } ` ;
1636- resultEl . className = 'config-test-result test-error' ;
1637- }
1627+ steps . push ( { icon : 'xmark' , text : `Unreachable: Chronicle server not responding at ${ baseUrl } . Check URL and server status.` } ) ;
16381628 }
1639- } catch ( err ) {
1640- if ( resultEl ) {
1641- // Browsers throw a TypeError with generic message on CORS failure.
1642- const isCors = err instanceof TypeError
1643- && / f a i l e d t o f e t c h | n e t w o r k e r r o r / i. test ( err . message || '' ) ;
1644- if ( isCors ) {
1645- resultEl . textContent = 'CORS error: The Chronicle server is not allowing requests from this Foundry instance. '
1646- + 'Add this origin to the server\'s CORS allowed origins.' ;
1647- } else {
1648- resultEl . textContent = `Connection error: ${ err . message } ` ;
1629+ this . _renderTestResults ( resultEl , steps ) ;
1630+ return ;
1631+ }
1632+
1633+ if ( resp . status === 401 ) {
1634+ steps . push ( { icon : 'check' , text : 'API reachable' } ) ;
1635+ steps . push ( { icon : 'xmark' , text : 'Auth failed: API key invalid or revoked. Create a new key in Chronicle Settings \u2192 Integrations.' } ) ;
1636+ this . _renderTestResults ( resultEl , steps ) ;
1637+ return ;
1638+ }
1639+ if ( resp . status === 403 ) {
1640+ steps . push ( { icon : 'check' , text : 'API reachable' } ) ;
1641+ steps . push ( { icon : 'xmark' , text : 'Forbidden: API key lacks required permissions. Check Read/Write/Sync are enabled.' } ) ;
1642+ this . _renderTestResults ( resultEl , steps ) ;
1643+ return ;
1644+ }
1645+ if ( resp . status === 404 ) {
1646+ steps . push ( { icon : 'check' , text : 'API reachable' } ) ;
1647+ steps . push ( { icon : 'check' , text : 'Auth OK' } ) ;
1648+ steps . push ( { icon : 'xmark' , text : `Campaign not found. Check campaign ID: ${ campaignId } ` } ) ;
1649+ this . _renderTestResults ( resultEl , steps ) ;
1650+ return ;
1651+ }
1652+ if ( resp . status === 429 ) {
1653+ steps . push ( { icon : 'check' , text : 'API reachable' } ) ;
1654+ steps . push ( { icon : 'xmark' , text : 'Rate limit exceeded. Wait a moment or ask admin to increase the limit.' } ) ;
1655+ this . _renderTestResults ( resultEl , steps ) ;
1656+ return ;
1657+ }
1658+ if ( ! resp . ok ) {
1659+ steps . push ( { icon : 'check' , text : 'API reachable' } ) ;
1660+ steps . push ( { icon : 'xmark' , text : `HTTP ${ resp . status } : ${ resp . statusText } ` } ) ;
1661+ this . _renderTestResults ( resultEl , steps ) ;
1662+ return ;
1663+ }
1664+
1665+ steps . push ( { icon : 'check' , text : 'API reachable' } ) ;
1666+ steps . push ( { icon : 'check' , text : 'Auth OK' } ) ;
1667+ steps . push ( { icon : 'check' , text : 'Campaign accessible' } ) ;
1668+
1669+ // Step 2: System match check
1670+ try {
1671+ const sysResp = await fetch ( `${ baseUrl } /api/v1/campaigns/${ campaignId } /systems` , {
1672+ method : 'GET' ,
1673+ headers : { 'Authorization' : `Bearer ${ key } ` , 'Accept' : 'application/json' } ,
1674+ } ) ;
1675+ if ( sysResp . ok ) {
1676+ const sysResult = await sysResp . json ( ) ;
1677+ const systems = sysResult . data || [ ] ;
1678+ const foundryId = game . system ?. id ;
1679+ if ( foundryId ) {
1680+ const match = systems . find ( s => s . foundry_system_id === foundryId && s . enabled ) ;
1681+ if ( match ) {
1682+ steps . push ( { icon : 'check' , text : `System matched: ${ match . name } ` } ) ;
1683+ } else {
1684+ const byId = systems . find ( s => s . foundry_system_id === foundryId ) ;
1685+ if ( byId && ! byId . enabled ) {
1686+ steps . push ( { icon : 'warn' , text : `System "${ byId . name } " found but not enabled for this campaign` } ) ;
1687+ } else if ( systems . length > 0 ) {
1688+ steps . push ( { icon : 'warn' , text : `No system matches Foundry "${ foundryId } ". Available: ${ systems . map ( s => s . foundry_system_id || '(none)' ) . join ( ', ' ) } ` } ) ;
1689+ } else {
1690+ steps . push ( { icon : 'warn' , text : 'No game systems installed in Chronicle' } ) ;
1691+ }
1692+ }
16491693 }
1650- resultEl . className = 'config-test-result test-error' ;
16511694 }
1652- }
1695+ } catch { /* system check is optional */ }
1696+
1697+ this . _renderTestResults ( resultEl , steps ) ;
1698+ }
1699+
1700+ /**
1701+ * Render multi-step test connection results.
1702+ * @param {HTMLElement } resultEl
1703+ * @param {Array<{icon: string, text: string}> } steps
1704+ * @private
1705+ */
1706+ _renderTestResults ( resultEl , steps ) {
1707+ if ( ! resultEl ) return ;
1708+ const hasError = steps . some ( s => s . icon === 'xmark' ) ;
1709+ const icons = {
1710+ check : '<i class="fa-solid fa-check-circle" style="color:#4ade80"></i>' ,
1711+ xmark : '<i class="fa-solid fa-circle-xmark" style="color:#f87171"></i>' ,
1712+ warn : '<i class="fa-solid fa-triangle-exclamation" style="color:#fbbf24"></i>' ,
1713+ } ;
1714+ resultEl . innerHTML = steps . map ( s => `<div>${ icons [ s . icon ] || '' } ${ s . text } </div>` ) . join ( '' ) ;
1715+ resultEl . className = `config-test-result ${ hasError ? 'test-error' : 'test-success' } ` ;
16531716 }
16541717
16551718 /**
0 commit comments