rastack 0.0.57 → 0.0.59
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/CHANGELOG.md +11 -0
- package/README.md +190 -0
- package/admin/index.tsx +19 -13
- package/admin/ui/BootScreen.tsx +201 -0
- package/dist/admin.js +156 -18
- package/dist/deploy/ci.js +2 -2
- package/dist/deploy/io.js +2 -2
- package/dist/dev/harness.d.ts +1 -1
- package/dist/dev/harness.js +21 -5
- package/dist/external/cache.d.ts +28 -0
- package/dist/external/cache.js +35 -0
- package/dist/external/envelope.d.ts +35 -0
- package/dist/external/envelope.js +48 -0
- package/dist/external/graphql.d.ts +25 -0
- package/dist/external/graphql.js +78 -0
- package/dist/external/handler.d.ts +52 -0
- package/dist/external/handler.js +139 -0
- package/dist/external/index.d.ts +19 -0
- package/dist/external/index.js +38 -0
- package/dist/external/lambda.d.ts +49 -0
- package/dist/external/lambda.js +86 -0
- package/dist/external/rest.d.ts +17 -0
- package/dist/external/rest.js +46 -0
- package/dist/external/route.d.ts +12 -0
- package/dist/external/route.js +33 -0
- package/dist/external/service.d.ts +50 -0
- package/dist/external/service.js +59 -0
- package/dist/external/types.d.ts +107 -0
- package/dist/external/types.js +22 -0
- package/dist/external/util.d.ts +16 -0
- package/dist/external/util.js +58 -0
- package/dist/rastack-dev.js +200 -4
- package/dist/rastack-wasm-build.d.ts +1 -1
- package/dist/rastack-wasm-build.js +6 -6
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/external.ts +10 -0
- package/hooks/query/delete.ts +12 -11
- package/hooks/query/fetch.ts +6 -8
- package/hooks/query/interfaces.ts +1 -4
- package/hooks/query/list.ts +16 -9
- package/hooks/query/update.ts +8 -6
- package/package.json +25 -2
- package/scripts/copy-templates.js +1 -1
- package/scripts/copy-wasm.js +1 -1
- package/scripts/dev-install.mjs +63 -0
- package/src/deploy/ci.ts +2 -2
- package/src/deploy/io.ts +2 -2
- package/src/dev/harness.ts +21 -5
- package/src/external/cache.ts +53 -0
- package/src/external/envelope.ts +78 -0
- package/src/external/graphql.ts +98 -0
- package/src/external/handler.ts +207 -0
- package/src/external/index.ts +25 -0
- package/src/external/lambda.ts +108 -0
- package/src/external/rest.ts +60 -0
- package/src/external/route.ts +33 -0
- package/src/external/service.ts +86 -0
- package/src/external/types.ts +121 -0
- package/src/external/util.ts +57 -0
- package/src/rastack-dev.ts +233 -6
- package/src/rastack-wasm-build.ts +6 -6
- package/jest.config.cjs +0 -12
- package/test/auth.spec.ts +0 -207
- package/test/cache.spec.ts +0 -263
- package/test/cognito.spec.ts +0 -281
- package/test/compile.spec.ts +0 -240
- package/test/components.spec.ts +0 -524
- package/test/csv-schema.spec.ts +0 -143
- package/test/deploy.spec.ts +0 -571
- package/test/dev.spec.ts +0 -414
- package/test/entities.spec.ts +0 -597
- package/test/import.spec.ts +0 -241
- package/test/plugin.spec.ts +0 -315
- package/test/runtime-manifest.spec.ts +0 -309
- package/test/schema-entities.spec.ts +0 -688
- package/test/tokens.spec.ts +0 -302
- package/test/transitions.spec.ts +0 -372
- package/test/typed-hooks.spec.ts +0 -412
- package/test/update.spec.ts +0 -152
- package/test/validate.spec.ts +0 -319
- package/tsconfig.json +0 -27
- package/wasm/package.json +0 -4
- package/wasm/rastack_wasm.d.ts +0 -100
- package/wasm/rastack_wasm.js +0 -745
- package/wasm/rastack_wasm_bg.wasm +0 -0
- package/wasm/rastack_wasm_bg.wasm.d.ts +0 -28
package/test/tokens.spec.ts
DELETED
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
3
|
-
import {
|
|
4
|
-
defineTheme,
|
|
5
|
-
defineTokens,
|
|
6
|
-
emitCss,
|
|
7
|
-
emitTs,
|
|
8
|
-
cssVarName,
|
|
9
|
-
formatCssValue,
|
|
10
|
-
ref,
|
|
11
|
-
resolveTokens,
|
|
12
|
-
resolveTheme,
|
|
13
|
-
t,
|
|
14
|
-
ThemedTokens,
|
|
15
|
-
TokenDocument,
|
|
16
|
-
} from "../src/tokens";
|
|
17
|
-
import { compileTokens, loadTokens } from "../src/tokens/compile";
|
|
18
|
-
import { renderStudio } from "../src/tokens/studio";
|
|
19
|
-
|
|
20
|
-
const workRoot = join(__dirname, "tokens-fixtures");
|
|
21
|
-
|
|
22
|
-
function findTok<T extends { path: string }>(list: T[], path: string): T {
|
|
23
|
-
const found = list.find((x) => x.path === path);
|
|
24
|
-
if (!found) throw new Error(`missing token ${path}`);
|
|
25
|
-
return found;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
describe("design tokens — resolution", () => {
|
|
29
|
-
it("inherits a group $type down to its tokens", () => {
|
|
30
|
-
const doc: TokenDocument = {
|
|
31
|
-
color: { $type: "color", brand: { 500: { $value: "#4F6BFF" } } },
|
|
32
|
-
};
|
|
33
|
-
const resolved = resolveTokens(doc);
|
|
34
|
-
expect(findTok(resolved, "color.brand.500").type).toBe("color");
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("resolves alias values transitively and records the immediate target", () => {
|
|
38
|
-
const doc: TokenDocument = {
|
|
39
|
-
color: {
|
|
40
|
-
$type: "color",
|
|
41
|
-
brand: { 500: { $value: "#4F6BFF" } },
|
|
42
|
-
accent: { $value: ref("color.brand.500") },
|
|
43
|
-
cta: { $value: ref("color.accent") },
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
|
-
const resolved = resolveTokens(doc);
|
|
47
|
-
const accent = findTok(resolved, "color.accent");
|
|
48
|
-
const cta = findTok(resolved, "color.cta");
|
|
49
|
-
expect(accent.value).toBe("#4F6BFF");
|
|
50
|
-
expect(accent.aliasOf).toBe("color.brand.500");
|
|
51
|
-
// transitive: cta -> accent -> brand.500
|
|
52
|
-
expect(cta.value).toBe("#4F6BFF");
|
|
53
|
-
expect(cta.aliasOf).toBe("color.accent");
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("throws on a dangling alias", () => {
|
|
57
|
-
const doc: TokenDocument = { color: { a: { $type: "color", $value: ref("color.missing") } } };
|
|
58
|
-
expect(() => resolveTokens(doc)).toThrow(/unknown token "color.missing"/);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("throws on an alias cycle", () => {
|
|
62
|
-
const doc: TokenDocument = {
|
|
63
|
-
a: { $type: "color", $value: ref("b") },
|
|
64
|
-
b: { $type: "color", $value: ref("a") },
|
|
65
|
-
};
|
|
66
|
-
expect(() => resolveTokens(doc)).toThrow(/cycle/i);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("names CSS variables from dotted paths (kebab, camel-split, prefix)", () => {
|
|
70
|
-
expect(cssVarName("color.brand.500")).toBe("--color-brand-500");
|
|
71
|
-
expect(cssVarName("color.surfaceAlt")).toBe("--color-surface-alt");
|
|
72
|
-
expect(cssVarName("color.accent", "rs")).toBe("--rs-color-accent");
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
describe("design tokens — DSL builders", () => {
|
|
77
|
-
it("t.dimension coerces bare numbers to px; t.duration to ms", () => {
|
|
78
|
-
const doc = defineTokens({
|
|
79
|
-
space: { md: t.dimension(16) },
|
|
80
|
-
motion: { fast: t.duration(150) },
|
|
81
|
-
}).tokens;
|
|
82
|
-
const resolved = resolveTokens(doc);
|
|
83
|
-
expect(findTok(resolved, "space.md").value).toBe("16px");
|
|
84
|
-
expect(findTok(resolved, "motion.fast").value).toBe("150ms");
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("defineTokens normalises a bare tree and a themed shape alike", () => {
|
|
88
|
-
const bare = defineTokens({ color: { bg: t.color("#fff") } });
|
|
89
|
-
expect(bare.tokens.color).toBeDefined();
|
|
90
|
-
expect(bare.modes).toEqual({});
|
|
91
|
-
|
|
92
|
-
const themed = defineTokens({ tokens: { color: { bg: t.color("#fff") } }, defaultMode: "light" });
|
|
93
|
-
expect(themed.defaultMode).toBe("light");
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
describe("design tokens — CSS emission", () => {
|
|
98
|
-
const themed: ThemedTokens = {
|
|
99
|
-
tokens: {
|
|
100
|
-
color: {
|
|
101
|
-
$type: "color",
|
|
102
|
-
brand: { 500: { $value: "#4F6BFF" } },
|
|
103
|
-
accent: { $value: ref("color.brand.500") },
|
|
104
|
-
bg: { $value: "#FFFFFF" },
|
|
105
|
-
},
|
|
106
|
-
space: { $type: "dimension", md: { $value: "16px" } },
|
|
107
|
-
font: { family: { $type: "fontFamily", sans: { $value: ["Inter", "sans-serif"] } } },
|
|
108
|
-
shadow: { $type: "shadow", sm: { $value: { offsetX: "0", offsetY: "1px", blur: "2px", color: "#0001" } } },
|
|
109
|
-
},
|
|
110
|
-
modes: { dark: { color: { bg: { $value: "#06080F" } } } },
|
|
111
|
-
defaultMode: "light",
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
const css = emitCss(themed);
|
|
115
|
-
|
|
116
|
-
it("emits :root custom properties", () => {
|
|
117
|
-
expect(css).toMatch(/:root\s*\{/);
|
|
118
|
-
expect(css).toContain("--color-brand-500: #4F6BFF;");
|
|
119
|
-
expect(css).toContain("--space-md: 16px;");
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("preserves aliases as var() references so themes cascade", () => {
|
|
123
|
-
expect(css).toContain("--color-accent: var(--color-brand-500);");
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("quotes font families with spaces and joins the stack", () => {
|
|
127
|
-
expect(css).toContain('--font-family-sans: Inter, sans-serif;');
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("renders shadows as box-shadow strings", () => {
|
|
131
|
-
expect(css).toContain("--shadow-sm: 0 1px 2px #0001;");
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it("scopes a mode override to its selector and omits unchanged tokens", () => {
|
|
135
|
-
expect(css).toContain('[data-theme="dark"]');
|
|
136
|
-
// only bg changed in dark
|
|
137
|
-
const darkBlock = css.slice(css.indexOf('[data-theme="dark"]'));
|
|
138
|
-
expect(darkBlock).toContain("--color-bg: #06080F;");
|
|
139
|
-
expect(darkBlock).not.toContain("--color-brand-500");
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("respects a variable prefix", () => {
|
|
143
|
-
expect(emitCss(themed, { prefix: "rs" })).toContain("--rs-color-brand-500:");
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
describe("design tokens — typed TS emission", () => {
|
|
148
|
-
const base = resolveTokens({
|
|
149
|
-
color: { $type: "color", brand: { 500: { $value: "#4F6BFF" } }, surfaceAlt: { $value: "#eee" } },
|
|
150
|
-
});
|
|
151
|
-
const source = emitTs(base);
|
|
152
|
-
|
|
153
|
-
it("emits a nested theme tree of var() references", () => {
|
|
154
|
-
expect(source).toContain('"500": "var(--color-brand-500)"');
|
|
155
|
-
expect(source).toContain('surfaceAlt: "var(--color-surface-alt)"');
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("emits a TokenPath union of every token", () => {
|
|
159
|
-
expect(source).toContain('| "color.brand.500"');
|
|
160
|
-
expect(source).toContain('| "color.surfaceAlt"');
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
describe("design tokens — compile to disk", () => {
|
|
165
|
-
const outDir = join(workRoot, "out");
|
|
166
|
-
const jsonSrc = join(workRoot, "sample.tokens.json");
|
|
167
|
-
|
|
168
|
-
beforeAll(() => {
|
|
169
|
-
mkdirSync(workRoot, { recursive: true });
|
|
170
|
-
writeFileSync(
|
|
171
|
-
jsonSrc,
|
|
172
|
-
JSON.stringify({
|
|
173
|
-
tokens: { color: { $type: "color", accent: { $value: "#4F6BFF" } } },
|
|
174
|
-
modes: { dark: { color: { accent: { $value: "#8BA4FF" } } } },
|
|
175
|
-
defaultMode: "light",
|
|
176
|
-
}),
|
|
177
|
-
);
|
|
178
|
-
});
|
|
179
|
-
afterAll(() => rmSync(workRoot, { recursive: true, force: true }));
|
|
180
|
-
|
|
181
|
-
it("writes tokens.rastack.json, tokens.css and tokens.ts", () => {
|
|
182
|
-
const { files } = compileTokens(jsonSrc, outDir);
|
|
183
|
-
expect(files.map((f) => f.split("/").pop())).toEqual([
|
|
184
|
-
"tokens.rastack.json",
|
|
185
|
-
"tokens.css",
|
|
186
|
-
"tokens.ts",
|
|
187
|
-
]);
|
|
188
|
-
for (const f of files) expect(existsSync(f)).toBe(true);
|
|
189
|
-
expect(readFileSync(join(outDir, "tokens.css"), "utf8")).toContain("--color-accent: #4F6BFF;");
|
|
190
|
-
expect(readFileSync(join(outDir, "tokens.ts"), "utf8")).toContain('| "color.accent"');
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it("loadTokens parses a bare DTCG tree as well as a themed doc", () => {
|
|
194
|
-
const bareSrc = join(workRoot, "bare.tokens.json");
|
|
195
|
-
writeFileSync(bareSrc, JSON.stringify({ color: { $type: "color", bg: { $value: "#fff" } } }));
|
|
196
|
-
const themed = loadTokens(bareSrc);
|
|
197
|
-
expect(themed.tokens.color).toBeDefined();
|
|
198
|
-
});
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
describe("design tokens — studio page", () => {
|
|
202
|
-
const themed = defineTokens({
|
|
203
|
-
tokens: { color: { brand: { 500: t.color("#4F6BFF") }, accent: t.color(ref("color.brand.500")) } },
|
|
204
|
-
modes: { dark: { color: { brand: { 500: t.color("#8BA4FF") } } } },
|
|
205
|
-
defaultMode: "light",
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("renders a self-contained HTML page embedding the tokens + CSS", () => {
|
|
209
|
-
const html = renderStudio(themed, { editable: true });
|
|
210
|
-
expect(html).toMatch(/^<!doctype html>/);
|
|
211
|
-
expect(html).toContain("--color-brand-500: #4F6BFF;");
|
|
212
|
-
expect(html).toContain('id="studio-data"');
|
|
213
|
-
// the embedded payload carries the layers + raw doc for the editor
|
|
214
|
-
expect(html).toContain('"color.accent"');
|
|
215
|
-
expect(html).toContain('"editable":true');
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
describe("design tokens — defineTheme() intent layer", () => {
|
|
220
|
-
it("derives a full 50–950 brand scale from one colour, with 500 = the seed", () => {
|
|
221
|
-
const resolved = resolveTheme(defineTheme({ brand: "#4F6BFF" }));
|
|
222
|
-
expect(findTok(resolved.base, "color.brand.500").value).toBe("#4F6BFF");
|
|
223
|
-
for (const step of ["50", "100", "200", "300", "400", "600", "700", "800", "900", "950"]) {
|
|
224
|
-
expect(findTok(resolved.base, `color.brand.${step}`).value).toMatch(/^#[0-9A-F]{6}$/);
|
|
225
|
-
}
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it("generates semantic tokens plus a dark mode by default", () => {
|
|
229
|
-
const themed = defineTheme({ brand: "#4F6BFF" });
|
|
230
|
-
expect(Object.keys(themed.modes ?? {})).toEqual(["dark"]);
|
|
231
|
-
const resolved = resolveTheme(themed);
|
|
232
|
-
for (const name of ["bg", "surface", "border", "text", "text-muted", "accent", "on-accent"]) {
|
|
233
|
-
expect(findTok(resolved.base, `color.${name}`)).toBeDefined();
|
|
234
|
-
}
|
|
235
|
-
// The accent is an alias of the brand ramp, and reads lighter in dark.
|
|
236
|
-
expect(findTok(resolved.base, "color.accent").aliasOf).toBe("color.brand.500");
|
|
237
|
-
expect(findTok(resolved.modes.dark, "color.accent").aliasOf).toBe("color.brand.400");
|
|
238
|
-
// dark bg is dark, light bg is white
|
|
239
|
-
expect(findTok(resolved.base, "color.bg").value).toBe("#FFFFFF");
|
|
240
|
-
expect(findTok(resolved.modes.dark, "color.bg").value).not.toBe("#FFFFFF");
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it("picks a readable on-accent colour by contrast", () => {
|
|
244
|
-
expect(findTok(resolveTheme(defineTheme({ brand: "#FACC15" })).base, "color.on-accent").value).toBe("#0B1020");
|
|
245
|
-
expect(findTok(resolveTheme(defineTheme({ brand: "#1D4ED8" })).base, "color.on-accent").value).toBe("#FFFFFF");
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it("maps radius keywords and honours a numeric radius", () => {
|
|
249
|
-
expect(findTok(resolveTheme(defineTheme({ brand: "#4F6BFF", radius: "sharp" })).base, "color.brand.500")).toBeDefined();
|
|
250
|
-
const r = resolveTheme(defineTheme({ brand: "#4F6BFF", radius: 20 }));
|
|
251
|
-
expect(findTok(r.base, "radius.md").value).toBe("20px");
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it("lowers to the same ThemedTokens shape and compiles to valid CSS", () => {
|
|
255
|
-
const css = emitCss(defineTheme({ brand: "#4F6BFF", font: "Poppins" }));
|
|
256
|
-
expect(css).toContain("--color-brand-500: #4F6BFF;");
|
|
257
|
-
expect(css).toContain("--color-accent: var(--color-brand-500);");
|
|
258
|
-
expect(css).toContain('[data-theme="dark"]');
|
|
259
|
-
expect(css).toContain("--font-family-sans: Poppins;");
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it("applies colour overrides and the extend escape hatch", () => {
|
|
263
|
-
const themed = defineTheme({
|
|
264
|
-
brand: "#4F6BFF",
|
|
265
|
-
colors: { success: "#00A86B" },
|
|
266
|
-
extend: { tokens: { z: { index: { modal: { $type: "number", $value: 1000 } } } } },
|
|
267
|
-
});
|
|
268
|
-
const base = resolveTheme(themed).base;
|
|
269
|
-
expect(findTok(base, "color.success").value).toBe("#00A86B");
|
|
270
|
-
expect(findTok(base, "z.index.modal").value).toBe(1000);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it("appearance:'light' emits no dark mode; 'dark' makes :root dark", () => {
|
|
274
|
-
expect(Object.keys(defineTheme({ brand: "#4F6BFF", appearance: "light" }).modes ?? {})).toEqual([]);
|
|
275
|
-
const dark = defineTheme({ brand: "#4F6BFF", appearance: "dark" });
|
|
276
|
-
expect(dark.defaultMode).toBe("dark");
|
|
277
|
-
expect(findTok(resolveTheme(dark).base, "color.bg").value).not.toBe("#FFFFFF");
|
|
278
|
-
});
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
describe("design tokens — the repo's own theme.tokens.ts + .json agree", () => {
|
|
282
|
-
it("the TS authoring source resolves and matches the JSON source", () => {
|
|
283
|
-
// Executed by ts-jest, importing the package by its subpath name.
|
|
284
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
285
|
-
const tsThemed = require("../../tokens/theme.tokens").default as ThemedTokens;
|
|
286
|
-
const jsonThemed = loadTokens(join(__dirname, "..", "..", "tokens", "theme.tokens.json"));
|
|
287
|
-
|
|
288
|
-
const fromTs = resolveTheme(tsThemed);
|
|
289
|
-
const fromJson = resolveTheme(jsonThemed);
|
|
290
|
-
|
|
291
|
-
// Same set of token paths and the same emitted CSS values (composite types
|
|
292
|
-
// like shadows may be authored with numbers in TS and strings in JSON, but
|
|
293
|
-
// must render identically).
|
|
294
|
-
const map = (r: ReturnType<typeof resolveTheme>) =>
|
|
295
|
-
Object.fromEntries(r.base.map((tok) => [tok.path, formatCssValue(tok)]));
|
|
296
|
-
expect(map(fromTs)).toEqual(map(fromJson));
|
|
297
|
-
|
|
298
|
-
// Alias cascades through the dark mode (accent follows brand.500).
|
|
299
|
-
const darkAccent = findTok(fromTs.modes.dark, "color.accent");
|
|
300
|
-
expect(darkAccent.value).toBe("#8BA4FF");
|
|
301
|
-
});
|
|
302
|
-
});
|
package/test/transitions.spec.ts
DELETED
|
@@ -1,372 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, rmSync, writeFileSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
3
|
-
import {
|
|
4
|
-
analyzeProject,
|
|
5
|
-
analyzeResources,
|
|
6
|
-
buildOpenApi,
|
|
7
|
-
writeSchema,
|
|
8
|
-
} from "../src/compile";
|
|
9
|
-
import {
|
|
10
|
-
allowedTransitions,
|
|
11
|
-
applyTransition,
|
|
12
|
-
compileRecordMachine,
|
|
13
|
-
describeTransitions,
|
|
14
|
-
findTransition,
|
|
15
|
-
normalizeTransitions,
|
|
16
|
-
runRecordMachine,
|
|
17
|
-
specsFromResource,
|
|
18
|
-
transitionGuard,
|
|
19
|
-
transitionProblems,
|
|
20
|
-
type TransitionsModel,
|
|
21
|
-
} from "../src/validate";
|
|
22
|
-
import { validateDataset } from "../src/import/tabular";
|
|
23
|
-
|
|
24
|
-
/** The flight lifecycle used throughout — mirrors `resources/airports.ts`. */
|
|
25
|
-
const FLIGHT: TransitionsModel = {
|
|
26
|
-
field: "status",
|
|
27
|
-
states: ["scheduled", "boarding", "departed", "cancelled"],
|
|
28
|
-
initial: "scheduled",
|
|
29
|
-
on: {
|
|
30
|
-
board: { from: ["scheduled"], to: "boarding" },
|
|
31
|
-
depart: { from: ["boarding"], to: "departed", set: { gate: null } },
|
|
32
|
-
cancel: { from: ["scheduled", "boarding"], to: "cancelled" },
|
|
33
|
-
},
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
describe("transitions — the pure core", () => {
|
|
37
|
-
it("normalises an authored block (scalar `from` becomes a list)", () => {
|
|
38
|
-
const model = normalizeTransitions({
|
|
39
|
-
field: "status",
|
|
40
|
-
states: ["a", "b"],
|
|
41
|
-
initial: "a",
|
|
42
|
-
on: { go: { from: "a", to: "b" } },
|
|
43
|
-
});
|
|
44
|
-
expect(model).toEqual({
|
|
45
|
-
field: "status",
|
|
46
|
-
states: ["a", "b"],
|
|
47
|
-
initial: "a",
|
|
48
|
-
on: { go: { from: ["a"], to: "b" } },
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("rejects structurally broken blocks and drops malformed edges", () => {
|
|
53
|
-
expect(normalizeTransitions(undefined)).toBeUndefined();
|
|
54
|
-
expect(normalizeTransitions("nope")).toBeUndefined();
|
|
55
|
-
expect(normalizeTransitions({ field: "s", states: [] })).toBeUndefined();
|
|
56
|
-
const model = normalizeTransitions({
|
|
57
|
-
field: "s",
|
|
58
|
-
states: ["a", "b"],
|
|
59
|
-
on: { ok: { from: "a", to: "b" }, broken: { from: "a" } },
|
|
60
|
-
});
|
|
61
|
-
expect(Object.keys(model!.on)).toEqual(["ok"]);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("reports authoring problems against the resource's fields", () => {
|
|
65
|
-
const fields = [{ name: "status" }, { name: "gate" }];
|
|
66
|
-
expect(transitionProblems(FLIGHT, fields)).toEqual([]);
|
|
67
|
-
|
|
68
|
-
const broken: TransitionsModel = {
|
|
69
|
-
field: "state", // not a field
|
|
70
|
-
states: ["a"],
|
|
71
|
-
initial: "b", // not a state
|
|
72
|
-
on: {
|
|
73
|
-
go: { from: ["a"], to: "z", set: { state: "x", ghost: 1 } }, // bad to/set
|
|
74
|
-
},
|
|
75
|
-
};
|
|
76
|
-
const problems = transitionProblems(broken, [{ name: "status" }]);
|
|
77
|
-
expect(problems).toEqual(
|
|
78
|
-
expect.arrayContaining([
|
|
79
|
-
expect.stringContaining('"state" is not a field'),
|
|
80
|
-
expect.stringContaining('initial state "b"'),
|
|
81
|
-
expect.stringContaining('to-state "z"'),
|
|
82
|
-
expect.stringContaining("must not patch the state field"),
|
|
83
|
-
expect.stringContaining('unknown field "ghost"'),
|
|
84
|
-
]),
|
|
85
|
-
);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("lists the transitions available from a state", () => {
|
|
89
|
-
expect(allowedTransitions(FLIGHT, "scheduled").map((t) => t.name)).toEqual([
|
|
90
|
-
"board",
|
|
91
|
-
"cancel",
|
|
92
|
-
]);
|
|
93
|
-
expect(allowedTransitions(FLIGHT, "departed")).toEqual([]);
|
|
94
|
-
// No state falls back to the initial state.
|
|
95
|
-
expect(allowedTransitions(FLIGHT, undefined).map((t) => t.name)).toEqual([
|
|
96
|
-
"board",
|
|
97
|
-
"cancel",
|
|
98
|
-
]);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("finds the declared edge covering a jump", () => {
|
|
102
|
-
expect(findTransition(FLIGHT, "boarding", "departed")?.name).toBe("depart");
|
|
103
|
-
expect(findTransition(FLIGHT, "scheduled", "departed")).toBeUndefined();
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("guards creates: records start at the initial state", () => {
|
|
107
|
-
expect(transitionGuard(FLIGHT, undefined, {}).ok).toBe(true);
|
|
108
|
-
expect(transitionGuard(FLIGHT, undefined, { status: "scheduled" }).ok).toBe(true);
|
|
109
|
-
const rejected = transitionGuard(FLIGHT, undefined, { status: "departed" });
|
|
110
|
-
expect(rejected).toMatchObject({
|
|
111
|
-
ok: false,
|
|
112
|
-
message: expect.stringContaining('start at "scheduled"'),
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("guards updates: a state change must follow a declared edge", () => {
|
|
117
|
-
const previous = { status: "scheduled" };
|
|
118
|
-
expect(transitionGuard(FLIGHT, previous, { number: "BA1" }).ok).toBe(true);
|
|
119
|
-
expect(transitionGuard(FLIGHT, previous, { status: "scheduled" }).ok).toBe(true);
|
|
120
|
-
expect(transitionGuard(FLIGHT, previous, { status: "boarding" }).ok).toBe(true);
|
|
121
|
-
expect(transitionGuard(FLIGHT, previous, { status: "departed" })).toMatchObject({
|
|
122
|
-
ok: false,
|
|
123
|
-
message: 'status: no transition from "scheduled" to "departed"',
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("carries the matched edge's `set` patches through the guard", () => {
|
|
128
|
-
const result = transitionGuard(FLIGHT, { status: "boarding" }, { status: "departed" });
|
|
129
|
-
expect(result).toEqual({ ok: true, patch: { gate: null } });
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("fires a named transition into a full write patch", () => {
|
|
133
|
-
const applied = applyTransition(FLIGHT, "depart", { status: "boarding" });
|
|
134
|
-
expect(applied).toEqual({
|
|
135
|
-
ok: true,
|
|
136
|
-
patch: { status: "departed", gate: null },
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
expect(applyTransition(FLIGHT, "depart", { status: "scheduled" })).toMatchObject({
|
|
140
|
-
ok: false,
|
|
141
|
-
message: expect.stringContaining('not available from "scheduled"'),
|
|
142
|
-
});
|
|
143
|
-
expect(applyTransition(FLIGHT, "teleport", {})).toMatchObject({
|
|
144
|
-
ok: false,
|
|
145
|
-
message: expect.stringContaining("unknown transition"),
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("describes the machine for UI hints", () => {
|
|
150
|
-
expect(describeTransitions(FLIGHT)).toEqual([
|
|
151
|
-
"board: scheduled → boarding",
|
|
152
|
-
"depart: boarding → departed",
|
|
153
|
-
"cancel: scheduled | boarding → cancelled",
|
|
154
|
-
]);
|
|
155
|
-
});
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
describe("transitions — the record-level gate in the record machine", () => {
|
|
159
|
-
const resource = {
|
|
160
|
-
fields: [
|
|
161
|
-
{ name: "number", type: "string" as const, maxLength: 8 },
|
|
162
|
-
{
|
|
163
|
-
name: "status",
|
|
164
|
-
type: "string" as const,
|
|
165
|
-
options: FLIGHT.states,
|
|
166
|
-
default: "scheduled",
|
|
167
|
-
},
|
|
168
|
-
{ name: "gate", type: "string" as const, null: true },
|
|
169
|
-
],
|
|
170
|
-
};
|
|
171
|
-
const machine = compileRecordMachine(specsFromResource(resource), FLIGHT);
|
|
172
|
-
|
|
173
|
-
it("passes a legal edge with the persisted row in context", () => {
|
|
174
|
-
const run = runRecordMachine(
|
|
175
|
-
machine,
|
|
176
|
-
{ number: "BA1", status: "boarding" },
|
|
177
|
-
{ previous: { status: "scheduled" } },
|
|
178
|
-
);
|
|
179
|
-
expect(run.state).toBe("valid");
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("fails an undeclared jump at the `transition` gate", () => {
|
|
183
|
-
const run = runRecordMachine(
|
|
184
|
-
machine,
|
|
185
|
-
{ number: "BA1", status: "departed" },
|
|
186
|
-
{ previous: { status: "scheduled" } },
|
|
187
|
-
);
|
|
188
|
-
expect(run.state).toBe("invalid");
|
|
189
|
-
expect(run.errors).toEqual([
|
|
190
|
-
expect.objectContaining({
|
|
191
|
-
field: "status",
|
|
192
|
-
gate: "transition",
|
|
193
|
-
message: 'status: no transition from "scheduled" to "departed"',
|
|
194
|
-
}),
|
|
195
|
-
]);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("treats a run without `previous` as a create", () => {
|
|
199
|
-
const born = runRecordMachine(machine, { number: "BA1", status: "departed" });
|
|
200
|
-
expect(born.state).toBe("invalid");
|
|
201
|
-
expect(born.errors[0].gate).toBe("transition");
|
|
202
|
-
|
|
203
|
-
const fresh = runRecordMachine(machine, { number: "BA1" });
|
|
204
|
-
expect(fresh.state).toBe("valid");
|
|
205
|
-
// The state field's default (the initial state) fills in.
|
|
206
|
-
expect(fresh.values.status).toBe("scheduled");
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it("lets the `member` gate explain an undeclared state first", () => {
|
|
210
|
-
const run = runRecordMachine(machine, { number: "BA1", status: "teleported" });
|
|
211
|
-
expect(run.state).toBe("invalid");
|
|
212
|
-
expect(run.errors).toHaveLength(1);
|
|
213
|
-
expect(run.errors[0].gate).toBe("member");
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it("rejects import rows born mid-machine (dataset surface)", () => {
|
|
217
|
-
const report = validateDataset(
|
|
218
|
-
specsFromResource(resource),
|
|
219
|
-
{
|
|
220
|
-
headers: ["number", "status"],
|
|
221
|
-
rows: [
|
|
222
|
-
["BA1", "scheduled"],
|
|
223
|
-
["BA2", "departed"],
|
|
224
|
-
["BA3", ""],
|
|
225
|
-
],
|
|
226
|
-
},
|
|
227
|
-
{ transitions: FLIGHT },
|
|
228
|
-
);
|
|
229
|
-
expect(report.valid).toBe(2);
|
|
230
|
-
expect(report.issues).toEqual([
|
|
231
|
-
expect.objectContaining({ row: 1, gate: "transition" }),
|
|
232
|
-
]);
|
|
233
|
-
});
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
describe("transitions — through the compiler", () => {
|
|
237
|
-
const workRoot = join(__dirname, "transitions-fixtures");
|
|
238
|
-
const resourcesDir = join(workRoot, "resources");
|
|
239
|
-
|
|
240
|
-
const FLIGHTS_TS = `
|
|
241
|
-
import { resource, s } from "rastack/define";
|
|
242
|
-
|
|
243
|
-
export const Flight = resource("airports", "flight", {
|
|
244
|
-
number: s.string({ maxLength: 8 }),
|
|
245
|
-
status: s.string({ maxLength: 20 }),
|
|
246
|
-
gate: s.string({ maxLength: 5, null: true }),
|
|
247
|
-
}, {
|
|
248
|
-
transitions: {
|
|
249
|
-
field: "status",
|
|
250
|
-
states: ["scheduled", "boarding", "departed", "cancelled"],
|
|
251
|
-
initial: "scheduled",
|
|
252
|
-
on: {
|
|
253
|
-
board: { from: "scheduled", to: "boarding" },
|
|
254
|
-
depart: { from: "boarding", to: "departed", set: { gate: null } },
|
|
255
|
-
cancel: { from: ["scheduled", "boarding"], to: "cancelled" },
|
|
256
|
-
},
|
|
257
|
-
},
|
|
258
|
-
});
|
|
259
|
-
`;
|
|
260
|
-
|
|
261
|
-
const ORDERS_TS = `
|
|
262
|
-
// A plain data class with a \`static transitions\` block — the class-authored
|
|
263
|
-
// twin of the DSL option. Statics are never columns.
|
|
264
|
-
export class Order {
|
|
265
|
-
reference!: string;
|
|
266
|
-
status = "draft";
|
|
267
|
-
|
|
268
|
-
static transitions = {
|
|
269
|
-
field: "status",
|
|
270
|
-
states: ["draft", "placed", "shipped"],
|
|
271
|
-
initial: "draft",
|
|
272
|
-
on: {
|
|
273
|
-
place: { from: "draft", to: "placed" },
|
|
274
|
-
ship: { from: "placed", to: "shipped" },
|
|
275
|
-
},
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
`;
|
|
279
|
-
|
|
280
|
-
beforeAll(() => {
|
|
281
|
-
mkdirSync(resourcesDir, { recursive: true });
|
|
282
|
-
writeFileSync(join(resourcesDir, "flights.ts"), FLIGHTS_TS);
|
|
283
|
-
writeFileSync(join(resourcesDir, "orders.ts"), ORDERS_TS);
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
afterAll(() => {
|
|
287
|
-
rmSync(workRoot, { recursive: true, force: true });
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it("compiles a DSL transitions block into the manifest, normalised", () => {
|
|
291
|
-
const resources = analyzeResources(resourcesDir);
|
|
292
|
-
const flight = resources.find((r) => r.model === "flight")!;
|
|
293
|
-
expect(flight.transitions).toEqual({
|
|
294
|
-
field: "status",
|
|
295
|
-
states: ["scheduled", "boarding", "departed", "cancelled"],
|
|
296
|
-
initial: "scheduled",
|
|
297
|
-
on: {
|
|
298
|
-
board: { from: ["scheduled"], to: "boarding" },
|
|
299
|
-
depart: { from: ["boarding"], to: "departed", set: { gate: null } },
|
|
300
|
-
cancel: { from: ["scheduled", "boarding"], to: "cancelled" },
|
|
301
|
-
},
|
|
302
|
-
});
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
it("stamps the state field with the states and the initial default", () => {
|
|
306
|
-
const resources = analyzeResources(resourcesDir);
|
|
307
|
-
const flight = resources.find((r) => r.model === "flight")!;
|
|
308
|
-
const status = flight.fields.find((f) => f.name === "status");
|
|
309
|
-
expect(status).toMatchObject({
|
|
310
|
-
options: ["scheduled", "boarding", "departed", "cancelled"],
|
|
311
|
-
default: "scheduled",
|
|
312
|
-
});
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
it("reads a class's `static transitions` (and keeps it out of the columns)", () => {
|
|
316
|
-
const resources = analyzeResources(resourcesDir);
|
|
317
|
-
const order = resources.find((r) => r.model === "order")!;
|
|
318
|
-
expect(order.transitions).toMatchObject({
|
|
319
|
-
field: "status",
|
|
320
|
-
states: ["draft", "placed", "shipped"],
|
|
321
|
-
on: { place: { from: ["draft"], to: "placed" } },
|
|
322
|
-
});
|
|
323
|
-
expect(order.fields.map((f) => f.name)).toEqual(["reference", "status"]);
|
|
324
|
-
const status = order.fields.find((f) => f.name === "status");
|
|
325
|
-
expect(status?.options).toEqual(["draft", "placed", "shipped"]);
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it("emits `enum` and `x-rastack-transitions` into the OpenAPI contract", () => {
|
|
329
|
-
const resources = analyzeResources(resourcesDir);
|
|
330
|
-
const flight = resources.find((r) => r.model === "flight")!;
|
|
331
|
-
const doc = buildOpenApi(resources);
|
|
332
|
-
|
|
333
|
-
const status = doc.components.schemas.Flight.properties.status;
|
|
334
|
-
expect(status.enum).toEqual(["scheduled", "boarding", "departed", "cancelled"]);
|
|
335
|
-
|
|
336
|
-
const write = doc.components.schemas.FlightRequest;
|
|
337
|
-
expect(write["x-rastack-transitions"]).toEqual(flight.transitions);
|
|
338
|
-
expect(doc.components.schemas.PatchedFlightRequest["x-rastack-transitions"])
|
|
339
|
-
.toEqual(flight.transitions);
|
|
340
|
-
|
|
341
|
-
// The exported `writeSchema` (what `useForm(Entity)` derives at runtime)
|
|
342
|
-
// is the same schema the document publishes.
|
|
343
|
-
expect(writeSchema(flight, false)).toEqual(write);
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
it("surfaces authoring mistakes as compile diagnostics", () => {
|
|
347
|
-
const brokenDir = join(workRoot, "broken");
|
|
348
|
-
mkdirSync(brokenDir, { recursive: true });
|
|
349
|
-
writeFileSync(
|
|
350
|
-
join(brokenDir, "broken.ts"),
|
|
351
|
-
`
|
|
352
|
-
import { resource, s } from "rastack/define";
|
|
353
|
-
export const Thing = resource("app", "thing", {
|
|
354
|
-
status: s.string({ maxLength: 10 }),
|
|
355
|
-
}, {
|
|
356
|
-
transitions: {
|
|
357
|
-
field: "status",
|
|
358
|
-
states: ["a", "b"],
|
|
359
|
-
on: { go: { from: "a", to: "missing" } },
|
|
360
|
-
},
|
|
361
|
-
});
|
|
362
|
-
`,
|
|
363
|
-
);
|
|
364
|
-
const { diagnostics } = analyzeProject(brokenDir);
|
|
365
|
-
expect(diagnostics).toEqual([
|
|
366
|
-
expect.objectContaining({
|
|
367
|
-
severity: "error",
|
|
368
|
-
message: expect.stringContaining('to-state "missing"'),
|
|
369
|
-
}),
|
|
370
|
-
]);
|
|
371
|
-
});
|
|
372
|
-
});
|