Skip to content

Commit 9212035

Browse files
test(cli): pin the Setup Integrations merged order to each contributor's priority (#28336)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao7HnHZJorpia6QZ5CZ4pABw)_ Fixes #28260 Refs #27238 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:新增一个测试,钉住 Setup → Integrations 分组里五个入口的顺序(Webhooks/Datasources → Connect an Agent → Connector Credentials → Cloud Connection),顺序从各插件真实的 `priority` 读出;另在真实运行的服务上走通了「安装应用 → 缺凭据 → 跳过 → 去凭据页填写 → 连接器无需重启即就绪」的全过程,记录贴在卡上。 - **为什么改**:#28200 的契约复审留了两条应修项:S1 要求在真实环境证明凭据闭环,S2 要求防止以后有人改一个数字就悄悄打乱管理员看到的菜单顺序。 - **风险与回滚**:只加一个测试文件,不动任何产品代码,不发布任何包;回滚即删除该文件。 - **先看**:`packages/cli/test/setup-integrations-order.pin.test.ts`(顺序断言与优先级读取两段)。 - **两分钟验证**:`pnpm --filter @objectstack/cli exec vitest run --project unit test/setup-integrations-order.pin.test.ts`,预期 1 passed;把 Connect an Agent 与 Connector Credentials 的 priority 对调后重建,同一命令变红。 - **需要你做的**:无,席位落地。 ## What changes One new test file, `packages/cli/test/setup-integrations-order.pin.test.ts` (S2 of the contract review on #28200). It composes the real Setup app shell and the five packages that contribute into `group_integrations`, folds them through the real `SchemaRegistry`, and asserts: - the merged order, tier by tier: `nav_webhooks` / `nav_http_deliveries` / `nav_datasources` (one priority, compared as a set because the fold's stable sort leaves that tie to registration order), then `nav_connect_agent`, then `nav_connector_credentials`, then `nav_cloud_connection`; - the priorities behind that order, **read from the contributions themselves** (no priority literal appears in the test): each tier shares one priority and every tier sorts strictly before the next, with the failure message naming the package that declares each side; - that nothing else contributes into the group, so a new entry is a deliberate edit of this pin. `@objectstack/plugin-webhooks` and `@objectstack/service-datasource` register their contribution from `init()`, so they are booted on a context serving only the `manifest` service, the same way `packages/cli/scripts/check-app-nav-i18n.mjs` boots them. The file uses `SchemaRegistry` directly (no `new ObjectQL(`), so it classifies into the `unit` tier. S1 (the live round trip) changes no file. Its record is posted on #28260. ## Tests - `pnpm --filter @objectstack/cli exec vitest run --project unit test/setup-integrations-order.pin.test.ts test/connect-agent-both-halves-wire.pin.test.ts test/vitest-tiers-partition.test.ts` at `98931dde`: 3 files, 25 tests passed. - Ablation (one-time, not kept): swapped the two priorities in source (`connector-credentials-ui.ts` 120 to 110, `connect-ui.ts` 110 to 120) with `scripts/ablation-replace.mjs`, rebuilt both packages, `ablation-dist-preflight.mjs` saw both markers in `dist/`, and the pin went red on the tier assertion (`expected Set{ 'nav_connector_credentials' } to deeply equal Set{ 'nav_connect_agent' }`). Restore proven (blob equals HEAD, `git diff HEAD` empty), both packages rebuilt, `--absent` preflight clean, pin green again. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` at `98931dde`: 358 files, 5136 tests passed. The `integration` tier is declared to CI (the diff touches no integration-tier file or spawn entry). - `pnpm --filter @objectstack/cli typecheck` (tsc plus `check:test-typecheck`): exit 0. ## Acceptance notes - **S1 ran on a probe package, not on HotCRM.** HotCRM declares no connector at all: `git grep -il credentialRef origin/main` over `objectstack-ai/hotcrm` at `a598256` returns nothing, and its own `content/docs/guides/integrations.mdx` says "`src/` carries no connector metadata of any kind". The Slack connector the decision card #27230 cites is a roadmap line in that page, so installing HotCRM would list no reference. The probe package (`com.example.slackprobe`) declares one `rest` connector whose bearer token is `credentialRef: SLACK_BOT_TOKEN`. - **The marketplace catalog was a local stand-in.** This container's proxy refuses `cloud.objectos.ai` and `marketplace.objectos.ai` (CONNECT 403), so the dev stack read its catalog through `OS_MARKETPLACE_PUBLIC_BASE_URL` pointed at a local server serving the snapshot shape (`packages.json`, `packages/ID.json`, `packages/ID/versions/latest/manifest.json`). Everything after that (the Console package page, `POST /api/v1/marketplace/install-local`, the credential step, the Setup page, the credential door, the automation service) is the real stack. - The live Setup sidebar served the Integrations group in exactly the order this pin holds: Webhooks, HTTP Deliveries, Datasources, Connect an Agent, Connector Credentials, Cloud Connection. - Observation, not filed: the Connector Credentials page's "Last changed" column shows the raw user id (`… by aPIvyNcbsR4QACN8KHZlCylmhRIImV7v`) rather than a name. carrier: none. ## Verification <details><summary>Derived gates: 48 derived, 48 run, all exit 0 (reconciled at 98931dd)</summary> `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran …`: "48 derived famil(ies) accounted for — 48 run, 0 NOT-MEASURED". `check-plugin-teardown-shape.mjs --self-test` first answered exit 3 (its fixture commit absent from the shallow clone); after `git fetch origin 621a487 --depth=1` it passed (48 cases). `node scripts/check-sdui-manifest.mjs`: exit 0. The record is `28260-gates-ran-98931dde.list` in the seat's out folder. </details> <details><summary>S1 live round trip (summary; the full record is on #28260)</summary> `pnpm dev -- --fresh -p 38517` (showcase), no connector env var set (`SLACK_BOT_TOKEN` unset), catalog stand-in on 38518. Install from the Console package page: `POST /api/v1/marketplace/install-local` `{"packageId":"slack_probe"}` answered 200 with `credentialRefs: [{ref: SLACK_BOT_TOKEN, status: missing}]`; the step listed `SLACK_BOT_TOKEN · slack_probe_api`; Skip showed "Skipped for now" with a link to `/_console/apps/com.objectstack.setup/component/automation/connector-credentials`; on that page `PUT …/credential-refs/SLACK_BOT_TOKEN` answered `status: set, source: object` with the connector `state: ready`; `GET /api/v1/automation/connectors` lists `slack_probe_api` `ready` with action `request`. Server pid 15400 before and after. The value appeared in no response body, no DOM text, no input after save, no reloaded page and no server log line. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qiby1xTh1tChY988SHhX)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent ab0c5df commit 9212035

