@suluk/cockpit 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/drift.ts +0 -0
- package/src/index.ts +6 -0
- package/test/drift.test.ts +152 -0
- package/test/module-cycle.test.ts +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/cockpit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "The pure cockpit core (cycle model · builder model · codegen · deploy planning · validate/audit/preview) shared by the vscode extension and the /superadmin web admin panel. CANDIDATE tooling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/drift.ts
ADDED
|
Binary file
|
package/src/index.ts
CHANGED
|
@@ -10,3 +10,9 @@ export { buildBuilderModel, builderTree, entitiesFromDoc, generateAppFiles, gene
|
|
|
10
10
|
export { entityNames, generateForm, generateTable, generateStoresModule, exportV4Json } from "./codegen";
|
|
11
11
|
export { deployPlan, deployMarkdown } from "./deploy";
|
|
12
12
|
export type { DeployPlan, DeployStep, DeployProvider } from "@suluk/deploy";
|
|
13
|
+
// drift (OBSERVE): compare a LOCAL contract against a DEPLOYED one — the "what's drifted in prod" view (C020).
|
|
14
|
+
export { diffContracts, canonical, type ContractDiff, type ChangedOp, type OpRef } from "./drift";
|
|
15
|
+
// cost formatting, re-exported so the extension shell can render a live /cost ledger without a direct @suluk/cost dep.
|
|
16
|
+
export { formatMicroUsd, summarize, type CostSummary } from "@suluk/cost";
|
|
17
|
+
// modules (C021): install a contract fragment into the hub doc — the cockpit then re-projects it for free.
|
|
18
|
+
export { installModule, namespaceModule, ECOMMERCE, type SulukModule, type InstallResult } from "@suluk/builder";
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { parseDocument } from "@suluk/core";
|
|
3
|
+
import { diffContracts, canonical } from "../src/drift";
|
|
4
|
+
|
|
5
|
+
// A small but realistic v4 contract: two ops + one schema. We mutate copies of it to provoke each drift kind.
|
|
6
|
+
const BASE = `openapi: 4.0.0-candidate
|
|
7
|
+
info: { title: T, version: 1.0.0 }
|
|
8
|
+
paths:
|
|
9
|
+
"project":
|
|
10
|
+
requests:
|
|
11
|
+
listProject:
|
|
12
|
+
method: get
|
|
13
|
+
responses: { ok: { status: 200, contentType: application/json, contentSchema: { type: array, items: { $ref: "#/components/schemas/Project" } } } }
|
|
14
|
+
createProject:
|
|
15
|
+
method: post
|
|
16
|
+
security: [ { bearerAuth: [ "write:project" ] } ]
|
|
17
|
+
contentSchema: { $ref: "#/components/schemas/Project" }
|
|
18
|
+
responses: { created: { status: 201 } }
|
|
19
|
+
components:
|
|
20
|
+
schemas:
|
|
21
|
+
Project: { type: object, required: [ name ], properties: { id: { type: integer }, name: { type: string } } }
|
|
22
|
+
`;
|
|
23
|
+
const doc = (s: string) => parseDocument(s);
|
|
24
|
+
|
|
25
|
+
describe("diffContracts", () => {
|
|
26
|
+
test("identical contracts ⇒ no drift", () => {
|
|
27
|
+
const d = diffContracts(doc(BASE), doc(BASE));
|
|
28
|
+
expect(d.identical).toBe(true);
|
|
29
|
+
expect(d.summary).toContain("in sync");
|
|
30
|
+
expect(d.operations.added).toHaveLength(0);
|
|
31
|
+
expect(d.operations.changed).toHaveLength(0);
|
|
32
|
+
expect(d.schemas.changed).toHaveLength(0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("key reordering is NOT drift (canonical comparison)", () => {
|
|
36
|
+
const reordered = `openapi: 4.0.0-candidate
|
|
37
|
+
info: { version: 1.0.0, title: T }
|
|
38
|
+
paths:
|
|
39
|
+
"project":
|
|
40
|
+
requests:
|
|
41
|
+
listProject:
|
|
42
|
+
responses: { ok: { contentType: application/json, status: 200, contentSchema: { items: { $ref: "#/components/schemas/Project" }, type: array } } }
|
|
43
|
+
method: get
|
|
44
|
+
createProject:
|
|
45
|
+
responses: { created: { status: 201 } }
|
|
46
|
+
method: post
|
|
47
|
+
contentSchema: { $ref: "#/components/schemas/Project" }
|
|
48
|
+
security: [ { bearerAuth: [ "write:project" ] } ]
|
|
49
|
+
components:
|
|
50
|
+
schemas:
|
|
51
|
+
Project: { properties: { name: { type: string }, id: { type: integer } }, required: [ name ], type: object }
|
|
52
|
+
`;
|
|
53
|
+
expect(diffContracts(doc(BASE), doc(reordered)).identical).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("an op present locally but not deployed ⇒ added (not yet shipped)", () => {
|
|
57
|
+
// deployed = BASE without createProject
|
|
58
|
+
const deployed = BASE.replace(/ createProject:[\s\S]*?responses: \{ created: \{ status: 201 \} \}\n/, "");
|
|
59
|
+
const d = diffContracts(doc(BASE), doc(deployed));
|
|
60
|
+
expect(d.identical).toBe(false);
|
|
61
|
+
expect(d.operations.added.map((o) => o.name)).toEqual(["createProject"]);
|
|
62
|
+
expect(d.operations.added[0].detail).toBe("POST project");
|
|
63
|
+
expect(d.operations.removed).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("an op deployed but gone locally ⇒ removed (prod still serves it)", () => {
|
|
67
|
+
const local = BASE.replace(/ createProject:[\s\S]*?responses: \{ created: \{ status: 201 \} \}\n/, "");
|
|
68
|
+
const d = diffContracts(doc(local), doc(BASE));
|
|
69
|
+
expect(d.operations.removed.map((o) => o.name)).toEqual(["createProject"]);
|
|
70
|
+
expect(d.operations.added).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a changed method/scope/response ⇒ changed with named field deltas", () => {
|
|
74
|
+
// local bumps createProject to PUT, drops the scope, adds a 400 response
|
|
75
|
+
const local = BASE
|
|
76
|
+
.replace(" createProject:\n method: post", " createProject:\n method: put")
|
|
77
|
+
.replace(' security: [ { bearerAuth: [ "write:project" ] } ]\n', "")
|
|
78
|
+
.replace("responses: { created: { status: 201 } }", "responses: { created: { status: 201 }, bad: { status: 400 } }");
|
|
79
|
+
const d = diffContracts(doc(local), doc(BASE));
|
|
80
|
+
const c = d.operations.changed.find((o) => o.name === "createProject")!;
|
|
81
|
+
expect(c).toBeDefined();
|
|
82
|
+
expect(c.changes.some((x) => x.includes("method"))).toBe(true);
|
|
83
|
+
expect(c.changes.some((x) => x.includes("scopes"))).toBe(true);
|
|
84
|
+
expect(c.changes.some((x) => x.includes("responses"))).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("schema add / remove / change are tracked independently", () => {
|
|
88
|
+
const local = BASE
|
|
89
|
+
.replace("name: { type: string }", "name: { type: string }, slug: { type: string }") // change Project
|
|
90
|
+
.replace("components:\n schemas:", "components:\n schemas:\n Tag: { type: object, properties: { label: { type: string } } }"); // add Tag
|
|
91
|
+
const d = diffContracts(doc(local), doc(BASE));
|
|
92
|
+
expect(d.schemas.added).toContain("Tag");
|
|
93
|
+
expect(d.schemas.changed).toContain("Project");
|
|
94
|
+
expect(d.schemas.removed).toHaveLength(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// regression — operations named the same across DIFFERENT paths must not collide (the C009 name is unique
|
|
98
|
+
// only within one pathItem). Keying by bare name silently hid drift; identity is pathTemplate + name.
|
|
99
|
+
const TWOPATH = `openapi: 4.0.0-candidate
|
|
100
|
+
info: { title: T, version: 1.0.0 }
|
|
101
|
+
paths:
|
|
102
|
+
"project": { requests: { get: { method: get, responses: { ok: { status: 200 } } } } }
|
|
103
|
+
"user": { requests: { get: { method: get, responses: { ok: { status: 200 } } } } }
|
|
104
|
+
`;
|
|
105
|
+
test("cross-path same-name op REMOVED is reported (not masked by a sibling)", () => {
|
|
106
|
+
const deployed = TWOPATH.replace(/ "user":[\s\S]*\n/, ""); // drop the user path
|
|
107
|
+
const d = diffContracts(doc(TWOPATH), doc(deployed));
|
|
108
|
+
expect(d.identical).toBe(false);
|
|
109
|
+
expect(d.operations.added.map((o) => o.detail)).toContain("GET user");
|
|
110
|
+
// and the project/get sibling must NOT be falsely reported as changed
|
|
111
|
+
expect(d.operations.changed).toHaveLength(0);
|
|
112
|
+
});
|
|
113
|
+
test("cross-path same-name op CHANGED is not hidden by an identical sibling", () => {
|
|
114
|
+
// local drifts ONLY project/get (200→500); user/get stays identical
|
|
115
|
+
const local = TWOPATH.replace('"project": { requests: { get: { method: get, responses: { ok: { status: 200 } } } } }', '"project": { requests: { get: { method: get, responses: { ok: { status: 500 } } } } }');
|
|
116
|
+
const d = diffContracts(doc(local), doc(TWOPATH));
|
|
117
|
+
expect(d.identical).toBe(false); // must NOT report "in sync"
|
|
118
|
+
const c = d.operations.changed.filter((o) => o.detail === "GET project");
|
|
119
|
+
expect(c).toHaveLength(1);
|
|
120
|
+
expect(c[0].changes.some((x) => x.includes("responses"))).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
test("response status 200 (number) vs \"200\" (string) is NOT drift", () => {
|
|
123
|
+
const numStatus = `openapi: 4.0.0-candidate
|
|
124
|
+
info: { title: T, version: 1.0.0 }
|
|
125
|
+
paths: { "p": { requests: { g: { method: get, responses: { ok: { status: 200 } } } } } }
|
|
126
|
+
`;
|
|
127
|
+
const strStatus = numStatus.replace("status: 200", 'status: "200"');
|
|
128
|
+
expect(diffContracts(doc(numStatus), doc(strStatus)).identical).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
test("deeper response-body drift is caught even alongside a named change", () => {
|
|
131
|
+
const base = `openapi: 4.0.0-candidate
|
|
132
|
+
info: { title: T, version: 1.0.0 }
|
|
133
|
+
paths: { "p": { requests: { g: { method: get, responses: { ok: { status: 200 } } } } } }
|
|
134
|
+
`;
|
|
135
|
+
// local changes method AND adds a content schema to the same response
|
|
136
|
+
const local = base.replace("g: { method: get, responses: { ok: { status: 200 } } }", "g: { method: post, responses: { ok: { status: 200, contentSchema: { type: object } } } }");
|
|
137
|
+
const c = diffContracts(doc(local), doc(base)).operations.changed.find((o) => o.name === "g")!;
|
|
138
|
+
expect(c.changes.some((x) => x.includes("method"))).toBe(true);
|
|
139
|
+
expect(c.changes.some((x) => x.includes("shape"))).toBe(true); // the residual is not suppressed
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("canonical() is cycle-safe (does not overflow the stack)", () => {
|
|
143
|
+
const a: Record<string, unknown> = { x: 1 };
|
|
144
|
+
a.self = a;
|
|
145
|
+
expect(() => canonical(a)).not.toThrow();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("canonical() is stable under key permutation", () => {
|
|
149
|
+
expect(canonical({ b: 1, a: { y: 2, x: 3 } })).toBe(canonical({ a: { x: 3, y: 2 }, b: 1 }));
|
|
150
|
+
expect(canonical([1, 2, 3])).not.toBe(canonical([3, 2, 1])); // array order IS significant
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import type { OpenAPIv4Document, PathItem } from "@suluk/core";
|
|
3
|
+
import { installModule, ECOMMERCE } from "@suluk/builder";
|
|
4
|
+
import { buildCycle } from "../src/cycle";
|
|
5
|
+
|
|
6
|
+
// The whole point of a module being a contract fragment: after install, EVERY cockpit layer re-projects to
|
|
7
|
+
// include the new entities/operations/cost — with no change to the cockpit. This test is the witness.
|
|
8
|
+
const host = (): OpenAPIv4Document => ({
|
|
9
|
+
openapi: "4.0.0-candidate",
|
|
10
|
+
info: { title: "Host", version: "1.0.0" },
|
|
11
|
+
paths: { user: { requests: { listUser: { method: "get", responses: { ok: { status: 200 } } } } } } as Record<string, PathItem>,
|
|
12
|
+
components: { schemas: { User: { type: "object", properties: { id: { type: "integer" }, email: { type: "string" } } } } },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("installModule → the cockpit cycle lights up", () => {
|
|
16
|
+
const before = buildCycle(host());
|
|
17
|
+
const installed = installModule(host(), ECOMMERCE);
|
|
18
|
+
const after = buildCycle(installed.doc);
|
|
19
|
+
const layer = (m: typeof after, id: string) => m.layers.find((l) => l.id === id)!;
|
|
20
|
+
|
|
21
|
+
test("the host alone has only User", () => {
|
|
22
|
+
expect(layer(before, "data").items.map((i) => i.label)).toEqual(["User"]);
|
|
23
|
+
});
|
|
24
|
+
test("after install, the data layer gains Product + Order", () => {
|
|
25
|
+
const entities = layer(after, "data").items.map((i) => i.label).sort();
|
|
26
|
+
expect(entities).toEqual(["Order", "Product", "User"]);
|
|
27
|
+
});
|
|
28
|
+
test("the contract layer gains the module's operations (incl. checkout)", () => {
|
|
29
|
+
const ops = layer(after, "contract").items.map((i) => i.label);
|
|
30
|
+
expect(ops).toContain("listProduct");
|
|
31
|
+
expect(ops).toContain("createOrder");
|
|
32
|
+
expect(ops).toContain("checkoutOrder");
|
|
33
|
+
});
|
|
34
|
+
test("the cost layer prices the module's operations", () => {
|
|
35
|
+
const costed = layer(after, "cost").items.map((i) => i.label);
|
|
36
|
+
expect(costed).toContain("createOrder");
|
|
37
|
+
expect(costed).toContain("checkoutOrder");
|
|
38
|
+
});
|
|
39
|
+
test("the UI layer derives a form/table for the new entities", () => {
|
|
40
|
+
const ui = layer(after, "ui").items.map((i) => i.label).sort();
|
|
41
|
+
expect(ui).toEqual(["Order", "Product", "User"]);
|
|
42
|
+
});
|
|
43
|
+
test("the document still validates against the v4 meta-schema after the merge", () => {
|
|
44
|
+
expect(after.valid).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|