|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// GOLDEN REGRESSION — a connector an install-local package declares is live |
| 4 | +// again after the runtime restarts on the same install ledger. |
| 5 | +// |
| 6 | +// ## What was measured before the fix |
| 7 | +// |
| 8 | +// Two showcase boots over one install-ledger directory. The first boot |
| 9 | +// installed an inline manifest declaring a provider-bound `openapi` connector |
| 10 | +// whose `providerConfig.spec` is an INLINE document, plus one flow. The |
| 11 | +// connector was listed `ready` on `GET /automation/connectors`. After the |
| 12 | +// second boot, `GET /marketplace/install-local` listed the package and the |
| 13 | +// engine served its flow (`GET /automation/NAME` 200), but the connector was |
| 14 | +// absent from `GET /automation/connectors`. |
| 15 | +// |
| 16 | +// Flows survive because the engine binds them in its own `kernel:ready` |
| 17 | +// handler, from the protocol's flow view, and the install-local rehydrate |
| 18 | +// registers the ledger's packages in an earlier `kernel:ready` handler. |
| 19 | +// Declared connectors are reconciled only in `start()`, before |
| 20 | +// `kernel:ready`, and again on `metadata:reloaded` — and the rehydrate |
| 21 | +// announced no reload, so nothing read the rehydrated connector entries. |
| 22 | +// |
| 23 | +// ## What this file pins |
| 24 | +// |
| 25 | +// 1. before the restart (precondition): the install answers 200 and the |
| 26 | +// connector is listed `ready` with the document's one operation; |
| 27 | +// 2. after the restart: the package is listed by the install-local GET; |
| 28 | +// 3. after the restart: the connector is listed `ready` with the same |
| 29 | +// operation; |
| 30 | +// 4. after the restart: the package's flow is served by the engine; |
| 31 | +// 5. after the restart: a connector the HOST declares (the showcase's |
| 32 | +// `showcase_status_openapi`) is still listed (control). |
| 33 | +// |
| 34 | +// Boots fixture stacks of its own (the install-local plugin mounted), so it |
| 35 | +// stays out of `SHARED_SHOWCASE`. |
| 36 | + |
| 37 | +import { mkdtempSync, rmSync } from 'node:fs'; |
| 38 | +import { tmpdir } from 'node:os'; |
| 39 | +import { join } from 'node:path'; |
| 40 | + |
| 41 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 42 | +import { type VerifyStack } from '@objectstack/verify'; |
| 43 | +import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; |
| 44 | +import { bootShowcase } from './showcase-boot.js'; |
| 45 | + |
| 46 | +const ROUTE = '/marketplace/install-local'; |
| 47 | +const PACKAGE_ID = 'com.dogfood.restartconn'; |
| 48 | +const CONNECTOR = 'rstconn_fixture'; |
| 49 | +const FLOW = 'rstconn_manual_probe'; |
| 50 | +const HOST_CONNECTOR = 'showcase_status_openapi'; |
| 51 | +/** A one-operation OpenAPI 3 document; nothing is dispatched, so its server answers nothing. */ |
| 52 | +const DOCUMENT = { |
| 53 | + openapi: '3.0.3', |
| 54 | + info: { title: 'Restart connector fixture', version: '1.0.0' }, |
| 55 | + servers: [{ url: 'http://127.0.0.1:9/' }], |
| 56 | + paths: { '/v1/things': { get: { operationId: 'GetThings', responses: { '200': { description: 'ok' } } } } }, |
| 57 | +}; |
| 58 | + |
| 59 | +const INSTALL_BODY = { |
| 60 | + manifest: { |
| 61 | + id: PACKAGE_ID, |
| 62 | + namespace: 'rstconn', |
| 63 | + name: 'Restart connector probe', |
| 64 | + version: '0.1.0', |
| 65 | + type: 'app', |
| 66 | + connectors: [ |
| 67 | + { name: CONNECTOR, label: 'Fixture', type: 'api', provider: 'openapi', providerConfig: { spec: DOCUMENT } }, |
| 68 | + ], |
| 69 | + flows: [ |
| 70 | + { |
| 71 | + name: FLOW, |
| 72 | + label: 'Restart probe (run by name)', |
| 73 | + type: 'autolaunched', |
| 74 | + status: 'active', |
| 75 | + nodes: [ |
| 76 | + { id: 'start', type: 'start', label: 'Start' }, |
| 77 | + { id: 'end', type: 'end', label: 'End' }, |
| 78 | + ], |
| 79 | + edges: [{ id: 'e1', source: 'start', target: 'end' }], |
| 80 | + }, |
| 81 | + ], |
| 82 | + }, |
| 83 | +}; |
| 84 | + |
| 85 | +type Answer = { status: number; body: any }; |
| 86 | + |
| 87 | +async function json(res: Response): Promise<Answer> { |
| 88 | + return { status: res.status, body: await res.json().catch(() => null) }; |
| 89 | +} |
| 90 | + |
| 91 | +const listed = (answer: Answer, name: string): any => |
| 92 | + (answer.body?.data?.connectors ?? []).find((c: any) => c?.name === name); |
| 93 | + |
| 94 | +const names = (answer: Answer): unknown => answer.body?.data?.connectors?.map((c: any) => c?.name); |
| 95 | + |
| 96 | +/** Read the connector listing until `name` is in it, or the window closes. */ |
| 97 | +async function readConnectors(stack: VerifyStack, token: string, name: string): Promise<Answer> { |
| 98 | + let answer: Answer; |
| 99 | + const deadline = Date.now() + 10_000; |
| 100 | + do { |
| 101 | + answer = await json(await stack.apiAs(token, 'GET', '/automation/connectors')); |
| 102 | + if (listed(answer, name)) break; |
| 103 | + await new Promise((r) => setTimeout(r, 100)); |
| 104 | + } while (Date.now() < deadline); |
| 105 | + return answer; |
| 106 | +} |
| 107 | + |
| 108 | +describe('dogfood: an install-local package\'s connector is live again after a restart on the same ledger', () => { |
| 109 | + let storageDir: string; |
| 110 | + let install: Answer; |
| 111 | + let before: Answer; |
| 112 | + let packages: Answer; |
| 113 | + let after: Answer; |
| 114 | + let flowAfter: Answer; |
| 115 | + let bootError: unknown; |
| 116 | + |
| 117 | + beforeAll(async () => { |
| 118 | + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-restart-conn-')); |
| 119 | + const first = await bootShowcase({ |
| 120 | + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], |
| 121 | + }); |
| 122 | + try { |
| 123 | + const token = await first.signIn(); |
| 124 | + install = await json(await first.apiAs(token, 'POST', ROUTE, INSTALL_BODY)); |
| 125 | + before = await readConnectors(first, token, CONNECTOR); |
| 126 | + } finally { |
| 127 | + await first.stop(); |
| 128 | + } |
| 129 | + |
| 130 | + let second: VerifyStack | undefined; |
| 131 | + try { |
| 132 | + second = await bootShowcase({ |
| 133 | + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], |
| 134 | + }); |
| 135 | + } catch (err) { |
| 136 | + bootError = err; |
| 137 | + return; |
| 138 | + } |
| 139 | + try { |
| 140 | + const token = await second.signIn(); |
| 141 | + packages = await json(await second.apiAs(token, 'GET', ROUTE)); |
| 142 | + after = await readConnectors(second, token, CONNECTOR); |
| 143 | + flowAfter = await json(await second.apiAs(token, 'GET', `/automation/${FLOW}`)); |
| 144 | + } finally { |
| 145 | + await second.stop(); |
| 146 | + } |
| 147 | + }, 300_000); |
| 148 | + |
| 149 | + afterAll(() => { |
| 150 | + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); |
| 151 | + }); |
| 152 | + |
| 153 | + it('before the restart, the install lists the connector ready (precondition)', () => { |
| 154 | + expect(install.status, JSON.stringify(install.body)).toBe(200); |
| 155 | + expect(install.body?.data?.hotLoaded).toBe(true); |
| 156 | + const entry = listed(before, CONNECTOR); |
| 157 | + expect(entry, JSON.stringify(names(before))).toBeDefined(); |
| 158 | + expect(entry.state).toBe('ready'); |
| 159 | + expect(entry.actions.map((a: any) => a.key)).toEqual(['GetThings']); |
| 160 | + }); |
| 161 | + |
| 162 | + it('the second boot over the same ledger succeeds and lists the package', () => { |
| 163 | + expect(bootError).toBeUndefined(); |
| 164 | + expect(packages.status, JSON.stringify(packages.body)).toBe(200); |
| 165 | + const item = (packages.body?.data?.items ?? []).find((i: any) => i?.manifestId === PACKAGE_ID); |
| 166 | + expect(item, JSON.stringify(packages.body)).toBeDefined(); |
| 167 | + expect(item.notLoaded).toBeUndefined(); |
| 168 | + }); |
| 169 | + |
| 170 | + it('after the restart, the package\'s connector is listed ready with the document\'s operation', () => { |
| 171 | + expect(after.status, JSON.stringify(after.body)).toBe(200); |
| 172 | + const entry = listed(after, CONNECTOR); |
| 173 | + expect(entry, JSON.stringify(names(after))).toBeDefined(); |
| 174 | + expect(entry.state).toBe('ready'); |
| 175 | + expect(entry.actions.map((a: any) => a.key)).toEqual(['GetThings']); |
| 176 | + }); |
| 177 | + |
| 178 | + it('after the restart, the engine serves the package\'s flow', () => { |
| 179 | + expect(flowAfter.status, JSON.stringify(flowAfter.body)).toBe(200); |
| 180 | + }); |
| 181 | + |
| 182 | + it('after the restart, a connector the host declares is still listed (control)', () => { |
| 183 | + expect(listed(after, HOST_CONNECTOR), JSON.stringify(names(after))).toBeDefined(); |
| 184 | + }); |
| 185 | +}); |
0 commit comments