From 0201095834a7619e64443b4ef7624f00f1da9807 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 17 Jul 2026 16:05:02 +0000 Subject: [PATCH 1/6] =?UTF-8?q?refactor(#276):=20delete=20conn/catalog=20A?= =?UTF-8?q?pp=20delegates=20=E2=80=94=20phase=205=20(1/5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase-2/4A temporary adapters are gone: consumers read app.conn.*/app.catalog.* directly. BootstrapApp/LoginApp/ShortcutsApp/ ResultsApp gained narrow conn Picks; loadConfig's one real caller (main.ts OAuth callback) renamed to conn.resolveConfig; the CM6 adapter's reference seam re-pointed to app.catalog (keeping its deliberately loose defensive shape); the e2e completions-write seam became window.__app.catalog.completions (still writable — the two-way accessors moved with the service). showLogin/signOut stay on App (they compose rendering — not pure forwards). ~150 test call sites re-pointed (incl. the 77 app.chCtx reads → app.conn.chCtx); fake-app keeps a convenience top-level chCtx override key feeding conn.chCtx so makeApp call sites didn't churn; one real coverage gap closed (catalog.docCache accessor now asserted directly). Gate: 113 files / 3214 tests, build, check:arch green. Part of #276 (Phase-5 PR, commit 1). Claude-Session: ad39bf19-1690-43c7-abb4-f0084fca00a2 Co-authored-by: Claude Fable 5 --- src/application/schema-catalog-service.ts | 6 +- src/editor/codemirror-adapter.ts | 91 ++++---- src/main.ts | 30 ++- src/ui/app.ts | 84 ++------ src/ui/app.types.ts | 58 ++--- src/ui/dashboard.ts | 10 +- src/ui/login.ts | 26 +-- src/ui/results.ts | 5 +- src/ui/shortcuts.ts | 5 +- tests/e2e/editor-cm6.spec.js | 2 +- tests/helpers/fake-app.ts | 111 +++++----- tests/unit/app.test.ts | 248 +++++++++++----------- tests/unit/codemirror-adapter.test.ts | 121 ++++++----- tests/unit/dashboard.test.ts | 32 +-- tests/unit/login.test.ts | 19 +- tests/unit/main.test.ts | 72 ++++--- tests/unit/results.test.ts | 2 +- tests/unit/schema-catalog-service.test.ts | 4 + tests/unit/shortcuts.test.ts | 8 +- 19 files changed, 450 insertions(+), 484 deletions(-) diff --git a/src/application/schema-catalog-service.ts b/src/application/schema-catalog-service.ts index 511a461..f4d58e9 100644 --- a/src/application/schema-catalog-service.ts +++ b/src/application/schema-catalog-service.ts @@ -139,9 +139,9 @@ export function createSchemaCatalogService(deps: SchemaCatalogDeps): SchemaCatal // connection (the keystroke rule, #25): keywords/functions drive both // version-correct highlighting and the autocomplete list; completion then // runs client-side. `refData`/`completions` are private closure state — - // `app.catalog.refData`/`app.catalog.completions` (and app.ts's mirrored - // `app.refData`/`app.completions`) read them via the getters above/below, - // always the CURRENT value. + // `app.catalog.refData`/`app.catalog.completions` (#276 Phase 5 deleted the + // flat `App.refData`/`App.completions` delegates app.ts used to mirror them + // onto) read them via the getters above/below, always the CURRENT value. let refData: AssembledReference = assembleReferenceData(null); // built-in fallback until loaded let completions: CompletionItem[] = buildCompletions(refData, state.schema.value as SchemaDb[] | null); diff --git a/src/editor/codemirror-adapter.ts b/src/editor/codemirror-adapter.ts index 3f50ff8..ce6a4fe 100644 --- a/src/editor/codemirror-adapter.ts +++ b/src/editor/codemirror-adapter.ts @@ -41,9 +41,9 @@ import type { EditorPort, EditorSelection } from './editor-port.types.js'; // ── Local typed contracts ─────────────────────────────────────────────────── // core/completions.js's own `AssembledReference`/`CompletionContext` shapes -// are imported directly (below) — this adapter's `app.refData` and the -// completion-context plumbing match them exactly. `app.completions` keeps its -// OWN wider local `CompletionItem` (kind: string, not the closed +// are imported directly (below) — this adapter's `app.catalog.refData` and +// the completion-context plumbing match them exactly. `app.catalog.completions` +// keeps its OWN wider local `CompletionItem` (kind: string, not the closed // `CompletionKind` union): this adapter renders an unrecognized `kind` as a // bare CSS chip rather than rejecting it (see `completionSourceFor`'s // `type: it.kind` below), so its own candidate contract is deliberately more @@ -71,7 +71,7 @@ interface InfoItem { fullType?: string; } -/** The complete completion-candidate shape `app.completions` holds (built by +/** The complete completion-candidate shape `app.catalog.completions` holds (built by * core/completions.js's `buildCompletions`, which in practice never emits * anything outside `CompletionKind` — but this adapter's own contract stays * open on `kind`, see above). `completionSourceFor` reads every field; @@ -97,16 +97,19 @@ export interface SqlCompletionContext { } /** `langExtensionFor`'s own narrow app slice — just the reference data (or - * its absence — no connection yet). Declared `unknown` (not - * `AssembledReference` directly) so the real, injected `app` controller's - * own placeholder type (`App.refData` in `ui/app.types.ts`, kept loose - * there since nothing else reads its actual shape) is assignable here too — - * `createCodeMirrorEditor` must stay assignable to `env.types.ts`'s - * `Editor?: (app: App) => EditorPort` seam. This adapter is the one real - * consumer of the precise shape, so it narrows with a local `as` at each - * read site instead. */ + * its absence — no connection yet), grouped under `catalog` (#276 Phase 5 — + * `app.catalog` is `SchemaCatalogService`; `refData`/`completions`/ + * `entityDoc` no longer exist as flat `App` delegates). Declared `unknown` + * (not `AssembledReference` directly) so the real, injected `app` + * controller's own `SchemaCatalogService.refData` (always a real object, + * never `null`/`undefined` in production — but this adapter's own tests + * still exercise the "no reference data at all" defensive branch) is + * assignable here too — `createCodeMirrorEditor` must stay assignable to + * `env.types.ts`'s `Editor?: (app: App) => EditorPort` seam. This adapter is + * the one real consumer of the precise shape, so it narrows with a local + * `as` at each read site instead. */ interface DialectApp { - refData?: unknown; + catalog?: { refData?: unknown }; } /** `hoverSourceFor`/`infoFor`'s own narrow app slice: the reference data plus @@ -114,7 +117,7 @@ interface DialectApp { * `CodeMirrorEditorApp` the port itself needs (tests call these two * directly with exactly this shape). */ interface ReferenceApp extends DialectApp { - entityDoc?: (name: string) => Promise; + catalog?: { refData?: unknown; entityDoc?: (name: string) => Promise }; } /** The subset of a DOM drag event `handleDrop` reads — real `DragEvent`s (the @@ -136,10 +139,11 @@ export interface DropEvent { export interface CodeMirrorEditorApp extends ReferenceApp { state: AppState; dom: { sqlEditorView?: EditorView }; - /** `unknown` for the same reason as `DialectApp.refData` above (matches - * `App.completions`'s own loose `Json` placeholder); narrowed with a - * local `as` in `completionSourceFor`. */ - completions?: unknown; + /** `completions` stays `unknown` for the same reason as `DialectApp`'s + * `catalog.refData` above (matches `SchemaCatalogService.completions`'s + * real, always-populated shape); narrowed with a local `as` in + * `completionSourceFor`. */ + catalog?: { refData?: unknown; completions?: unknown; entityDoc?: (name: string) => Promise }; actions: { loadColumns(db: string, table: string): Promise }; } @@ -171,9 +175,9 @@ const LITERAL_NODE = /String|Comment|QuotedIdentifier/; * deliberately doesn't pair (it would fight the #134 `{name:Type}` variables). */ export function langExtensionFor(app: DialectApp): Extension[] { - // `as`: `app.refData` is `unknown` here (see `DialectApp`'s doc comment) — - // this adapter is the one real consumer of its actual shape. - const ref = app.refData as AssembledReference | null | undefined; + // `as`: `app.catalog.refData` is `unknown` here (see `DialectApp`'s doc + // comment) — this adapter is the one real consumer of its actual shape. + const ref = app.catalog?.refData as AssembledReference | null | undefined; const dialect = SQLDialect.define({ keywords: (ref ? ref.keywords : []).join(' ').toLowerCase(), builtin: Object.keys(ref ? ref.functions : {}).join(' ').toLowerCase(), @@ -239,9 +243,9 @@ export function inputGuards(view: EditorView, from: number, to: number, text: st /** * Completion source: CM6's UI over the pure core ranking (#26 parity v0). * `filter: false` keeps `rankCompletions`' order (CM6 would fuzzy-rescore and - * dedup otherwise). Candidates come from `app.completions` at call time, so + * dedup otherwise). Candidates come from `app.catalog.completions` at call time, so * schema/reference updates need no reconfigure. Never queries — `info` resolves - * through app.entityDoc's lazy cache, and only for the row the user rests on. + * through app.catalog.entityDoc's lazy cache, and only for the row the user rests on. */ export function completionSourceFor(app: CodeMirrorEditorApp): (ctx: SqlCompletionContext) => CompletionResult | null { return (ctx) => { @@ -260,14 +264,14 @@ export function completionSourceFor(app: CodeMirrorEditorApp): (ctx: SqlCompleti // lines before the caret, so a FROM above the caret is in view; a FROM below // it degrades gracefully to the global pool (no scope). c.scope = fromScopeAt(doc, ctx.pos, toks); - // `as`: `app.completions` is `unknown` here (see `CodeMirrorEditorApp`'s - // doc comment). Real values are always core/completions.ts's own - // `buildCompletions` output (`CompletionKind`-typed); this adapter's own - // `CompletionItem` deliberately keeps `kind: string` open for its - // pass-through rendering (see the top-of-file note), and - // `rankCompletions` only ever compares `kind` against its own known - // literals, so the cast is behaviorally safe either way. - const items = rankCompletions((app.completions as CompletionItem[] | undefined || []) as CoreCompletionItem[], c); + // `as`: `app.catalog.completions` is `unknown` here (see + // `CodeMirrorEditorApp`'s doc comment). Real values are always + // core/completions.ts's own `buildCompletions` output (`CompletionKind`- + // typed); this adapter's own `CompletionItem` deliberately keeps + // `kind: string` open for its pass-through rendering (see the top-of-file + // note), and `rankCompletions` only ever compares `kind` against its own + // known literals, so the cast is behaviorally safe either way. + const items = rankCompletions((app.catalog?.completions as CompletionItem[] | undefined || []) as CoreCompletionItem[], c); if (!items.length) return null; return { from: c.from, @@ -309,26 +313,27 @@ export function applyFor(it: ApplyItem): Completion['apply'] { type InfoFn = () => Node | null | Promise; // The active row's description: static keyword docs immediately, function -// docs lazily via app.entityDoc (cached, one query per name ever — #27). +// docs lazily via app.catalog.entityDoc (cached, one query per name ever — #27). // CM6 shows it as a side tooltip (the old dropdown used a footer). An `info` // FUNCTION must yield a DOM node (a bare string is only legal when `info` // itself is the string) — CM6's addInfoPane appendChild()s the result. export function infoFor(app: ReferenceApp, it: InfoItem): InfoFn | undefined { const doc = (text: string | null | undefined): Node | null => (text ? h('div', null, text) : null); if (it.kind === 'keyword') { - // `as`: `app.refData` is `unknown` (see `DialectApp`'s doc comment); read - // fresh on every call (not hoisted) — CM6 may invoke this `info` callback - // well after refData has since changed (#25 load lands mid-session). + // `as`: `app.catalog.refData` is `unknown` (see `DialectApp`'s doc + // comment); read fresh on every call (not hoisted) — CM6 may invoke this + // `info` callback well after refData has since changed (#25 load lands + // mid-session). return () => { - const refData = app.refData as AssembledReference | null | undefined; + const refData = app.catalog?.refData as AssembledReference | null | undefined; return doc(refData && refData.keywordDocs[it.label.toUpperCase()]); }; } if (it.kind === 'fn' || it.kind === 'agg' || it.kind === 'cast') { - if (!app.entityDoc) return undefined; + if (!app.catalog?.entityDoc) return undefined; // `!`: just checked above; a deferred closure doesn't retain that // narrowing across the function boundary, but the guard already ran. - return () => Promise.resolve(app.entityDoc!(it.label)).then(doc); + return () => Promise.resolve(app.catalog!.entityDoc!(it.label)).then(doc); } // A column whose `detail` is a compacted type summary (#177) exposes the full // declared type here — CM6's detail column has no native title fallback. @@ -357,8 +362,8 @@ const lookupFn = (functions: Record, word: stri */ export function hoverSourceFor(app: ReferenceApp): (view: EditorView, pos: number) => Tooltip | null { return (view, pos) => { - // `as`: `app.refData` is `unknown` here (see `DialectApp`'s doc comment). - const refData = app.refData as AssembledReference | null | undefined; + // `as`: `app.catalog.refData` is `unknown` here (see `DialectApp`'s doc comment). + const refData = app.catalog?.refData as AssembledReference | null | undefined; if (!refData) return null; if (LITERAL_NODE.test(syntaxTree(view.state).resolveInner(pos, 0).name)) return null; // Identifiers can't span lines — scan the line, not the whole doc. @@ -378,8 +383,8 @@ export function hoverSourceFor(app: ReferenceApp): (view: EditorView, pos: numbe fn.ret ? h('span', { class: 'hover-ret' }, ' → ' + fn.ret) : null)); const doc = h('div', { class: 'hover-doc' }, fn.desc || ''); dom.appendChild(doc); - if (!fn.desc && app.entityDoc) { - Promise.resolve(app.entityDoc(w.word)).then((d) => { if (d) doc.textContent = d; }); + if (!fn.desc && app.catalog?.entityDoc) { + Promise.resolve(app.catalog.entityDoc(w.word)).then((d) => { if (d) doc.textContent = d; }); } } else { dom.appendChild(h('div', { class: 'hover-doc' }, kwDoc)); @@ -446,7 +451,7 @@ const COLUMN_LOAD_DELAY_MS = 300; * FROM-driven lazy column loading (#84): parse the statement around the caret, * find its FROM/JOIN tables whose columns aren't loaded yet, and fetch them via * the app's existing `loadColumns` (which writes the `'loading'` sentinel to - * dedupe, caches per connection, and rebuilds `app.completions`). Uses the whole + * dedupe, caches per connection, and rebuilds `app.catalog.completions`). Uses the whole * document (not the keystroke-path line slice) so a FROM below the caret still * prefetches. Resolves to whether it fetched anything — the caller refreshes an * open dropdown only after re-checking the view is still live (destroy race). diff --git a/src/main.ts b/src/main.ts index 9a8f47c..b4f5db9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,7 +19,7 @@ import { isQuerylessPanel } from './core/panel-cfg.js'; import { setTabSpecDraft, SAVED_VIEWS } from './state.js'; import type { State } from './ui/app.types.js'; import type { BootstrapEnv } from './env.types.js'; -import type { ResolvedIdpConfig } from './net/oauth-config.js'; +import type { ConnectionSession } from './application/connection-session.js'; import type { SpecEditorApp } from './editor/spec-editor.js'; import type { ShortcutKeydownEvent } from './ui/shortcuts.js'; @@ -28,15 +28,13 @@ import type { ShortcutKeydownEvent } from './ui/shortcuts.js'; * own `createApp()` call below) satisfies this directly, and so does * tests/unit/main.test.ts's long-standing minimal `fakeApp()` fixture — no * cast needed on either side (same convention as ui/shortcuts.ts's - * ShortcutsApp). */ + * ShortcutsApp). Identity/auth reads go through `conn` (#276 Phase 5 — + * the flat `App` delegates were deleted; `loadConfig` is now `resolveConfig`, + * its real name on `ConnectionSession`). */ export interface BootstrapApp { state: Pick; - isSignedIn(): boolean; - loadConfig(): Promise; - setTokens(id: string, refresh?: string): void; - receiveAuthHandoff(handoffEnv: { opener?: Window | null }): Promise; - ensureFreshToken(): Promise; - ensureConfig(): Promise; + conn: Pick; renderDashboard(): void; renderApp(): void; /** The real `App.showLogin` is `(msg?: string) => void` — every other real @@ -72,7 +70,7 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ callbackError = 'OAuth state mismatch — please try again.'; } else { try { - const cfg = await app.loadConfig(); + const cfg = await app.conn.resolveConfig(); const tokens = await exchangeCodeForTokens(env.fetch, cfg, { code, // `verifier` is written to sessionStorage just before the redirect; @@ -84,7 +82,7 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ }); const bearer = bearerFromTokens(tokens, cfg.bearer); if (!bearer) throw new Error('Token response missing bearer token'); - app.setTokens(bearer, tokens.refresh_token); + app.conn.setTokens(bearer, tokens.refresh_token); } catch (e) { callbackError = 'OAuth token exchange failed: ' + ((e instanceof Error && e.message) || e); } @@ -155,26 +153,26 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ // one-time credential handoff from the opener before deciding what to render. // A cold/bookmarked visit has no opener → falls through to the login screen, // which after sign-in returns to /sql/dashboard and renders the dashboard. - if (dash && !app.isSignedIn()) { - await app.receiveAuthHandoff(env); + if (dash && !app.conn.isSignedIn()) { + await app.conn.receiveAuthHandoff(env); // The opener may hand over an *expired* id_token whose refresh token is still // good (an idle opener refreshes only lazily). Attempt a refresh before // giving up — otherwise a valid handoff would still bounce to a full re-login. - if (!app.isSignedIn()) await app.ensureFreshToken(); + if (!app.conn.isSignedIn()) await app.conn.ensureFreshToken(); } - if (app.isSignedIn()) { + if (app.conn.isSignedIn()) { // Signed in either via a valid OAuth token or a restored basic session. ss.removeItem('oauth_shared'); // consumed // Resolve config first so the header shows the real CH identity (the // ch_auth=basic username, not the raw email claim) on first paint. // (ensureConfig is a no-op in basic mode.) - await app.ensureConfig(); + await app.conn.ensureConfig(); if (dash) app.renderDashboard(); else app.renderApp(); } else { app.showLogin(callbackError); } - return { callbackError, signedIn: app.isSignedIn() }; + return { callbackError, signedIn: app.conn.isSignedIn() }; } // Set once by tests/setup.js to keep the browser-only autostart block below diff --git a/src/ui/app.ts b/src/ui/app.ts index 5827069..d7a5666 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -58,7 +58,6 @@ import type { ComboField } from './combobox.js'; import { recentOptions } from '../core/recent-values.js'; import { paramComparisonColumns } from '../core/param-comparison.js'; import type { SchemaDb } from '../core/from-scope.js'; -import type { AssembledReference, CompletionItem } from '../core/completions.js'; import { libraryControls, renderLibraryTitle } from './file-menu.js'; import { renderLogin } from './login.js'; import { openShortcuts } from './shortcuts.js'; @@ -216,8 +215,8 @@ export function createApp(env: CreateAppEnv = {}): App { app.downloadFile = downloadFile; // --- identity ------------------------------------------------------------ - // app.host is a Phase-2 delegate (conn.host) — assigned below, alongside the - // rest of the ConnectionSession delegates, once `conn` is constructed. + // Identity/auth reads (host/email/isSignedIn/…) live on `app.conn` itself + // (assigned below, once `conn` is constructed) — no flat `App` delegate. app.activeTab = () => activeTab(app.state); // --- independent SQL + Spec editor seams (#143/#212) --------------------- @@ -341,22 +340,12 @@ export function createApp(env: CreateAppEnv = {}): App { const getToken = conn.getToken; const ensureConfig = conn.ensureConfig; - // Phase-2 delegates — shells/bootstrap consume these; Phase 5 re-points them - // to app.conn directly. - app.basePath = conn.basePath; - app.hostHint = conn.hostHint; - app.host = conn.host; - app.isSignedIn = conn.isSignedIn; - app.email = conn.email; - app.setTokens = conn.setTokens; - app.loadConfig = conn.resolveConfig; - app.loadIdps = conn.loadIdps; - app.ensureConfig = conn.ensureConfig; - app.ensureFreshToken = conn.ensureFreshToken; - app.chCtx = chCtx; + // Identity/auth/config all live on `conn` (see app.types.ts's own doc + // comment) — no flat `App` delegates (#276 Phase 5 deleted them). + // `showLogin`/`signOut` stay app.ts-owned: they compose rendering, not + // pure forwards. app.signOut = () => { conn.signOut(); renderLoginApp(); }; app.showLogin = (msg) => renderLoginApp(msg); - app.receiveAuthHandoff = (handoffEnv) => conn.receiveAuthHandoff(handoffEnv); // --- data loaders -------------------------------------------------------- // The server-metadata/reference lifecycle (#276 Phase 4A) — server-version @@ -395,44 +384,11 @@ export function createApp(env: CreateAppEnv = {}): App { }, }); app.catalog = catalog; - app.loadVersion = () => catalog.loadVersion(); - app.loadSchema = () => catalog.loadSchema(); - app.loadReference = () => catalog.loadReference(); - app.rebuildCompletions = () => catalog.rebuildCompletions(); - app.entityDoc = (name) => catalog.entityDoc(name); - // `App.refData`/`App.completions` are deliberately loose (`Json`-shaped) - // placeholders — codemirror-adapter.ts's own narrow app slice reads the real - // `AssembledReference`/`CompletionItem[]` shapes via its own local `as` - // (documented there). The service owns the real precisely-typed values as - // its OWN get/set ACCESSORS (`catalog.refData`/`catalog.completions`, - // always current — see that interface's doc comment for why a setter - // exists, not just a getter). These `Object.defineProperty` accessors - // mirror BOTH directions onto `app`: a read observes the CURRENT value - // after a `loadReference()`/columns-load rebuild (no app.ts re-mirroring - // needed, unlike the pre-extraction `Object.assign(app, {refData})` - // pattern), and a write (e.g. a test overwriting `app.completions` - // directly, same as `tests/e2e/editor-cm6.spec.js` does) flows straight - // into the service's own live value, exactly as reassigning the plain - // property used to. `as never`/loose casts on the setter's incoming value - // sidestep the same declared-type mismatch `Object.assign` used to - // sidestep (see `App.refData`'s own loose `Json`-ish declared type). - Object.defineProperty(app, 'refData', { - get: () => catalog.refData, - set: (v) => { catalog.refData = v as AssembledReference; }, - enumerable: true, - configurable: true, - }); - Object.defineProperty(app, 'completions', { - get: () => catalog.completions, - set: (v) => { catalog.completions = v as CompletionItem[]; }, - enumerable: true, - configurable: true, - }); - // `docCache` is a single Map instance the service owns and only ever - // mutates in place (cleared/set), never reassigns — a direct property copy - // of the reference (not a getter) stays valid for the object's lifetime, - // matching the original `app.docCache = new Map()` assignment. - app.docCache = catalog.docCache; + // `loadVersion`/`loadSchema`/`loadReference`/`rebuildCompletions`/ + // `entityDoc`/`refData`/`completions`/`docCache` all live on `catalog` + // itself now (#276 Phase 5 deleted the flat `App` delegates) — + // codemirror-adapter.ts and every other consumer reads `app.catalog.*` + // directly. // A prominent, dismissible banner for schema/auth failures — the schema-panel // text alone is easy to miss on first deploy. Driven by app.state.schemaError. function updateBanner() { @@ -602,7 +558,7 @@ export function createApp(env: CreateAppEnv = {}): App { renderResults: () => renderResults(app), showExportProgress: (onCancel) => showExportProgress(onCancel), toast: (message) => flashToast(message, { document: doc }), - loadSchema: () => { void app.loadSchema(); }, + loadSchema: () => { void catalog.loadSchema(); }, }, }); app.exports = exportService; @@ -624,7 +580,7 @@ export function createApp(env: CreateAppEnv = {}): App { renderResults: () => renderResults(app), renderSavedHistory: () => renderSavedHistory(app), cancelSchemaGraph, - loadSchema: () => { void app.loadSchema(); }, + loadSchema: () => { void catalog.loadSchema(); }, recordHistory: (tab, sql) => app.recordHistory(tab, sql), recordBoundParams: (bp) => app.recordBoundParams([...bp]), prepareTabSource: params.prepareTabSource, varGateBlocked: params.varGateBlocked, @@ -1370,7 +1326,7 @@ export function createApp(env: CreateAppEnv = {}): App { let close: () => void; const logoutBtn = h('button', { class: 'um-item danger', onclick: () => { close(); app.signOut(); } }, Icon.logout(), h('span', null, 'Log out')); const menu = h('div', { class: 'user-menu' }, - h('div', { class: 'um-id' }, app.email()), + h('div', { class: 'um-id' }, conn.email()), logoutBtn, h('div', { class: 'um-build', title: 'App version / build' }, app.build)); ({ close } = anchoredPopover(menu, app.dom.userBtn!, 'userMenu')); @@ -1483,12 +1439,12 @@ export function renderApp(app: App, helpers: RenderAppHelpers): void { app.dom.connStatus = h('div', { class: 'conn-status dim' }, h('span', { class: 'ver' }, 'Connecting…')); app.dom.themeBtn = h('button', { class: 'hd-btn', title: 'Toggle theme', onclick: helpers.toggleTheme }); app.dom.themeBtn.appendChild(state.theme === 'dark' ? Icon.sun() : Icon.moon()); - app.dom.userBtn = h('button', { class: 'hd-btn user-btn', title: app.email(), onclick: () => app.actions.openUserMenu() }, - h('span', { class: 'user-short' }, userShortName(app.email())), Icon.chevDown()); + app.dom.userBtn = h('button', { class: 'hd-btn user-btn', title: app.conn.email(), onclick: () => app.actions.openUserMenu() }, + h('span', { class: 'user-short' }, userShortName(app.conn.email())), Icon.chevDown()); const header = h('div', { class: 'app-header' }, h('div', { class: 'logo-mark' }, Icon.brand()), h('div', { class: 'logo-name' }, 'Altinity® SQL Browser'), - h('div', { class: 'env-chip' }, app.host()), + h('div', { class: 'env-chip' }, app.conn.host()), h('div', { class: 'hd-divider' }), ...libraryControls(app), h('div', { style: { flex: '1' } }), @@ -1755,7 +1711,7 @@ export function renderApp(app: App, helpers: RenderAppHelpers): void { // breakpoint). Each runs once now for the initial paint. effect(() => { mainRow.dataset.mobileView = state.mobileView.value; }); effect(() => { sidebar.dataset.mobileTab = state.mobileTab.value; }); - app.loadVersion(); - app.loadSchema(); - app.loadReference(); + app.catalog.loadVersion(); + app.catalog.loadSchema(); + app.catalog.loadReference(); } diff --git a/src/ui/app.types.ts b/src/ui/app.types.ts index 62ee4e0..575ea65 100644 --- a/src/ui/app.types.ts +++ b/src/ui/app.types.ts @@ -13,7 +13,6 @@ import type { EditorPort } from '../editor/editor-port.types.js'; import type { SpecEditorPort } from '../editor/spec-editor.types.js'; import type { CodeViewerFactory } from '../editor/code-viewer.types.js'; import type { QueryTab as Tab, AppState as State, SpecValidationService } from '../state.js'; -import type { ConfigDoc, ResolvedIdpConfig } from '../net/oauth-config.js'; import type { QueryExecutionService } from '../application/query-execution-service.js'; import type { ConnectionSession, SessionChCtx } from '../application/connection-session.js'; import type { SchemaCatalogService } from '../application/schema-catalog-service.js'; @@ -212,37 +211,18 @@ export interface App { /** Ad-hoc, consumer-attached (chart-render.js), not initialized by createApp. */ chart?: { destroy(): void }; - // Identity / auth — Phase-2 delegates onto `conn` (see its doc comment - // above). `authMode`/`chAuth`/`basicUserClaim`/`idpId`/`selectIdp`/ - // `chUsername` moved onto `app.conn` itself (`conn.authMode()`/ - // `conn.chAuth()`/`conn.basicUserClaim()`/`conn.idpId()`/`conn.selectIdp()`) - // — no other module read them off `App` directly (verified #276 Phase 2), - // so they aren't re-delegated here. - host(): string; + // Identity / auth — all live on `app.conn` (see its doc comment above), + // e.g. `conn.host()`/`conn.email()`/`conn.isSignedIn()`. `authMode`/ + // `chAuth`/`basicUserClaim`/`idpId`/`selectIdp`/`chUsername` likewise moved + // there in Phase 2; the flat `App` delegates that used to forward onto them + // (`isSignedIn`/`email`/`host`/`hostHint`/`basePath`/`setTokens`/ + // `loadConfig`/`loadIdps`/`ensureConfig`/`ensureFreshToken`/`chCtx`/ + // `receiveAuthHandoff`) were deleted in #276 Phase 5 — every consumer reads + // `app.conn.*` directly now. `showLogin`/`signOut` stay here: they compose + // rendering (`renderLoginApp`), not pure forwards. activeTab(): Tab; - isSignedIn(): boolean; - email(): string; - hostHint: string; - basePath: string; - setTokens(id: string, refresh?: string): void; - /** The real resolved shape (net/oauth-config.ts's `ResolvedIdpConfig`) — the - * previous opaque `Json` undersold it; main.js's OAuth-callback path reads - * `cfg.bearer` straight off this value. */ - loadConfig(): Promise; - /** The real resolved shape (net/oauth-config.ts's `ConfigDoc`) — the - * previous `{ idps: Array<{ id: string }> }` undersold it (no - * `basicLogin`/`hosts`, and each `idps[]` entry is a full `IdpDescriptor`, - * not just `{id}`); login.ts read the real shape all along behind its own - * `LoginIdpsResult` widening, now redundant (dropped there). */ - loadIdps(): Promise; - /** Same real shape as `loadConfig` (`null` when config couldn't be loaded — - * fail-soft, see app.ts's own `ensureConfig`). */ - ensureConfig(): Promise; - ensureFreshToken(): Promise; - chCtx: ChCtx; showLogin(msg?: string): void; signOut(): void; - receiveAuthHandoff(handoffEnv: { opener?: Window | null }): Promise; canExport(): boolean; canExportScript(): boolean; showSaveFilePicker: ((opts?: unknown) => Promise) | null; @@ -283,22 +263,12 @@ export interface App { // Data / schema loaders. /** The server-metadata/reference lifecycle service (#276 Phase 4A) — * `src/application/schema-catalog-service.ts`, constructible without - * App/AppState/DOM. The members below (`loadVersion`/`loadSchema`/ - * `loadReference`/`rebuildCompletions`/`entityDoc`/`docCache`/`refData`/ - * `completions`) are thin delegates to it, kept so no consumer needs to - * change (mirrors `exec`/`conn`/`workbench`'s own extraction pattern). */ + * App/AppState/DOM: `loadVersion`/`loadSchema`/`loadReference`/ + * `rebuildCompletions`/`entityDoc`/`docCache`/`refData`/`completions` all + * live on it now — the flat `App` delegates that used to forward onto + * them were deleted in #276 Phase 5; every consumer reads `app.catalog.*` + * directly. */ catalog: SchemaCatalogService; - loadVersion(): Promise; - loadSchema(): Promise; - loadReference(): Promise; - refData: { functions: unknown; keywordDocs: unknown } & Json; - completions: Json; - rebuildCompletions(): void; - /** A pending fetch while in flight; the resolved doc string once settled (a - * failed fetch, `null`, is dropped rather than cached — see entityDoc). */ - docCache: Map>; - /** Resolves to `null` on a failed fetch (not cached; retried next call). */ - entityDoc(name: string): Promise; updateBanner(): void; // Query-run / var-strip / editor-mode UI hooks. diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 7af5640..39b56ee 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -360,15 +360,15 @@ export function renderDashboard(app: App): Promise { const header = h('div', { class: 'dash-header' }, h('a', { - class: 'dash-back', href: app.basePath || '/sql', title: 'Back to SQL Browser', + class: 'dash-back', href: app.conn.basePath || '/sql', title: 'Back to SQL Browser', 'aria-label': 'Back to SQL Browser', }, Icon.arrow(), h('span', { class: 'dash-back-label' }, 'SQL Browser')), h('div', { class: 'dash-title' }, state.libraryName.value), favChip, skipNote, h('div', { class: 'dash-spacer', style: { flex: '1' } }), - h('span', { class: 'dash-chip dash-src', title: app.host() }, - h('span', { class: 'dash-dot' }), app.host()), + h('span', { class: 'dash-chip dash-src', title: app.conn.host() }, + h('span', { class: 'dash-dot' }), app.conn.host()), updated, themeBtn, refreshBtn); @@ -520,7 +520,7 @@ export function renderDashboard(app: App): Promise { } }, disposeFilterBar: disposeCurrentFilterBar, - onAuthFailed: () => { app.chCtx.onSignedOut(); }, + onAuthFailed: () => { app.conn.chCtx.onSignedOut(); }, onRunAllStart: () => { refreshBtn.disabled = true; }, onRunAllSettled: () => { updated.textContent = 'Updated ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); @@ -535,7 +535,7 @@ export function renderDashboard(app: App): Promise { const filterCuratedSeed: Record = state.filterCurated || {}; const deps: DashboardSessionDeps = { exec: app.exec, - ensureFreshToken: () => app.ensureFreshToken(), + ensureFreshToken: () => app.conn.ensureFreshToken(), now: () => app.now(), wallNow: () => app.wallNow(), recordBoundParams: (bp) => app.recordBoundParams(bp), diff --git a/src/ui/login.ts b/src/ui/login.ts index 3a6f5f9..605a840 100644 --- a/src/ui/login.ts +++ b/src/ui/login.ts @@ -15,21 +15,23 @@ import { h } from './dom.js'; import { Icon } from './icons.js'; import type { ActionsRegistry } from './app.types.js'; import type { ConfigDoc, HostDescriptor, IdpDescriptor } from '../net/oauth-config.js'; +import type { ConnectionSession } from '../application/connection-session.js'; /** The narrow slice of the real `app` controller this module reads — not the * full ~50-member `App` contract (app.types.ts). `root` is narrowed to a * non-null `Element` (vs. `App.root`'s `Element | null`): this module always * writes through it unconditionally, exactly as it already did pre-#262. - * `loadIdps` now matches `App.loadIdps`'s real resolved shape (oauth-config.ts's - * `ConfigDoc`) directly — #267 fixed the contract that used to undersell it - * as `{ idps: Array<{id}> }`, so the local `LoginIdpsResult` widening this - * module needed for that gap is gone. */ + * `conn.loadIdps` matches `ConnectionSession.loadIdps`'s real resolved shape + * (oauth-config.ts's `ConfigDoc`) directly — #267 fixed the contract that + * used to undersell it as `{ idps: Array<{id}> }`, so the local + * `LoginIdpsResult` widening this module needed for that gap is gone. + * `host`/`hostHint`/`loadIdps` moved onto `app.conn` in #276 Phase 5 (the + * flat `App` delegates were deleted); `showLogin` stays App-level — it + * composes rendering, not a pure forward. */ export interface LoginApp { root: Element; - host(): string; - hostHint?: string; + conn: Pick; actions: Pick; - loadIdps(): Promise; showLogin(msg?: string): void; } @@ -43,20 +45,20 @@ function errMsg(err: unknown): string { /** * Render the login screen into `app.root`. `app` provides: - * host() — the serving host (where SSO authenticates) + * conn.host() — the serving host (where SSO authenticates) * actions.login(id?) — start the OAuth flow for IdP `id` (async) * actions.connect({...}) — credential sign-in; renders the app on success - * loadIdps() — resolve { idps, basicLogin } (async) + * conn.loadIdps() — resolve { idps, basicLogin } (async) * showLogin(msg) — re-render with an error message */ export function renderLogin(app: LoginApp, errorMsg?: string): void { - const cur = app.host(); + const cur = app.conn.host(); let busy: 'sso' | 'creds' | null = null; // guards against double-submit let showPw = false; // A `?host=` URL param pre-fills the credential server address. A non-empty // host means credential-only (SSO can only target the serving host), so // Advanced opens and the SSO buttons disable. - const hostHint = app.hostHint || ''; + const hostHint = app.conn.hostHint || ''; let advOpen = !!hostHint; let ssoBtns: HTMLButtonElement[] = []; @@ -160,7 +162,7 @@ export function renderLogin(app: LoginApp, errorMsg?: string): void { // Resolve the configured IdPs (and the basic_login flag) and reconcile which // sections are shown. On failure keep credentials visible (fail-open — OAuth // can't work without config anyway) and show no SSO. - app.loadIdps().then(({ idps, basicLogin, hosts }) => { + app.conn.loadIdps().then(({ idps, basicLogin, hosts }) => { const credsShown = basicLogin !== false; if (!credsShown) credSection.remove(); populateHosts(hosts); diff --git a/src/ui/results.ts b/src/ui/results.ts index 066b59b..567fb06 100644 --- a/src/ui/results.ts +++ b/src/ui/results.ts @@ -36,6 +36,7 @@ import type { PanelResolution } from '../core/panel-cfg.js'; import type { ResultSource } from '../core/query-source.js'; import type { SchemaGraphFocus } from '../core/schema-graph.js'; import type { QueryExecutionService } from '../application/query-execution-service.js'; +import type { ConnectionSession } from '../application/connection-session.js'; // ── The Result contract (#267) ────────────────────────────────────────────── // `QueryTab.result` (state.ts) is deliberately opaque there @@ -157,7 +158,7 @@ export interface ResultsApp { now(): number; elapsedMs(): number; wallNow(): number; - ensureFreshToken(): Promise; + conn: Pick; /** The shared request/stream/normalize service (#276 Phase 1) — this module * only ever needs `executeRead` (the detached Data view's own re-run). */ exec: Pick; @@ -972,7 +973,7 @@ export function expandDataPane(app: ResultsApp, r: QueryResult): DetachedView { running = true; setStatus('Running…'); if (refreshBtn) refreshBtn.disabled = true; - if (!(await app.ensureFreshToken())) { + if (!(await app.conn.ensureFreshToken())) { if (myGen === gen && !closed) settle('Not signed in'); return; } diff --git a/src/ui/shortcuts.ts b/src/ui/shortcuts.ts index f1bd8e8..70b80e0 100644 --- a/src/ui/shortcuts.ts +++ b/src/ui/shortcuts.ts @@ -2,6 +2,7 @@ import { h, attachBackdropClose } from './dom.js'; import type { ActionsRegistry, State, Tab } from './app.types.js'; +import type { ConnectionSession } from '../application/connection-session.js'; /** The narrow slice of the real `app` controller this module reads — not * the full ~50-member `App` contract (app.types.ts). A real `App` satisfies @@ -11,7 +12,7 @@ import type { ActionsRegistry, State, Tab } from './app.types.js'; export interface ShortcutsApp { document?: Document; state: Pick; - isSignedIn(): boolean; + conn: Pick; activeTab(): Pick; actions: Pick< ActionsRegistry, @@ -103,7 +104,7 @@ export function handleKeydown(e: ShortcutKeydownEvent, app: ShortcutsApp): strin // trigger a global action like cancelling the running query. if (e.defaultPrevented) return null; const mod = e.metaKey || e.ctrlKey; - const signedIn = app.isSignedIn(); + const signedIn = app.conn.isSignedIn(); const editorMode = app.activeTab().editorMode || 'sql'; // Esc cancels an in-flight query (aborts the stream + KILL QUERY). if (e.key === 'Escape' && app.state.running.value) { diff --git a/tests/e2e/editor-cm6.spec.js b/tests/e2e/editor-cm6.spec.js index b2f28e9..cbf70f1 100644 --- a/tests/e2e/editor-cm6.spec.js +++ b/tests/e2e/editor-cm6.spec.js @@ -101,7 +101,7 @@ test.describe('CM6 editor', () => { // Seed the candidate pool with a column of `events` (as if its columns were // loaded) plus an unrelated table's column that must NOT surface for `e.`. await page.evaluate(() => { - window.__app.completions = window.__app.completions.concat([ + window.__app.catalog.completions = window.__app.catalog.completions.concat([ { label: 'user_id', kind: 'column', insert: 'user_id', detail: 'UInt64', parent: 'events' }, { label: 'other_col', kind: 'column', insert: 'other_col', detail: 'String', parent: 'unrelated' }, ]); diff --git a/tests/helpers/fake-app.ts b/tests/helpers/fake-app.ts index 9137af2..756f6da 100644 --- a/tests/helpers/fake-app.ts +++ b/tests/helpers/fake-app.ts @@ -15,9 +15,11 @@ // 3. `overrides` — generic in `O`, so a caller's own mock keeps its exact // call-site type (e.g. a 2-arg `exec.executeRead` spy) instead of // collapsing to `App`'s declared (argument-erased) method signature. -// `dom`/`chCtx`/`actions`/`exec` are nested objects, merged the same three-way -// (defaults, stubs, override) so a caller overriding e.g. `chCtx: { onSignedOut }` -// or `exec: { executeRead }` never has to re-spread the other sibling fields. +// `dom`/`conn`/`catalog`/`actions`/`exec` are nested objects, merged the same +// three-way (defaults, stubs, override) so a caller overriding e.g. +// `chCtx: { onSignedOut }` (feeds `conn.chCtx` — #276 Phase 5 deleted the flat +// `App.chCtx` alias) or `exec: { executeRead }` never has to re-spread the +// other sibling fields. import { vi } from 'vitest'; import dagre from '@dagrejs/dagre'; import { createState, activeTab } from '../../src/state.js'; @@ -44,19 +46,17 @@ import type { import { assembleReferenceData, buildCompletions } from '../../src/core/completions.js'; import type { AssembledReference } from '../../src/core/completions.js'; -// Production invariant (#276 Phase 2): `app.chCtx` and `app.conn.chCtx` are -// THE SAME live object (app.ts aliases `conn.chCtx`) — the defaults + the -// makeApp merge below preserve that identity so a fixture mutation through -// either alias observes shared state, exactly like the real app. +// `app.conn.chCtx`'s defaults (#276 Phase 2; Phase 5 deleted the flat +// `App.chCtx` alias this file used to also mirror it onto). const chCtxDefaults: ChCtx = { fetch, origin: '', authConfirmed: true, getToken: async () => null, refresh: async () => false, authHeader: () => '', onSignedOut: () => {}, }; -// A minimal `ConnectionSession` stub (#276 Phase 2) — every render-module test -// exercises `app.conn` only incidentally (through the `App`-level auth -// delegates it backs, e.g. `isSignedIn`/`email`/`ensureConfig`), never -// directly; this is inert, never-called-by-default plumbing so `appDefaults` +// A minimal `ConnectionSession` stub (#276 Phase 2) — most render-module +// tests read `app.conn.isSignedIn`/`.email`/`.ensureConfig`/etc. only +// incidentally, never directly; this is inert, never-called-by-default +// plumbing so `appDefaults` // still satisfies the full `App` contract. const connDefaults: ConnectionSession = { basePath: '', @@ -127,13 +127,11 @@ const workbenchDefaults: WorkbenchSession = { // A minimal `SchemaCatalogService` stub (#276 Phase 4A) — no render-module // fixture exercises the service directly (that's schema-catalog-service.test.ts's -// job); `App`'s own top-level `refData`/`completions`/`docCache`/`entityDoc`/ -// `loadVersion`/`loadSchema`/`loadReference` stubs below are independent of -// this (same dual pattern as `conn` vs. the top-level auth delegates) — this -// just satisfies the `App.catalog` contract. `refData`/`completions` use the -// real built-in fallback (`assembleReferenceData(null)`/`buildCompletions`) -// rather than a cast, so they stay structurally real `AssembledReference`/ -// `CompletionItem[]` values. +// job); this just satisfies the `App.catalog` contract (Phase 5 deleted the +// flat `App` delegates this file used to also mirror it onto). `refData`/ +// `completions` use the real built-in fallback (`assembleReferenceData(null)`/ +// `buildCompletions`) rather than a cast, so they stay structurally real +// `AssembledReference`/`CompletionItem[]` values. const catalogRefDataDefault: AssembledReference = assembleReferenceData(null); const catalogDefaults: SchemaCatalogService = { loadVersion: vi.fn(async () => {}), @@ -261,21 +259,9 @@ const appDefaults: App = { faviconHref: '', toggleTheme: () => {}, chart: undefined, - host: () => '', activeTab: () => ({}) as App['activeTab'] extends () => infer T ? T : never, - isSignedIn: () => true, - email: () => '', - hostHint: '', - basePath: '', - setTokens: () => {}, - loadConfig: async () => ({}) as ResolvedIdpConfig, - loadIdps: async () => ({ idps: [], basicLogin: true, hosts: [] }) as ConfigDoc, - ensureConfig: async () => null, - ensureFreshToken: async () => true, - chCtx: chCtxDefaults, showLogin: () => {}, signOut: () => {}, - receiveAuthHandoff: async () => false, canExport: () => false, canExportScript: () => false, showSaveFilePicker: null, @@ -295,14 +281,6 @@ const appDefaults: App = { recordHistory: () => {}, downloadFile: () => {}, editingLibrary: false, - loadVersion: async () => {}, - loadSchema: async () => {}, - loadReference: async () => {}, - refData: { functions: {}, keywordDocs: {} }, - completions: {}, - rebuildCompletions: () => {}, - docCache: new Map(), - entityDoc: async () => null, updateBanner: () => {}, wallNow: () => 0, now: () => 0, @@ -341,8 +319,11 @@ const appDefaults: App = { * alone only shallowly optionalizes App's OWN members, so `chCtx: { * onSignedOut }` (a caller overriding one ChCtx member, not the whole * service) would otherwise fail the constraint even though the nested merge - * below happily accepts a partial sub-object. */ -type AppOverrides = Partial> & { + * below happily accepts a partial sub-object. `chCtx` is a fake-app-only + * convenience key (#276 Phase 5 deleted `App.chCtx` — `app.conn.chCtx` is + * the only live alias now), kept so existing `makeApp({ chCtx: {...} })` + * call sites don't all need to become `conn: { chCtx: {...} }`. */ +type AppOverrides = Partial> & { dom?: Partial; chCtx?: Partial; actions?: Partial; @@ -352,9 +333,13 @@ type AppOverrides = Partial; /** Partial like the rest. `conn.chCtx` cannot be overridden here — the * merge below always re-points it at the shared merged `chCtx` object - * (the production identity invariant); override `chCtx` at the top level - * and both aliases see it. */ + * built from the top-level `chCtx` override above. */ conn?: Partial>; + /** Partial like `conn` above (#276 Phase 5 — `SchemaCatalogService` no + * longer has flat `App` delegates); most fixtures never touch it, a test + * asserting e.g. `catalog.loadSchema` was called can override just that + * method. */ + catalog?: Partial; /** Partial like the rest (#276 Phase 3a) — most fixtures never touch the * session directly; a test asserting `workbench.run`/`.cancel` was called * can override just that method. */ @@ -396,19 +381,30 @@ export function makeApp>(override Chart: FakeChart, Dagre: dagre, // real dagre — it's pure (no DOM), so tests use it directly cssVar: () => '', // blank → chartColors() uses its dark-theme fallbacks - host: () => 'test.host', build: 'v0.0.0-test', activeTab: () => activeTab(state), sqlEditor: createNoopPort(), specEditor: createNoopSpecEditor(), - isSignedIn: () => true, - // Dashboard (#149) surface: auth is resolved once before tiles fan out, the - // Back link derives from the SPA base, and onSignedOut redirects on failure. - ensureFreshToken: vi.fn(async () => true), - chCtx: { onSignedOut: vi.fn() }, - basePath: '/sql', + // Identity/auth (#276 Phase 5 — all live on `conn` now, no flat `App` + // delegates). Dashboard (#149) surface: auth is resolved once before + // tiles fan out, the Back link derives from the SPA base, and + // onSignedOut redirects on failure. + conn: { + host: () => 'test.host', + isSignedIn: () => true, + ensureFreshToken: vi.fn(async () => true), + basePath: '/sql', + email: () => 'me@example.com', + loadIdps: async (): Promise => ({ idps: [], basicLogin: true, hosts: [] }), + }, + // The server-metadata/reference lifecycle (#276 Phase 4A) — no flat `App` + // delegates (Phase 5 deleted them); `entityDoc` overridden per test (#27). + catalog: { + loadVersion: vi.fn(), + loadSchema: vi.fn(), + entityDoc: vi.fn(async () => ''), + }, toggleTheme: vi.fn(), - email: () => 'me@example.com', savePref: vi.fn(), saveVarValues: vi.fn(), saveFilterActive: vi.fn(), @@ -438,10 +434,6 @@ export function makeApp>(override wallNow: () => 0, // the #173 wave wall clock (epoch ms; fixed in tests) showLogin: vi.fn(), signOut: vi.fn(), - loadVersion: vi.fn(), - loadSchema: vi.fn(), - entityDoc: vi.fn(async () => ''), // lazy hover-doc loader (#27); overridden per test - loadIdps: async (): Promise => ({ idps: [], basicLogin: true, hosts: [] }), // Concrete (non-optional) elements — most consumers read these // unconditionally; a test that clears one back to `undefined` (a "no // mount point" guard) widens its own local read, same convention as @@ -495,18 +487,19 @@ export function makeApp>(override updateSaveBtn: vi.fn(), }, }; - // One merged chCtx object shared by BOTH aliases (app.chCtx and - // app.conn.chCtx) — the production identity invariant (#276 Phase 2). - const chCtx = { ...appDefaults.chCtx, ...base.chCtx, ...(overrides.chCtx ?? {}) }; + // `app.conn.chCtx` — built from the defaults + this fixture's own + // `onSignedOut` mock + a caller's top-level `chCtx` override (#276 Phase 5: + // no more flat `App.chCtx` alias to also mirror it onto). + const chCtx = { ...chCtxDefaults, onSignedOut: vi.fn(), ...(overrides.chCtx ?? {}) }; const merged = { ...appDefaults, ...base, ...overrides, dom: { ...appDefaults.dom, ...base.dom, ...(overrides.dom ?? {}) }, - chCtx, actions: { ...appDefaults.actions, ...base.actions, ...(overrides.actions ?? {}) }, exec: { ...appDefaults.exec, ...base.exec, ...(overrides.exec ?? {}) }, - conn: { ...connDefaults, ...(overrides.conn ?? {}), chCtx }, + conn: { ...connDefaults, ...base.conn, ...(overrides.conn ?? {}), chCtx }, + catalog: { ...catalogDefaults, ...base.catalog, ...(overrides.catalog ?? {}) }, workbench: { ...workbenchDefaults, ...(overrides.workbench ?? {}) }, params: { ...paramsDefaults, ...(overrides.params ?? {}) }, queryDoc: { ...queryDocDefaults, ...(overrides.queryDoc ?? {}) }, diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index d8c3920..de74bd5 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -237,8 +237,8 @@ const schemaOf = (app: App): SchemaDb[] => app.state.schema.value as SchemaDb[]; const asLegacyTab = (v: object): QueryTab => v as QueryTab; const asRefData = (v: object): AssembledReference => v as AssembledReference; const asCompletions = (v: object): CompletionItem[] => v as CompletionItem[]; -const refDataOf = (app: App): AssembledReference => asRefData(app.refData); -const completionsOf = (app: App): CompletionItem[] => asCompletions(app.completions); +const refDataOf = (app: App): AssembledReference => asRefData(app.catalog.refData); +const completionsOf = (app: App): CompletionItem[] => asCompletions(app.catalog.completions); // Matches results.test.ts / login.test.ts's own `qs`/`qsa` convention: every // selector below targets a real, already-rendered element a passing test @@ -358,9 +358,9 @@ describe('createApp basics', () => { it('reads the stored token and derives identity', () => { const app = createApp(env()); expect(app.conn.token()).toBe(validToken); - expect(app.isSignedIn()).toBe(true); - expect(app.email()).toBe('me@example.com'); - expect(app.host()).toBe('ch.example'); + expect(app.conn.isSignedIn()).toBe(true); + expect(app.conn.email()).toBe('me@example.com'); + expect(app.conn.host()).toBe('ch.example'); }); it('wires every document-toolbar control to its injected action', () => { const app = createApp(env()); @@ -387,12 +387,12 @@ describe('createApp basics', () => { }); it('host falls back when location.host is empty', () => { const app = createApp(env({ location: { host: '', origin: 'o', pathname: '/sql' } as Location })); - expect(app.host()).toBe('clickhouse'); + expect(app.conn.host()).toBe('clickhouse'); }); - it('reads the ?host= URL param into app.hostHint (empty when absent)', () => { - expect(createApp(env()).hostHint).toBe(''); + it('reads the ?host= URL param into app.conn.hostHint (empty when absent)', () => { + expect(createApp(env()).conn.hostHint).toBe(''); const app = createApp(env({ location: { host: 'h', origin: 'https://h', pathname: '/sql', search: '?host=antalya.demo:9000' } as Location })); - expect(app.hostHint).toBe('antalya.demo:9000'); + expect(app.conn.hostHint).toBe('antalya.demo:9000'); }); it('openWindow + stylesText seams resolve from env, from window.open, and from the page