ruact 0.0.5 → 0.0.6
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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +67 -18
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +69 -421
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +65 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/railtie.rb +56 -200
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +102 -0
- data/lib/ruact.rb +19 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +63 -326
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +0 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
- data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +55 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
|
@@ -32,147 +32,22 @@ afterEach(() => {
|
|
|
32
32
|
delete process.env.RUACT_SILENCE_LOG;
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
// Story 9.9 — the v1 (registry / `_makeRef`) render path was demolished; the
|
|
36
|
+
// base snapshot is now the route-driven (version-2) shape.
|
|
35
37
|
const baseSnapshot = (over = {}) => ({
|
|
36
|
-
version:
|
|
38
|
+
version: 2,
|
|
37
39
|
generated_at: "2026-05-13T12:34:56Z",
|
|
38
40
|
functions: [],
|
|
39
41
|
...over,
|
|
40
42
|
});
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
[
|
|
50
|
-
"// AUTO-GENERATED by vite-plugin-ruact (Story 8.0a). DO NOT EDIT.",
|
|
51
|
-
"// Source: tmp/cache/ruact/server-functions.json (version 1)",
|
|
52
|
-
"// Generated at: 2026-05-13T12:34:56Z",
|
|
53
|
-
`import { _makeRef } from "${RUNTIME_IMPORT_SPECIFIER}";`,
|
|
54
|
-
"",
|
|
55
|
-
"// (no server functions registered yet — Stories 8.1 / 9.1 populate)",
|
|
56
|
-
"void _makeRef;",
|
|
57
|
-
"",
|
|
58
|
-
`export { revalidate } from "${RUNTIME_IMPORT_SPECIFIER}";`,
|
|
59
|
-
"",
|
|
60
|
-
].join("\n"),
|
|
61
|
-
);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("references _makeRef via `void` even when empty so noUnusedLocals stays " +
|
|
65
|
-
"green (Re-run patch 2026-05-13)", () => {
|
|
66
|
-
expect(render(baseSnapshot())).toContain("void _makeRef;");
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("emits the revalidate re-export (Story 8.2) in both empty + populated branches", () => {
|
|
70
|
-
expect(render(baseSnapshot())).toContain(
|
|
71
|
-
`export { revalidate } from "${RUNTIME_IMPORT_SPECIFIER}";`,
|
|
72
|
-
);
|
|
73
|
-
expect(
|
|
74
|
-
render(
|
|
75
|
-
baseSnapshot({
|
|
76
|
-
functions: [{ ruby_symbol: "create_post", js_identifier: "createPost", kind: "action" }],
|
|
77
|
-
}),
|
|
78
|
-
),
|
|
79
|
-
).toContain(`export { revalidate } from "${RUNTIME_IMPORT_SPECIFIER}";`);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("rejects a snapshot entry whose js_identifier is not a valid JS identifier " +
|
|
83
|
-
"(snapshot trust-boundary guard — Re-run patch 2026-05-13)", () => {
|
|
84
|
-
const evil = baseSnapshot({
|
|
85
|
-
functions: [
|
|
86
|
-
{
|
|
87
|
-
ruby_symbol: "create_post",
|
|
88
|
-
js_identifier: ');\nevil();_makeRef("x',
|
|
89
|
-
kind: "action",
|
|
90
|
-
},
|
|
91
|
-
],
|
|
92
|
-
});
|
|
93
|
-
expect(() => render(evil)).toThrow(/valid JS identifier/);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("JSON-escapes ruby_symbol in the _makeRef argument so quotes/backslashes " +
|
|
97
|
-
"cannot break out (Re-run patch 2026-05-13)", () => {
|
|
98
|
-
const weird = baseSnapshot({
|
|
99
|
-
functions: [
|
|
100
|
-
{
|
|
101
|
-
ruby_symbol: 'weird"\\name',
|
|
102
|
-
js_identifier: "weirdName",
|
|
103
|
-
kind: "action",
|
|
104
|
-
},
|
|
105
|
-
],
|
|
106
|
-
});
|
|
107
|
-
// The emitted call site should contain a JSON-escaped string literal.
|
|
108
|
-
expect(render(weird)).toContain('_makeRef("weird\\"\\\\name");');
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("emits one export per registered function", () => {
|
|
112
|
-
const fns = [
|
|
113
|
-
{ ruby_symbol: "create_post", js_identifier: "createPost", kind: "action" },
|
|
114
|
-
{ ruby_symbol: "delete_post", js_identifier: "deletePost", kind: "action" },
|
|
115
|
-
];
|
|
116
|
-
const out = render(baseSnapshot({ functions: fns }));
|
|
117
|
-
expect((out.match(/export const /g) || []).length).toBe(2);
|
|
118
|
-
expect(out).toContain("export const createPost:");
|
|
119
|
-
expect(out).toContain("export const deletePost:");
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("emits action and query exports with identical accessor mechanics " +
|
|
123
|
-
"(both call _makeRef)", () => {
|
|
124
|
-
const out = render(
|
|
125
|
-
baseSnapshot({
|
|
126
|
-
functions: [
|
|
127
|
-
{ ruby_symbol: "create_post", js_identifier: "createPost", kind: "action" },
|
|
128
|
-
{ ruby_symbol: "categories", js_identifier: "categories", kind: "query" },
|
|
129
|
-
],
|
|
130
|
-
}),
|
|
131
|
-
);
|
|
132
|
-
expect(out).toContain(`_makeRef("create_post")`);
|
|
133
|
-
expect(out).toContain(`_makeRef("categories")`);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("Story 8.2 — action signatures emit the intersection (direct callable + " +
|
|
137
|
-
"`<form action>`-compatible overload); query signatures stay narrow", () => {
|
|
138
|
-
// R1 (2026-05-17 review patch): the previous widening to
|
|
139
|
-
// `(args?: FormData | Record<string, unknown>) => Promise<unknown>`
|
|
140
|
-
// alone did NOT make `<form action={createPost}>` typecheck against
|
|
141
|
-
// React 19's `(formData: FormData) => void | Promise<void>` — Promise
|
|
142
|
-
// generics are invariant. The intersection adds a second
|
|
143
|
-
// `(formData: FormData) => Promise<void>` signature so the same export
|
|
144
|
-
// satisfies both call sites.
|
|
145
|
-
const out = render(
|
|
146
|
-
baseSnapshot({
|
|
147
|
-
functions: [
|
|
148
|
-
{ ruby_symbol: "create_post", js_identifier: "createPost", kind: "action" },
|
|
149
|
-
{ ruby_symbol: "categories", js_identifier: "categories", kind: "query" },
|
|
150
|
-
],
|
|
151
|
-
}),
|
|
152
|
-
);
|
|
153
|
-
expect(out).toContain(
|
|
154
|
-
"export const createPost: ((args?: FormData | Record<string, unknown>) => Promise<unknown>) & ((formData: FormData) => Promise<void>) =",
|
|
155
|
-
);
|
|
156
|
-
expect(out).toContain("export const categories: () => Promise<unknown> =");
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it("Story 8.2 — query signatures do NOT widen (regression guard against accidental " +
|
|
160
|
-
"carry-over of the action widening)", () => {
|
|
161
|
-
const out = render(
|
|
162
|
-
baseSnapshot({
|
|
163
|
-
functions: [{ ruby_symbol: "categories", js_identifier: "categories", kind: "query" }],
|
|
164
|
-
}),
|
|
165
|
-
);
|
|
166
|
-
// The literal action-style FormData union must NOT appear next to a query export.
|
|
167
|
-
expect(out).not.toMatch(/export const categories:[^=]*FormData/);
|
|
168
|
-
expect(out).toContain("export const categories: () => Promise<unknown> =");
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("ends with exactly one trailing newline", () => {
|
|
172
|
-
const out = render(baseSnapshot());
|
|
173
|
-
expect(out.endsWith("\n")).toBe(true);
|
|
174
|
-
expect(out.endsWith("\n\n")).toBe(false);
|
|
175
|
-
});
|
|
44
|
+
const v2ActionEntry = (over = {}) => ({
|
|
45
|
+
js_identifier: "demoPing",
|
|
46
|
+
kind: "action",
|
|
47
|
+
http_method: "POST",
|
|
48
|
+
path: "/demo_ping",
|
|
49
|
+
segments: [],
|
|
50
|
+
...over,
|
|
176
51
|
});
|
|
177
52
|
|
|
178
53
|
describe("Story 8.0a — writeIfChanged()", () => {
|
|
@@ -224,7 +99,7 @@ describe("Story 8.0a — generateOnce()", () => {
|
|
|
224
99
|
snapshotJson,
|
|
225
100
|
JSON.stringify(
|
|
226
101
|
baseSnapshot({
|
|
227
|
-
functions: [
|
|
102
|
+
functions: [v2ActionEntry()],
|
|
228
103
|
}),
|
|
229
104
|
),
|
|
230
105
|
);
|
|
@@ -429,9 +304,9 @@ describe("Story 8.0a — installServerFunctionsHooks() composition", () => {
|
|
|
429
304
|
});
|
|
430
305
|
});
|
|
431
306
|
|
|
432
|
-
describe("
|
|
307
|
+
describe("snapshot validation — version-agnostic metadata guards (trust boundary)", () => {
|
|
433
308
|
it("rejects snapshot whose version contains a newline (would break out of comment)", () => {
|
|
434
|
-
const evil = baseSnapshot({ version: "
|
|
309
|
+
const evil = baseSnapshot({ version: "2\n// injected" });
|
|
435
310
|
expect(() => render(evil)).toThrow(/version.*line break|line break.*version/i);
|
|
436
311
|
});
|
|
437
312
|
|
|
@@ -440,86 +315,28 @@ describe("Story 8.0a — snapshot validation (Chunk 2 M1 — trust-boundary guar
|
|
|
440
315
|
expect(() => render(evil)).toThrow(/generated_at.*line break|line break.*generated_at/i);
|
|
441
316
|
});
|
|
442
317
|
|
|
443
|
-
it("rejects snapshot whose
|
|
444
|
-
const evil = baseSnapshot({
|
|
445
|
-
expect(() => render(evil)).toThrow(/functions.*array/);
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
it("rejects snapshot entry with unknown kind", () => {
|
|
449
|
-
const evil = baseSnapshot({
|
|
450
|
-
functions: [{ ruby_symbol: "foo", js_identifier: "foo", kind: "mutation" }],
|
|
451
|
-
});
|
|
452
|
-
expect(() => render(evil)).toThrow(/invalid kind/);
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
it("rejects snapshot with duplicate js_identifier entries", () => {
|
|
456
|
-
const evil = baseSnapshot({
|
|
457
|
-
functions: [
|
|
458
|
-
{ ruby_symbol: "foo", js_identifier: "foo", kind: "action" },
|
|
459
|
-
{ ruby_symbol: "bar", js_identifier: "foo", kind: "query" },
|
|
460
|
-
],
|
|
461
|
-
});
|
|
462
|
-
expect(() => render(evil)).toThrow(/duplicate js_identifier "foo"/);
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
it("rejects snapshot entry whose js_identifier is a JS reserved word", () => {
|
|
466
|
-
const evil = baseSnapshot({
|
|
467
|
-
functions: [{ ruby_symbol: "delete", js_identifier: "delete", kind: "action" }],
|
|
468
|
-
});
|
|
469
|
-
expect(() => render(evil)).toThrow(/reserved JS word/);
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
it("R12 — rejects snapshot entry whose js_identifier is reserved by ruact " +
|
|
473
|
-
"(`revalidate` — would clash with the helper re-export)", () => {
|
|
474
|
-
const evil = baseSnapshot({
|
|
475
|
-
functions: [{ ruby_symbol: "revalidate", js_identifier: "revalidate", kind: "action" }],
|
|
476
|
-
});
|
|
477
|
-
expect(() => render(evil)).toThrow(/reserved by the ruact runtime\/codegen surface/);
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
it("R12 — rejects snapshot entry whose js_identifier is `_makeRef` " +
|
|
481
|
-
"(would clash with the codegen's runtime import)", () => {
|
|
482
|
-
const evil = baseSnapshot({
|
|
483
|
-
functions: [{ ruby_symbol: "make_ref", js_identifier: "_makeRef", kind: "action" }],
|
|
484
|
-
});
|
|
485
|
-
expect(() => render(evil)).toThrow(/reserved by the ruact runtime\/codegen surface/);
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
it("rejects snapshot whose version contains U+2028 LINE SEPARATOR " +
|
|
489
|
-
"(Pass-2 patch 2026-05-14 — JS LineTerminator parity)", () => {
|
|
490
|
-
const evil = baseSnapshot({ version: "1
// injected" });
|
|
318
|
+
it("rejects snapshot whose version contains U+2028 LINE SEPARATOR (JS LineTerminator parity)", () => {
|
|
319
|
+
const evil = baseSnapshot({ version: "2
// injected" });
|
|
491
320
|
expect(() => render(evil)).toThrow(/U\+2028/);
|
|
492
321
|
});
|
|
493
322
|
|
|
494
|
-
it("rejects snapshot whose generated_at contains U+2029 PARAGRAPH SEPARATOR
|
|
495
|
-
"(Pass-2 patch 2026-05-14)", () => {
|
|
323
|
+
it("rejects snapshot whose generated_at contains U+2029 PARAGRAPH SEPARATOR", () => {
|
|
496
324
|
const evil = baseSnapshot({ generated_at: "2026-05-14
// injected" });
|
|
497
325
|
expect(() => render(evil)).toThrow(/U\+2029/);
|
|
498
326
|
});
|
|
499
327
|
|
|
500
|
-
it("rejects snapshot missing a root key
|
|
328
|
+
it("rejects snapshot missing a root key", () => {
|
|
501
329
|
const evil = { generated_at: "x", functions: [] }; // no version
|
|
502
330
|
expect(() => render(evil)).toThrow(/missing required key "version"/);
|
|
503
331
|
});
|
|
504
332
|
|
|
505
|
-
it("rejects
|
|
506
|
-
|
|
507
|
-
const evil = baseSnapshot({
|
|
508
|
-
functions: [{ ruby_symbol: "", js_identifier: "foo", kind: "action" }],
|
|
509
|
-
});
|
|
510
|
-
expect(() => render(evil)).toThrow(/missing or empty ruby_symbol/);
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
it("rejects entry with missing ruby_symbol field (Pass-2 patch 2026-05-14)", () => {
|
|
514
|
-
const evil = baseSnapshot({
|
|
515
|
-
functions: [{ js_identifier: "foo", kind: "action" }],
|
|
516
|
-
});
|
|
517
|
-
expect(() => render(evil)).toThrow(/missing or empty ruby_symbol/);
|
|
333
|
+
it("rejects when snapshot itself is not an object (e.g., an array)", () => {
|
|
334
|
+
expect(() => render([])).toThrow(/snapshot is not an object/);
|
|
518
335
|
});
|
|
519
336
|
|
|
520
|
-
it("rejects
|
|
521
|
-
|
|
522
|
-
expect(() => render(
|
|
337
|
+
it("rejects an unsupported (non-v2) snapshot version (Story 9.9)", () => {
|
|
338
|
+
const evil = baseSnapshot({ version: 1 });
|
|
339
|
+
expect(() => render(evil)).toThrow(/unsupported snapshot version/);
|
|
523
340
|
});
|
|
524
341
|
});
|
|
525
342
|
|
|
@@ -588,7 +405,7 @@ describe("Story 8.0a — configureServer watcher (Chunk 2 m1 + m2)", () => {
|
|
|
588
405
|
snapshotJson,
|
|
589
406
|
JSON.stringify(
|
|
590
407
|
baseSnapshot({
|
|
591
|
-
functions: [
|
|
408
|
+
functions: [v2ActionEntry()],
|
|
592
409
|
}),
|
|
593
410
|
),
|
|
594
411
|
);
|
|
@@ -644,7 +461,7 @@ describe("Story 8.0a — configureServer watcher (Chunk 2 m1 + m2)", () => {
|
|
|
644
461
|
snapshotJson,
|
|
645
462
|
JSON.stringify(
|
|
646
463
|
baseSnapshot({
|
|
647
|
-
functions: [{
|
|
464
|
+
functions: [v2ActionEntry({ js_identifier: "categories", http_method: "POST", path: "/categories" })],
|
|
648
465
|
}),
|
|
649
466
|
),
|
|
650
467
|
);
|
|
@@ -672,7 +489,7 @@ describe("Story 8.0a — configureServer watcher (Chunk 2 m1 + m2)", () => {
|
|
|
672
489
|
snapshotJson,
|
|
673
490
|
JSON.stringify(
|
|
674
491
|
baseSnapshot({
|
|
675
|
-
functions: [
|
|
492
|
+
functions: [v2ActionEntry()],
|
|
676
493
|
}),
|
|
677
494
|
),
|
|
678
495
|
);
|
|
@@ -687,60 +504,6 @@ describe("Story 8.0a — configureServer watcher (Chunk 2 m1 + m2)", () => {
|
|
|
687
504
|
});
|
|
688
505
|
});
|
|
689
506
|
|
|
690
|
-
describe("Story 8.0a — Ruby parity (Task 8.5)", () => {
|
|
691
|
-
// The Ruby codegen reads the same fixture and must produce byte-identical
|
|
692
|
-
// output. If this test fails, the Ruby OR JS side drifted — diff the outputs
|
|
693
|
-
// and bring them back into sync rather than normalizing in the assertion.
|
|
694
|
-
|
|
695
|
-
const fixture = {
|
|
696
|
-
version: 1,
|
|
697
|
-
generated_at: "2026-05-13T12:34:56Z",
|
|
698
|
-
functions: [
|
|
699
|
-
{
|
|
700
|
-
ruby_symbol: "alpha",
|
|
701
|
-
js_identifier: "alpha",
|
|
702
|
-
kind: "query",
|
|
703
|
-
controller: "AlphaController",
|
|
704
|
-
},
|
|
705
|
-
{
|
|
706
|
-
ruby_symbol: "create_post",
|
|
707
|
-
js_identifier: "createPost",
|
|
708
|
-
kind: "action",
|
|
709
|
-
controller: "PostsController",
|
|
710
|
-
},
|
|
711
|
-
{
|
|
712
|
-
ruby_symbol: "_internal_dump",
|
|
713
|
-
js_identifier: "_internalDump",
|
|
714
|
-
kind: "action",
|
|
715
|
-
controller: "InternalsController",
|
|
716
|
-
},
|
|
717
|
-
],
|
|
718
|
-
};
|
|
719
|
-
|
|
720
|
-
it("Ruby's Codegen.render and JS's render produce byte-identical output", () => {
|
|
721
|
-
const jsOutput = render(fixture);
|
|
722
|
-
|
|
723
|
-
// Shell out to Ruby. The gem lib path is two levels up from this file.
|
|
724
|
-
const gemLibPath = path.resolve(
|
|
725
|
-
path.dirname(new URL(import.meta.url).pathname),
|
|
726
|
-
"..",
|
|
727
|
-
"..",
|
|
728
|
-
"..",
|
|
729
|
-
"lib",
|
|
730
|
-
);
|
|
731
|
-
const fixtureJson = JSON.stringify(fixture);
|
|
732
|
-
const script =
|
|
733
|
-
'require "ruact/server_functions/codegen"; ' +
|
|
734
|
-
'require "json"; ' +
|
|
735
|
-
`print Ruact::ServerFunctions::Codegen.render(JSON.parse('${fixtureJson}'))`;
|
|
736
|
-
const rubyOutput = execFileSync("ruby", ["-I", gemLibPath, "-e", script], {
|
|
737
|
-
encoding: "utf8",
|
|
738
|
-
});
|
|
739
|
-
|
|
740
|
-
expect(jsOutput).toBe(rubyOutput);
|
|
741
|
-
});
|
|
742
|
-
});
|
|
743
|
-
|
|
744
507
|
describe("Story 9.3 — route-driven (v2) render + parity", () => {
|
|
745
508
|
const v2Fixture = {
|
|
746
509
|
version: 2,
|
|
@@ -959,3 +722,177 @@ describe("Story 9.5 — v2 query entries render + parity", () => {
|
|
|
959
722
|
expect(jsOutput).toBe(rubyOutput);
|
|
960
723
|
});
|
|
961
724
|
});
|
|
725
|
+
|
|
726
|
+
describe("Story 13.4 — typed query params render + parity", () => {
|
|
727
|
+
const UNION = "string | number | boolean | null";
|
|
728
|
+
|
|
729
|
+
const typedQueryFixture = {
|
|
730
|
+
version: 2,
|
|
731
|
+
generated_at: "2026-06-26T00:00:00Z",
|
|
732
|
+
functions: [
|
|
733
|
+
// no-kwargs query → bare () signature (byte-identical to 9.5)
|
|
734
|
+
{
|
|
735
|
+
js_identifier: "categories",
|
|
736
|
+
kind: "query",
|
|
737
|
+
http_method: "GET",
|
|
738
|
+
path: "/q/categories",
|
|
739
|
+
segments: [],
|
|
740
|
+
accepts_params: false,
|
|
741
|
+
params: [],
|
|
742
|
+
params_rest: false,
|
|
743
|
+
controller: "CatalogQuery",
|
|
744
|
+
action: "categories",
|
|
745
|
+
},
|
|
746
|
+
// required + optional kwargs → typed object, declaration order
|
|
747
|
+
{
|
|
748
|
+
js_identifier: "searchUsers",
|
|
749
|
+
kind: "query",
|
|
750
|
+
http_method: "GET",
|
|
751
|
+
path: "/q/searchUsers",
|
|
752
|
+
segments: [],
|
|
753
|
+
accepts_params: true,
|
|
754
|
+
params: [
|
|
755
|
+
{ name: "term", required: true },
|
|
756
|
+
{ name: "limit", required: false },
|
|
757
|
+
],
|
|
758
|
+
params_rest: false,
|
|
759
|
+
controller: "CatalogQuery",
|
|
760
|
+
action: "search_users",
|
|
761
|
+
},
|
|
762
|
+
// named key + **keyrest → intersection with the open record
|
|
763
|
+
{
|
|
764
|
+
js_identifier: "withRest",
|
|
765
|
+
kind: "query",
|
|
766
|
+
http_method: "GET",
|
|
767
|
+
path: "/q/withRest",
|
|
768
|
+
segments: [],
|
|
769
|
+
accepts_params: true,
|
|
770
|
+
params: [{ name: "scope", required: true }],
|
|
771
|
+
params_rest: true,
|
|
772
|
+
controller: "CatalogQuery",
|
|
773
|
+
action: "with_rest",
|
|
774
|
+
},
|
|
775
|
+
// **keyrest-only → open record
|
|
776
|
+
{
|
|
777
|
+
js_identifier: "restOnly",
|
|
778
|
+
kind: "query",
|
|
779
|
+
http_method: "GET",
|
|
780
|
+
path: "/q/restOnly",
|
|
781
|
+
segments: [],
|
|
782
|
+
accepts_params: true,
|
|
783
|
+
params: [],
|
|
784
|
+
params_rest: true,
|
|
785
|
+
controller: "CatalogQuery",
|
|
786
|
+
action: "rest_only",
|
|
787
|
+
},
|
|
788
|
+
],
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
it("emits typed params objects (required/optional, keyrest, declaration order)", () => {
|
|
792
|
+
const out = render(typedQueryFixture);
|
|
793
|
+
expect(out).toContain("export const categories: () => Promise<unknown> =");
|
|
794
|
+
expect(out).toContain(
|
|
795
|
+
`export const searchUsers: (params: { term: ${UNION}; limit?: ${UNION} }) => Promise<unknown> =`,
|
|
796
|
+
);
|
|
797
|
+
expect(out).toContain(
|
|
798
|
+
`export const withRest: (params: { scope: ${UNION} } & Record<string, unknown>) => Promise<unknown> =`,
|
|
799
|
+
);
|
|
800
|
+
expect(out).toContain(
|
|
801
|
+
"export const restOnly: (params: Record<string, unknown>) => Promise<unknown> =",
|
|
802
|
+
);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("rejects malformed params metadata (trust boundary)", () => {
|
|
806
|
+
expect(() =>
|
|
807
|
+
render({
|
|
808
|
+
version: 2,
|
|
809
|
+
generated_at: "x",
|
|
810
|
+
functions: [
|
|
811
|
+
{
|
|
812
|
+
js_identifier: "x",
|
|
813
|
+
kind: "query",
|
|
814
|
+
http_method: "GET",
|
|
815
|
+
path: "/q/x",
|
|
816
|
+
segments: [],
|
|
817
|
+
params: [{ name: "q\n// injected", required: true }],
|
|
818
|
+
},
|
|
819
|
+
],
|
|
820
|
+
}),
|
|
821
|
+
).toThrow(/non-empty single-line string/);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it("rejects a non-boolean params_rest (would silently over-widen the type)", () => {
|
|
825
|
+
expect(() =>
|
|
826
|
+
render({
|
|
827
|
+
version: 2,
|
|
828
|
+
generated_at: "x",
|
|
829
|
+
functions: [
|
|
830
|
+
{
|
|
831
|
+
js_identifier: "x",
|
|
832
|
+
kind: "query",
|
|
833
|
+
http_method: "GET",
|
|
834
|
+
path: "/q/x",
|
|
835
|
+
segments: [],
|
|
836
|
+
params: [],
|
|
837
|
+
params_rest: "true",
|
|
838
|
+
},
|
|
839
|
+
],
|
|
840
|
+
}),
|
|
841
|
+
).toThrow(/non-boolean params_rest/);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it("Ruby's render and JS's render produce byte-identical typed-query output", () => {
|
|
845
|
+
const jsOutput = render(typedQueryFixture);
|
|
846
|
+
const gemLibPath = path.resolve(
|
|
847
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
848
|
+
"..",
|
|
849
|
+
"..",
|
|
850
|
+
"..",
|
|
851
|
+
"lib",
|
|
852
|
+
);
|
|
853
|
+
const fixtureJson = JSON.stringify(typedQueryFixture);
|
|
854
|
+
const script =
|
|
855
|
+
'require "ruact/server_functions/codegen"; ' +
|
|
856
|
+
'require "json"; ' +
|
|
857
|
+
`print Ruact::ServerFunctions::Codegen.render(JSON.parse('${fixtureJson}'))`;
|
|
858
|
+
const rubyOutput = execFileSync("ruby", ["-I", gemLibPath, "-e", script], {
|
|
859
|
+
encoding: "utf8",
|
|
860
|
+
});
|
|
861
|
+
expect(jsOutput).toBe(rubyOutput);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it("leaves action + empty modules byte-identical to the pre-13.4 output (regression)", () => {
|
|
865
|
+
const ACTION_ONLY = {
|
|
866
|
+
version: 2,
|
|
867
|
+
generated_at: "2026-06-26T00:00:00Z",
|
|
868
|
+
functions: [
|
|
869
|
+
{ js_identifier: "createPost", kind: "action", http_method: "POST", path: "/posts", segments: [] },
|
|
870
|
+
],
|
|
871
|
+
};
|
|
872
|
+
const expectedAction =
|
|
873
|
+
"// AUTO-GENERATED by vite-plugin-ruact (Story 9.3). DO NOT EDIT.\n" +
|
|
874
|
+
"// Source: Rails route table (version 2)\n" +
|
|
875
|
+
"// Generated at: 2026-06-26T00:00:00Z\n" +
|
|
876
|
+
'import { _makeServerFunction } from "ruact/server-functions-runtime";\n' +
|
|
877
|
+
"\n" +
|
|
878
|
+
"export const createPost: ((args?: FormData | Record<string, unknown>) => Promise<unknown>) " +
|
|
879
|
+
"& ((formData: FormData) => Promise<void>) =\n" +
|
|
880
|
+
' _makeServerFunction({ method: "POST", path: "/posts", segments: [] });\n' +
|
|
881
|
+
"\n" +
|
|
882
|
+
'export { revalidate } from "ruact/server-functions-runtime";\n';
|
|
883
|
+
expect(render(ACTION_ONLY)).toBe(expectedAction);
|
|
884
|
+
|
|
885
|
+
const EMPTY = { version: 2, generated_at: "2026-06-26T00:00:00Z", functions: [] };
|
|
886
|
+
const expectedEmpty =
|
|
887
|
+
"// AUTO-GENERATED by vite-plugin-ruact (Story 9.3). DO NOT EDIT.\n" +
|
|
888
|
+
"// Source: Rails route table (version 2)\n" +
|
|
889
|
+
"// Generated at: 2026-06-26T00:00:00Z\n" +
|
|
890
|
+
'import { _makeServerFunction } from "ruact/server-functions-runtime";\n' +
|
|
891
|
+
"\n" +
|
|
892
|
+
"// (no server functions exposed yet — add a non-GET route on a Ruact::Server controller)\n" +
|
|
893
|
+
"void _makeServerFunction;\n" +
|
|
894
|
+
"\n" +
|
|
895
|
+
'export { revalidate } from "ruact/server-functions-runtime";\n';
|
|
896
|
+
expect(render(EMPTY)).toBe(expectedEmpty);
|
|
897
|
+
});
|
|
898
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "Story 13.4 — type-check ONLY the emitted-accessor type-test fixtures (AC6). `noEmit` + `strict`; resolves the runtime import to the bundled .d.ts so the emitted `_makeQuery` call shape is exercised. Run via `npm run typecheck`.",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"strict": true,
|
|
5
|
+
"noEmit": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"target": "ES2020",
|
|
8
|
+
"module": "ESNext",
|
|
9
|
+
"moduleResolution": "Bundler",
|
|
10
|
+
"exactOptionalPropertyTypes": false,
|
|
11
|
+
"types": [],
|
|
12
|
+
"baseUrl": ".",
|
|
13
|
+
"paths": {
|
|
14
|
+
"ruact/server-functions-runtime": ["../ruact-server-functions-runtime/index.d.ts"]
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"include": ["type-tests/**/*.ts"]
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "Story 14.4 (AC5) — type-check the GENERATED design-system-AGNOSTIC `<Model>{List,Form,DeleteDialog}.tsx` fixtures in ISOLATION against an ambient stub (type-tests/scaffold/agnostic/ambient.d.ts) that declares ONLY React + @/.ruact/server-functions and NO @/components/ui/* module. A stray shadcn import would fail to resolve → the typecheck goes red, proving the agnostic default needs zero design-system resolution. Mirrors tsconfig.scaffold.json (the shadcn isolated typecheck), but scoped to the agnostic subdir so the two ambient stubs never merge. Run via `npm run typecheck`.",
|
|
3
|
+
"//noImplicitAny": "Relaxed ONLY here: native-element event handler params (e.g. `onChange={(e) => …}`) get no contextual type from the permissive ambient `JSX.IntrinsicElements` (no @types/react / lib:dom). The load-bearing types — PostRow, the typed `search` accessor (FR99), the required `posts` prop, the controlled DeleteDialog contract — are all EXPLICITLY annotated and stay fully checked under `strict`.",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"strict": true,
|
|
6
|
+
"noImplicitAny": false,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"target": "ES2020",
|
|
10
|
+
"module": "ESNext",
|
|
11
|
+
"moduleResolution": "Bundler",
|
|
12
|
+
"jsx": "preserve",
|
|
13
|
+
"exactOptionalPropertyTypes": false,
|
|
14
|
+
"types": [],
|
|
15
|
+
"baseUrl": "."
|
|
16
|
+
},
|
|
17
|
+
"include": ["type-tests/scaffold/agnostic/**/*.ts", "type-tests/scaffold/agnostic/**/*.tsx"]
|
|
18
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "Story 10.2 (AC7 / Task 5) — type-check the GENERATED `<Model>List.tsx` fixture in ISOLATION against the ambient shadcn / @tanstack/react-table / server-functions stubs (type-tests/scaffold/ambient.d.ts). `jsx: preserve` type-checks JSX without `lib: dom` / @types/react. Kept separate from tsconfig.json so the strict no-JSX 13.4 type-tests stay untouched. Run via `npm run typecheck` (which runs both).",
|
|
3
|
+
"//noImplicitAny": "Relaxed ONLY here: native-element event handler params (e.g. `onChange={(e) => …}`) get no contextual type from the permissive ambient `JSX.IntrinsicElements` (no @types/react / lib:dom). The load-bearing types — PostRow, the typed `search` accessor (FR99), ColumnDef<Row>, the required `posts` prop — are all EXPLICITLY annotated and stay fully checked under `strict`.",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"strict": true,
|
|
6
|
+
"noImplicitAny": false,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"target": "ES2020",
|
|
10
|
+
"module": "ESNext",
|
|
11
|
+
"moduleResolution": "Bundler",
|
|
12
|
+
"jsx": "preserve",
|
|
13
|
+
"exactOptionalPropertyTypes": false,
|
|
14
|
+
"types": [],
|
|
15
|
+
"baseUrl": "."
|
|
16
|
+
},
|
|
17
|
+
"include": ["type-tests/scaffold/**/*.ts", "type-tests/scaffold/**/*.tsx"],
|
|
18
|
+
"//exclude": "Story 14.4 — the agnostic fixtures live under type-tests/scaffold/agnostic/ with their OWN ambient stub (NO @/components/ui module). Exclude them here so the shadcn ambient (this config) and the agnostic ambient never merge into one compilation; they are type-checked separately via tsconfig.scaffold-agnostic.json.",
|
|
19
|
+
"exclude": ["type-tests/scaffold/agnostic/**"]
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Story 13.4 (AC3 / Task 6) — proves the emitted typed-`params` `export const`
|
|
2
|
+
// type-checks against the runtime `_makeQuery` declaration WITHOUT a cast (so the
|
|
3
|
+
// runtime `.d.ts` needs no widening). This is a faithful copy of what
|
|
4
|
+
// `renderQueryExportV2` emits for a kwargs query: the typed signature annotation
|
|
5
|
+
// on the `const`, assigned the `_makeQuery({ path, kind })` factory result.
|
|
6
|
+
|
|
7
|
+
import { _makeQuery } from "ruact/server-functions-runtime";
|
|
8
|
+
|
|
9
|
+
// Emitted verbatim by the codegen (no cast, no `any`):
|
|
10
|
+
export const searchUsers: (params: {
|
|
11
|
+
term: string | number | boolean | null;
|
|
12
|
+
limit?: string | number | boolean | null;
|
|
13
|
+
}) => Promise<unknown> = _makeQuery({ path: "/q/searchUsers", kind: "query" });
|
|
14
|
+
|
|
15
|
+
// And the no-kwargs shape:
|
|
16
|
+
export const categories: () => Promise<unknown> = _makeQuery({
|
|
17
|
+
path: "/q/categories",
|
|
18
|
+
kind: "query",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// The emitted accessors are callable at their declared shapes.
|
|
22
|
+
void searchUsers({ term: "ada", limit: 10 });
|
|
23
|
+
void categories();
|