1 file changed

Lines changed: 181 additions & 0 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The MERGED-ORDER pin for Setup → Integrations.
4+
//
5+
// ---------------------------------------------------------------------------
6+
// What this pins
7+
// ---------------------------------------------------------------------------
8+
// `SETUP_APP` declares `group_integrations` as an empty anchor (ADR-0029 D7);
9+
// every entry in it is contributed at runtime by the package that owns the
10+
// capability, and `SchemaRegistry.applyNavContributions` orders the
11+
// contributions by their `priority`, lowest first. Today that reads:
12+
//
13+
// Webhooks / HTTP Deliveries `@objectstack/plugin-webhooks`
14+
// Datasources `@objectstack/service-datasource`
15+
// Connect an Agent `@objectstack/mcp`
16+
// Connector Credentials `@objectstack/service-automation`
17+
// Cloud Connection `@objectstack/cloud-connection`
18+
//
19+
// Each contributor states the group's other priorities in its own header so
20+
// that its number sorts where it means to — and nothing held those headers to
21+
// the code. A later contributor could renumber one of the five and silently
22+
// reorder the menu an administrator reads. This file is that hold.
23+
//
24+
// ⛔ The priorities are READ from the real contributions, never copied here as
25+
// literals: a pin that restated `110` / `120` would pass beside a source that
26+
// had moved, as long as nobody re-ran the arithmetic. What the file does state
27+
// is the ORDER of the ids, which is the user-visible fact under protection.
28+
//
29+
// The first two contributors share one priority, so the fold's stable sort
30+
// leaves their relative order to registration order, which belongs to the
31+
// composition root's plugin list and not to any priority. That tier is pinned
32+
// as a set; every tier boundary is pinned as an order.
33+
//
34+
// ---------------------------------------------------------------------------
35+
// Why it lives in `packages/cli/test/`
36+
// ---------------------------------------------------------------------------
37+
// The same reason as `connect-agent-both-halves-wire.pin.test.ts` beside it:
38+
// this is a COMPOSED fact across five packages and the registry fold, and
39+
// `packages/cli` is the only workspace package that depends on all of them.
40+
//
41+
// Two of the five register their contribution from inside `init()` through
42+
// the `manifest` service rather than exporting it, so they are booted here on
43+
// a context that serves exactly that service — the shape
44+
// `packages/cli/scripts/check-app-nav-i18n.mjs` boots the same two with.
45+
// `@objectstack/service-automation` registers its bundle at `kernel:ready`,
46+
// which this context never fires, so its exported bundle is read directly, as
47+
// that gate does too.
48+
49+
import { describe, expect, it } from 'vitest';
50+
import { CLOUD_CONNECTION_UI_BUNDLE } from '@objectstack/cloud-connection';
51+
import { CONNECT_AGENT_UI_BUNDLE } from '@objectstack/mcp';
52+
import { SchemaRegistry } from '@objectstack/objectql';
53+
import { SETUP_APP, SETUP_NAV_CONTRIBUTIONS } from '@objectstack/platform-objects/apps';
54+
import { WebhookOutboxPlugin } from '@objectstack/plugin-webhooks';
55+
import { CONNECTOR_CREDENTIALS_UI_BUNDLE } from '@objectstack/service-automation';
56+
import { DatasourceAdminServicePlugin } from '@objectstack/service-datasource';
57+
58+
type AnyRec = Record<string, any>;
59+
60+
const GROUP = 'group_integrations';
61+
62+
/**
63+
* The group, in the order an administrator reads it. Each inner array is one
64+
* priority tier; ids inside a tier are compared as a set (see the header).
65+
*/
66+
const EXPECTED_TIERS: readonly (readonly string[])[] = [
67+
['nav_webhooks', 'nav_http_deliveries', 'nav_datasources'],
68+
['nav_connect_agent'],
69+
['nav_connector_credentials'],
70+
['nav_cloud_connection'],
71+
];
72+
73+
/**
74+
* The manifests a plugin registers from `init()`, captured off a context that
75+
* serves the `manifest` service and inert, complete stand-ins for the rest of
76+
* the interface (`packages/core/src/types.ts`), so a missing member cannot be
77+
* misread as a plugin that contributes nothing.
78+
*/
79+
async function manifestsRegisteredBy(plugin: { init(ctx: any): Promise<void> | void }): Promise<AnyRec[]> {
80+
const manifests: AnyRec[] = [];
81+
const ctx = {
82+
getService: (name: string) => (name === 'manifest' ? { register: (m: AnyRec) => manifests.push(m) } : undefined),
83+
registerService: () => {},
84+
registerServiceFactory: () => {},
85+
replaceService: () => {},
86+
getServiceScoped: async () => undefined,
87+
getServices: () => new Map(),
88+
hook: () => {},
89+
trigger: async () => {},
90+
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
91+
getKernel: () => undefined,
92+
};
93+
await plugin.init(ctx);
94+
return manifests;
95+
}
96+
97+
/** Every Setup contribution the composition carries, tagged with its package. */
98+
async function setupContributions(): Promise<Array<{ packageId: string; contribution: AnyRec }>> {
99+
const sources: Array<[string, AnyRec[]]> = [
100+
['@objectstack/plugin-webhooks', await manifestsRegisteredBy(new WebhookOutboxPlugin({ autoEnqueue: false }))],
101+
['@objectstack/service-datasource', await manifestsRegisteredBy(new DatasourceAdminServicePlugin({}) as any)],
102+
['@objectstack/mcp', [CONNECT_AGENT_UI_BUNDLE as AnyRec]],
103+
['@objectstack/service-automation', [CONNECTOR_CREDENTIALS_UI_BUNDLE as AnyRec]],
104+
['@objectstack/cloud-connection', [CLOUD_CONNECTION_UI_BUNDLE as AnyRec]],
105+
];
106+
const out: Array<{ packageId: string; contribution: AnyRec }> = SETUP_NAV_CONTRIBUTIONS.map((contribution) => ({
107+
packageId: '@objectstack/platform-objects',
108+
contribution: contribution as AnyRec,
109+
}));
110+
for (const [packageId, manifests] of sources) {
111+
for (const manifest of manifests) {
112+
for (const contribution of manifest?.navigationContributions ?? []) {
113+
if (contribution?.app === 'setup') out.push({ packageId, contribution });
114+
}
115+
}
116+
}
117+
return out;
118+
}
119+
120+
/** The children of one nav group in the merged app, by id. */
121+
function groupChildren(app: AnyRec | undefined, groupId: string): string[] {
122+
const group = (app?.navigation ?? []).find((item: AnyRec) => item?.id === groupId);
123+
return (group?.children ?? []).map((child: AnyRec) => String(child?.id));
124+
}
125+
126+
describe('Setup → Integrations: the merged order follows each contributor\'s priority', () => {
127+
it('serves the five contributors in priority order, read from the contributions themselves', async () => {
128+
const contributions = await setupContributions();
129+
130+
// `structuredClone`: `registerApp` writes the `_lock` envelope onto
131+
// what it is handed (ADR-0010 §3.7); the module constant stays clean.
132+
const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
133+
(registry as AnyRec).logLevel = 'silent';
134+
registry.registerApp(structuredClone(SETUP_APP), '@objectstack/platform-objects');
135+
for (const { packageId, contribution } of contributions) {
136+
registry.registerAppNavContribution(contribution as any, packageId);
137+
}
138+
139+
const merged = groupChildren(registry.getApp('setup'), GROUP);
140+
141+
// ── The order an administrator reads ──────────────────────────────
142+
expect(merged).toHaveLength(EXPECTED_TIERS.flat().length);
143+
let offset = 0;
144+
for (const tier of EXPECTED_TIERS) {
145+
expect(new Set(merged.slice(offset, offset + tier.length))).toEqual(new Set(tier));
146+
offset += tier.length;
147+
}
148+
149+
// ── The priorities behind it, read where they are declared ────────
150+
// Every id in the group, mapped to the priority of the contribution
151+
// that carries it — and to that contribution's package, so a red names
152+
// who moved.
153+
const declared = new Map<string, { priority: number; packageId: string }>();
154+
for (const { packageId, contribution } of contributions) {
155+
if (contribution.group !== GROUP) continue;
156+
for (const item of contribution.items ?? []) {
157+
declared.set(String(item.id), { priority: contribution.priority, packageId });
158+
}
159+
}
160+
// Nothing contributes into the group that this pin does not order.
161+
expect([...declared.keys()].sort()).toEqual([...EXPECTED_TIERS.flat()].sort());
162+
163+
const tierPriority = EXPECTED_TIERS.map((tier) => {
164+
const priorities = new Set(tier.map((id) => declared.get(id)?.priority));
165+
expect(priorities.size, `${tier.join(', ')} share one priority`).toBe(1);
166+
const [priority] = priorities;
167+
expect(typeof priority, `${tier.join(', ')} declare a numeric priority`).toBe('number');
168+
return { tier, priority: priority as number };
169+
});
170+
for (let i = 1; i < tierPriority.length; i += 1) {
171+
const before = tierPriority[i - 1];
172+
const after = tierPriority[i];
173+
const owner = (id: string) => declared.get(id)?.packageId;
174+
expect(
175+
before.priority < after.priority,
176+
`${before.tier[0]} (${owner(before.tier[0])}, ${before.priority}) sorts before ` +
177+
`${after.tier[0]} (${owner(after.tier[0])}, ${after.priority})`,
178+
).toBe(true);
179+
}
180+
});
181+
});

0 commit comments

Comments
 (0)