|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #20142 — the Integrations & APIs page, one of the console pages that lost |
| 4 | +// their only in-app link when objectui#10520 retired the Developer Hub, read |
| 5 | +// back OVER THE WIRE from the composed Studio app. |
| 6 | +// |
| 7 | +// --------------------------------------------------------------------------- |
| 8 | +// What this pins |
| 9 | +// --------------------------------------------------------------------------- |
| 10 | +// The card's acceptance: "each entry resolves to its registered page (a |
| 11 | +// `componentRef` that is registered)". For the entry |
| 12 | +// `@objectstack/platform-objects` declares — `nav_integrations` → |
| 13 | +// `developer:integrations`, last in Studio's `group_developer` — this file |
| 14 | +// reads it from the served body of `GET /api/v1/meta/:type/:name`, after the |
| 15 | +// real `SchemaRegistry` registration and the real per-request filter, so a |
| 16 | +// regression anywhere between the declaration and the wire turns it red. |
| 17 | +// |
| 18 | +// The other two pages are pinned where they belong: |
| 19 | +// - `nav_audit_log_browser` → `audit:log` is contributed by |
| 20 | +// `@objectstack/plugin-audit`; its ref is pinned beside it in |
| 21 | +// `packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts`. |
| 22 | +// Importing that plugin from this package's tests would resolve to its |
| 23 | +// `dist/`, which `check:test-source-alias` refuses for a new import. |
| 24 | +// - `ai:approvals` is not this repository's entry: ADR-0029 D7 has each |
| 25 | +// capability plugin contribute its own navigation, and the `ai` |
| 26 | +// capability's owner is `@objectstack/service-ai` in Cloud/Enterprise. |
| 27 | +// |
| 28 | +// "Registered" is judged against the keys MEASURED at the commit |
| 29 | +// objectstack's `.objectui-sha` pins (dd3f7e1be3561d63267d7162f3fc0ac52e72834d): |
| 30 | +// `registerDeveloperComponents.tsx` registers `developer:integrations` and |
| 31 | +// `registerSystemComponents.tsx` registers `audit:log`. objectui pins its |
| 32 | +// registration half in |
| 33 | +// `apps/console/src/__tests__/orphanedPageComponentRefs-10520.test.tsx`. |
| 34 | +// |
| 35 | +// It lives in `packages/cli/test/` for the reason its sibling |
| 36 | +// `connect-agent-both-halves-wire.pin.test.ts` states: `cli` is the one |
| 37 | +// workspace package that depends on every piece at once — the app shells |
| 38 | +// (`@objectstack/platform-objects`), the registry (`@objectstack/objectql`) and |
| 39 | +// the per-request filter (`@objectstack/rest`). |
| 40 | + |
| 41 | +import { describe, expect, it, vi } from 'vitest'; |
| 42 | +import { SchemaRegistry } from '@objectstack/objectql'; |
| 43 | +import { STUDIO_APP } from '@objectstack/platform-objects/apps'; |
| 44 | +import { RestServer } from '@objectstack/rest'; |
| 45 | + |
| 46 | +type AnyRec = Record<string, any>; |
| 47 | + |
| 48 | +/** The registry keys the pinned console registers for the pages this repo links. */ |
| 49 | +const MEASURED_CONSOLE_KEYS = ['audit:log', 'developer:integrations']; |
| 50 | + |
| 51 | +/** |
| 52 | + * The real registration of the Studio shell. `structuredClone` because |
| 53 | + * `registerItem` writes the `_lock` envelope onto what it is handed |
| 54 | + * (ADR-0010 §3.7). |
| 55 | + */ |
| 56 | +function composedRegistry(): SchemaRegistry { |
| 57 | + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); |
| 58 | + (registry as AnyRec).logLevel = 'silent'; |
| 59 | + registry.registerApp(structuredClone(STUDIO_APP), '@objectstack/platform-objects'); |
| 60 | + return registry; |
| 61 | +} |
| 62 | + |
| 63 | +function createMockServer() { |
| 64 | + return { |
| 65 | + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), |
| 66 | + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), |
| 67 | + }; |
| 68 | +} |
| 69 | + |
| 70 | +function makeRes() { |
| 71 | + const res: AnyRec = { statusCode: 200, body: undefined }; |
| 72 | + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); |
| 73 | + res.json = vi.fn((b: unknown) => { res.body = b; return res; }); |
| 74 | + res.header = vi.fn(); res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); |
| 75 | + return res; |
| 76 | +} |
| 77 | + |
| 78 | +/** A `RestServer` serving the composed Studio app to a caller holding `studio.access`. */ |
| 79 | +function serve() { |
| 80 | + const registry = composedRegistry(); |
| 81 | + const protocol: AnyRec = { |
| 82 | + getDiscovery: vi.fn().mockResolvedValue({ |
| 83 | + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, |
| 84 | + }), |
| 85 | + getMetaTypes: vi.fn().mockResolvedValue([]), |
| 86 | + getMetaItems: vi.fn(async ({ type }: AnyRec) => { |
| 87 | + const t = String(type ?? ''); |
| 88 | + return t === 'app' || t === 'apps' ? registry.getAllApps() : []; |
| 89 | + }), |
| 90 | + getMetaItem: vi.fn(async ({ name }: AnyRec) => { |
| 91 | + const item = registry.getApp(String(name)); |
| 92 | + return item ? { type: 'app', name, item } : undefined; |
| 93 | + }), |
| 94 | + findData: vi.fn().mockResolvedValue([]), |
| 95 | + }; |
| 96 | + const rest: AnyRec = new RestServer( |
| 97 | + createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any, |
| 98 | + ); |
| 99 | + rest.resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: ['studio.access'] }); |
| 100 | + rest.registerRoutes(); |
| 101 | + return rest; |
| 102 | +} |
| 103 | + |
| 104 | +/** `GET /api/v1/meta/apps/:name`, answered as `{ statusCode, body }`. */ |
| 105 | +async function getApp(rest: AnyRec, name: string) { |
| 106 | + const route = rest.getRoutes().find( |
| 107 | + (r: AnyRec) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name', |
| 108 | + ); |
| 109 | + if (!route) throw new Error('meta/:type/:name route not registered'); |
| 110 | + const res = makeRes(); |
| 111 | + await route.handler( |
| 112 | + { method: 'GET', params: { type: 'apps', name }, query: {}, body: {}, headers: {} }, res, |
| 113 | + ); |
| 114 | + return res; |
| 115 | +} |
| 116 | + |
| 117 | +/** The served children of one nav group, or `undefined` when the group is not served. */ |
| 118 | +function group(app: AnyRec | undefined, groupId: string): AnyRec[] | undefined { |
| 119 | + const found = ((app?.navigation ?? []) as AnyRec[]).find((g) => g?.id === groupId); |
| 120 | + return found ? ((found.children ?? []) as AnyRec[]) : undefined; |
| 121 | +} |
| 122 | + |
| 123 | +const ids = (items: AnyRec[] | undefined) => (items ?? []).map((i) => String(i?.id)); |
| 124 | + |
| 125 | +describe('#20142 — the Integrations & APIs nav entry, served from the composed Studio app', () => { |
| 126 | + it('developer:integrations is served last in Studio\'s Developer group', async () => { |
| 127 | + const studio = await getApp(serve(), 'studio'); |
| 128 | + expect(studio.statusCode).toBe(200); |
| 129 | + const developer = group(studio.body?.item, 'group_developer'); |
| 130 | + expect(ids(developer)).toEqual([ |
| 131 | + 'nav_api_console', 'nav_flow_runs', 'nav_public_forms', 'nav_integrations', |
| 132 | + ]); |
| 133 | + expect(developer?.at(-1)).toMatchObject({ type: 'component', componentRef: 'developer:integrations' }); |
| 134 | + }); |
| 135 | + |
| 136 | + it('the served ref is a key the pinned console registers', async () => { |
| 137 | + const studio = await getApp(serve(), 'studio'); |
| 138 | + const served = ((studio.body?.item?.navigation ?? []) as AnyRec[]) |
| 139 | + .flatMap((g) => (g?.children ?? []) as AnyRec[]) |
| 140 | + .filter((i) => i?.id === 'nav_integrations'); |
| 141 | + expect(served).toHaveLength(1); |
| 142 | + expect(MEASURED_CONSOLE_KEYS).toContain(served[0].componentRef); |
| 143 | + }); |
| 144 | +}); |
0 commit comments