ruact 0.0.2 → 0.0.3
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/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +86 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1680 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +89 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +75 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +446 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +429 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +88 -5
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
// Story 8.0a — vitest suite for the server-functions codegen sidecar.
|
|
2
|
+
//
|
|
3
|
+
// Covers the nine cases enumerated in AC9, plus the Ruby↔JS parity test
|
|
4
|
+
// (Task 8.5). The parity test is the load-bearing CI guard against the two
|
|
5
|
+
// codegen implementations drifting apart.
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { EventEmitter } from "node:events";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
render,
|
|
15
|
+
writeIfChanged,
|
|
16
|
+
readSnapshot,
|
|
17
|
+
generateOnce,
|
|
18
|
+
resolvePaths,
|
|
19
|
+
buildConfigContribution,
|
|
20
|
+
installServerFunctionsHooks,
|
|
21
|
+
RUNTIME_IMPORT_SPECIFIER,
|
|
22
|
+
runtimePackagePath,
|
|
23
|
+
} from "./server-functions-codegen.mjs";
|
|
24
|
+
|
|
25
|
+
let tmpdir;
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "ruact-8-0a-"));
|
|
28
|
+
process.env.RUACT_SILENCE_LOG = "1";
|
|
29
|
+
});
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
fs.rmSync(tmpdir, { recursive: true, force: true });
|
|
32
|
+
delete process.env.RUACT_SILENCE_LOG;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const baseSnapshot = (over = {}) => ({
|
|
36
|
+
version: 1,
|
|
37
|
+
generated_at: "2026-05-13T12:34:56Z",
|
|
38
|
+
functions: [],
|
|
39
|
+
...over,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("Story 8.0a — render()", () => {
|
|
43
|
+
it("emits empty module when registry JSON has no functions", () => {
|
|
44
|
+
// Story 8.2 — the empty-registry branch now also emits the
|
|
45
|
+
// `export { revalidate } from "ruact/server-functions-runtime";`
|
|
46
|
+
// line so `import { revalidate } from "@/.ruact/server-functions"`
|
|
47
|
+
// works in projects that have not declared any server actions yet.
|
|
48
|
+
expect(render(baseSnapshot())).toEqual(
|
|
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
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("Story 8.0a — writeIfChanged()", () => {
|
|
179
|
+
it("writes when the file does not exist", () => {
|
|
180
|
+
const file = path.join(tmpdir, "out.ts");
|
|
181
|
+
expect(writeIfChanged(file, "x")).toBe(true);
|
|
182
|
+
expect(fs.readFileSync(file, "utf8")).toBe("x");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("skips writing when content is byte-identical", () => {
|
|
186
|
+
const file = path.join(tmpdir, "out.ts");
|
|
187
|
+
fs.writeFileSync(file, "x");
|
|
188
|
+
const before = fs.statSync(file).mtimeMs;
|
|
189
|
+
expect(writeIfChanged(file, "x")).toBe(false);
|
|
190
|
+
expect(fs.statSync(file).mtimeMs).toBe(before);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("rewrites when content differs", () => {
|
|
194
|
+
const file = path.join(tmpdir, "out.ts");
|
|
195
|
+
fs.writeFileSync(file, "x");
|
|
196
|
+
expect(writeIfChanged(file, "y")).toBe(true);
|
|
197
|
+
expect(fs.readFileSync(file, "utf8")).toBe("y");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("Story 8.0a — generateOnce()", () => {
|
|
202
|
+
function setupSnapshot(snapshot) {
|
|
203
|
+
const jsonPath = path.join(tmpdir, "snap.json");
|
|
204
|
+
fs.writeFileSync(jsonPath, JSON.stringify(snapshot));
|
|
205
|
+
return jsonPath;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
it("emits TS file from JSON snapshot on first call", () => {
|
|
209
|
+
const snapshotJson = setupSnapshot(baseSnapshot());
|
|
210
|
+
const generatedTs = path.join(tmpdir, "out.ts");
|
|
211
|
+
const result = generateOnce(tmpdir, { snapshotJson, generatedTs });
|
|
212
|
+
expect(result.wrote).toBe(true);
|
|
213
|
+
expect(fs.readFileSync(generatedTs, "utf8")).toContain(
|
|
214
|
+
"// AUTO-GENERATED by vite-plugin-ruact",
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("re-emits module when registry JSON mtime changes (Story 8.0a)", () => {
|
|
219
|
+
const snapshotJson = setupSnapshot(baseSnapshot());
|
|
220
|
+
const generatedTs = path.join(tmpdir, "out.ts");
|
|
221
|
+
generateOnce(tmpdir, { snapshotJson, generatedTs });
|
|
222
|
+
|
|
223
|
+
fs.writeFileSync(
|
|
224
|
+
snapshotJson,
|
|
225
|
+
JSON.stringify(
|
|
226
|
+
baseSnapshot({
|
|
227
|
+
functions: [{ ruby_symbol: "demo_ping", js_identifier: "demoPing", kind: "action" }],
|
|
228
|
+
}),
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
const result = generateOnce(tmpdir, { snapshotJson, generatedTs });
|
|
232
|
+
expect(result.wrote).toBe(true);
|
|
233
|
+
expect(fs.readFileSync(generatedTs, "utf8")).toContain("export const demoPing");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("does NOT re-emit when JSON content is byte-identical (write-if-changed)", () => {
|
|
237
|
+
const snapshotJson = setupSnapshot(baseSnapshot());
|
|
238
|
+
const generatedTs = path.join(tmpdir, "out.ts");
|
|
239
|
+
generateOnce(tmpdir, { snapshotJson, generatedTs });
|
|
240
|
+
const result = generateOnce(tmpdir, { snapshotJson, generatedTs });
|
|
241
|
+
expect(result.wrote).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("gracefully handles malformed JSON (returns null snapshot, leaves last-good module)", () => {
|
|
245
|
+
const snapshotJson = path.join(tmpdir, "snap.json");
|
|
246
|
+
fs.writeFileSync(snapshotJson, "{ not json");
|
|
247
|
+
const generatedTs = path.join(tmpdir, "out.ts");
|
|
248
|
+
fs.writeFileSync(generatedTs, "// last-good content");
|
|
249
|
+
const result = generateOnce(tmpdir, { snapshotJson, generatedTs });
|
|
250
|
+
expect(result.wrote).toBe(false);
|
|
251
|
+
expect(result.snapshot).toBeNull();
|
|
252
|
+
expect(fs.readFileSync(generatedTs, "utf8")).toBe("// last-good content");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("returns wrote=false when snapshot file is absent", () => {
|
|
256
|
+
const result = generateOnce(tmpdir, {
|
|
257
|
+
snapshotJson: path.join(tmpdir, "missing.json"),
|
|
258
|
+
generatedTs: path.join(tmpdir, "out.ts"),
|
|
259
|
+
});
|
|
260
|
+
expect(result.wrote).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("Story 8.0a — buildConfigContribution() (AC6 — @ alias)", () => {
|
|
265
|
+
it("registers @ alias as <root>/app/javascript when host config does not define it " +
|
|
266
|
+
"(Chunk 2 patch 2026-05-13 — no placeholder, AC6 shape)", () => {
|
|
267
|
+
const contribution = buildConfigContribution({ root: "/my/app" });
|
|
268
|
+
expect(contribution.resolve.alias["@"]).toBe(path.resolve("/my/app", "app/javascript"));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("falls back to process.cwd() when userConfig.root is absent", () => {
|
|
272
|
+
const contribution = buildConfigContribution({});
|
|
273
|
+
expect(contribution.resolve.alias["@"]).toBe(path.resolve(process.cwd(), "app/javascript"));
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("leaves @ alias intact when host pre-defines it", () => {
|
|
277
|
+
const contribution = buildConfigContribution({
|
|
278
|
+
resolve: { alias: { "@": "/custom/path" } },
|
|
279
|
+
});
|
|
280
|
+
expect(contribution.resolve.alias["@"]).toBeUndefined();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("always registers the runtime alias (regardless of host config)", () => {
|
|
284
|
+
const contribution = buildConfigContribution({});
|
|
285
|
+
expect(contribution.resolve.alias[RUNTIME_IMPORT_SPECIFIER]).toBe(runtimePackagePath());
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("recognizes the array form of resolve.alias", () => {
|
|
289
|
+
const contribution = buildConfigContribution({
|
|
290
|
+
resolve: { alias: [{ find: "@", replacement: "/elsewhere" }] },
|
|
291
|
+
});
|
|
292
|
+
expect(contribution.resolve.alias["@"]).toBeUndefined();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("Story 8.0a — installServerFunctionsHooks() composition", () => {
|
|
297
|
+
it("preserves the host plugin's existing hooks", async () => {
|
|
298
|
+
const calls = [];
|
|
299
|
+
const plugin = {
|
|
300
|
+
name: "vite-plugin-ruact",
|
|
301
|
+
configResolved() {
|
|
302
|
+
calls.push("configResolved");
|
|
303
|
+
},
|
|
304
|
+
buildStart() {
|
|
305
|
+
calls.push("buildStart");
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
installServerFunctionsHooks(plugin);
|
|
309
|
+
|
|
310
|
+
await plugin.configResolved({ root: tmpdir });
|
|
311
|
+
await plugin.buildStart();
|
|
312
|
+
|
|
313
|
+
expect(calls).toEqual(["configResolved", "buildStart"]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("config() returns the resolved @ alias inline (Chunk 2 H1 — drop placeholder + " +
|
|
317
|
+
"configResolved rewrite)", async () => {
|
|
318
|
+
const plugin = { name: "vite-plugin-ruact" };
|
|
319
|
+
installServerFunctionsHooks(plugin);
|
|
320
|
+
|
|
321
|
+
const merged = await plugin.config({ root: "/my/app" }, { mode: "development" });
|
|
322
|
+
expect(merged.resolve.alias["@"]).toBe(path.resolve("/my/app", "app/javascript"));
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("configResolved() re-canonicalizes @ when actual config.root differs from " +
|
|
326
|
+
"userConfig.root (Re-run 2026-05-14)", async () => {
|
|
327
|
+
const plugin = { name: "vite-plugin-ruact" };
|
|
328
|
+
installServerFunctionsHooks(plugin);
|
|
329
|
+
|
|
330
|
+
// config() runs with userConfig.root=/cwd-guess (best effort)
|
|
331
|
+
const merged = await plugin.config({ root: "/cwd-guess" }, { mode: "development" });
|
|
332
|
+
expect(merged.resolve.alias["@"]).toBe(path.resolve("/cwd-guess", "app/javascript"));
|
|
333
|
+
|
|
334
|
+
// Vite then merges in the actual root from elsewhere (e.g., CLI flag) and
|
|
335
|
+
// hands us a resolved config — we must rewrite our best-effort placeholder.
|
|
336
|
+
const resolved = { root: "/real-root", resolve: { alias: { ...merged.resolve.alias } } };
|
|
337
|
+
await plugin.configResolved(resolved);
|
|
338
|
+
expect(resolved.resolve.alias["@"]).toBe(path.resolve("/real-root", "app/javascript"));
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("configResolved() leaves a host-defined @ alias alone even when canonical " +
|
|
342
|
+
"would differ (Re-run 2026-05-14)", async () => {
|
|
343
|
+
const plugin = { name: "vite-plugin-ruact" };
|
|
344
|
+
installServerFunctionsHooks(plugin);
|
|
345
|
+
|
|
346
|
+
await plugin.config(
|
|
347
|
+
{ root: "/cwd-guess", resolve: { alias: { "@": "/host/elsewhere" } } },
|
|
348
|
+
{ mode: "development" },
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const resolved = {
|
|
352
|
+
root: "/real-root",
|
|
353
|
+
resolve: { alias: { "@": "/host/elsewhere" } },
|
|
354
|
+
};
|
|
355
|
+
await plugin.configResolved(resolved);
|
|
356
|
+
// Host's value must survive the canonicalization step
|
|
357
|
+
expect(resolved.resolve.alias["@"]).toBe("/host/elsewhere");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("config() awaits an async upstream handler (Chunk 2 M2 — async hook support)", async () => {
|
|
361
|
+
const calls = [];
|
|
362
|
+
const plugin = {
|
|
363
|
+
name: "vite-plugin-ruact",
|
|
364
|
+
async config() {
|
|
365
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
366
|
+
calls.push("upstream-resolved");
|
|
367
|
+
return { server: { port: 5173 } };
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
installServerFunctionsHooks(plugin);
|
|
371
|
+
|
|
372
|
+
const merged = await plugin.config({ root: tmpdir }, { mode: "development" });
|
|
373
|
+
expect(calls).toEqual(["upstream-resolved"]);
|
|
374
|
+
// upstream survives merge
|
|
375
|
+
expect(merged.server.port).toBe(5173);
|
|
376
|
+
// sidecar contribution wins on resolve.alias
|
|
377
|
+
expect(merged.resolve.alias[RUNTIME_IMPORT_SPECIFIER]).toBe(runtimePackagePath());
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("config() preserves upstream's array-form resolve.alias instead of corrupting it " +
|
|
381
|
+
"into numeric-keyed object (Chunk 2 M2 — alias-array safety)", async () => {
|
|
382
|
+
const plugin = {
|
|
383
|
+
name: "vite-plugin-ruact",
|
|
384
|
+
config() {
|
|
385
|
+
return {
|
|
386
|
+
resolve: {
|
|
387
|
+
alias: [
|
|
388
|
+
{ find: "~components", replacement: "/host/components" },
|
|
389
|
+
{ find: "~lib", replacement: "/host/lib" },
|
|
390
|
+
],
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
installServerFunctionsHooks(plugin);
|
|
396
|
+
|
|
397
|
+
const merged = await plugin.config({ root: "/my/app" }, { mode: "development" });
|
|
398
|
+
// After the patch, merged alias is still array-form (host's shape preserved)
|
|
399
|
+
expect(Array.isArray(merged.resolve.alias)).toBe(true);
|
|
400
|
+
// Sidecar entries prepended (so they take precedence in Vite's top-down order)
|
|
401
|
+
const finds = merged.resolve.alias.map((e) => e.find);
|
|
402
|
+
expect(finds).toContain("~components");
|
|
403
|
+
expect(finds).toContain("~lib");
|
|
404
|
+
expect(finds).toContain("@");
|
|
405
|
+
expect(finds).toContain(RUNTIME_IMPORT_SPECIFIER);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("buildStart() awaits async upstream before running codegen (Chunk 2 M2)", async () => {
|
|
409
|
+
const order = [];
|
|
410
|
+
const plugin = {
|
|
411
|
+
name: "vite-plugin-ruact",
|
|
412
|
+
async buildStart() {
|
|
413
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
414
|
+
order.push("upstream");
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
const snapshotJson = path.join(tmpdir, "snap.json");
|
|
418
|
+
const generatedTs = path.join(tmpdir, "out.ts");
|
|
419
|
+
fs.writeFileSync(snapshotJson, JSON.stringify(baseSnapshot()));
|
|
420
|
+
|
|
421
|
+
installServerFunctionsHooks(plugin, { snapshotJson, generatedTs });
|
|
422
|
+
await plugin.configResolved({ root: tmpdir });
|
|
423
|
+
await plugin.buildStart();
|
|
424
|
+
order.push("after-buildStart");
|
|
425
|
+
|
|
426
|
+
// If upstream weren't awaited, "after-buildStart" would precede "upstream"
|
|
427
|
+
expect(order).toEqual(["upstream", "after-buildStart"]);
|
|
428
|
+
expect(fs.existsSync(generatedTs)).toBe(true);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe("Story 8.0a — snapshot validation (Chunk 2 M1 — trust-boundary guards)", () => {
|
|
433
|
+
it("rejects snapshot whose version contains a newline (would break out of comment)", () => {
|
|
434
|
+
const evil = baseSnapshot({ version: "1\n// injected" });
|
|
435
|
+
expect(() => render(evil)).toThrow(/version.*line break|line break.*version/i);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("rejects snapshot whose generated_at contains a newline", () => {
|
|
439
|
+
const evil = baseSnapshot({ generated_at: "2026-05-13\n// injected" });
|
|
440
|
+
expect(() => render(evil)).toThrow(/generated_at.*line break|line break.*generated_at/i);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("rejects snapshot whose functions field is not an array", () => {
|
|
444
|
+
const evil = baseSnapshot({ functions: "oops" });
|
|
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" });
|
|
491
|
+
expect(() => render(evil)).toThrow(/U\+2028/);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("rejects snapshot whose generated_at contains U+2029 PARAGRAPH SEPARATOR " +
|
|
495
|
+
"(Pass-2 patch 2026-05-14)", () => {
|
|
496
|
+
const evil = baseSnapshot({ generated_at: "2026-05-14
// injected" });
|
|
497
|
+
expect(() => render(evil)).toThrow(/U\+2029/);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("rejects snapshot missing a root key (Pass-2 patch 2026-05-14)", () => {
|
|
501
|
+
const evil = { generated_at: "x", functions: [] }; // no version
|
|
502
|
+
expect(() => render(evil)).toThrow(/missing required key "version"/);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("rejects entry with empty ruby_symbol so we never emit _makeRef(\"\") " +
|
|
506
|
+
"(Pass-2 patch 2026-05-14)", () => {
|
|
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/);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("rejects when snapshot itself is not an object (e.g., an array) " +
|
|
521
|
+
"(Pass-2 patch 2026-05-14)", () => {
|
|
522
|
+
expect(() => render([])).toThrow(/snapshot is not an object/);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
describe("Story 8.0a — readSnapshot() (Chunk 2 M3 — log malformed JSON)", () => {
|
|
527
|
+
it("logs an error and returns null when JSON is malformed", () => {
|
|
528
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
529
|
+
delete process.env.RUACT_SILENCE_LOG;
|
|
530
|
+
try {
|
|
531
|
+
const snap = path.join(tmpdir, "bad.json");
|
|
532
|
+
fs.writeFileSync(snap, "{ not json");
|
|
533
|
+
const result = readSnapshot(snap);
|
|
534
|
+
expect(result).toBeNull();
|
|
535
|
+
expect(errSpy).toHaveBeenCalledTimes(1);
|
|
536
|
+
expect(errSpy.mock.calls[0][0]).toMatch(/failed to parse server-functions bridge JSON/);
|
|
537
|
+
} finally {
|
|
538
|
+
errSpy.mockRestore();
|
|
539
|
+
process.env.RUACT_SILENCE_LOG = "1";
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("returns null silently (no error log) when file is simply absent", () => {
|
|
544
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
545
|
+
delete process.env.RUACT_SILENCE_LOG;
|
|
546
|
+
try {
|
|
547
|
+
expect(readSnapshot(path.join(tmpdir, "missing.json"))).toBeNull();
|
|
548
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
549
|
+
} finally {
|
|
550
|
+
errSpy.mockRestore();
|
|
551
|
+
process.env.RUACT_SILENCE_LOG = "1";
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
describe("Story 8.0a — configureServer watcher (Chunk 2 m1 + m2)", () => {
|
|
557
|
+
function buildMockServer() {
|
|
558
|
+
const watcher = new EventEmitter();
|
|
559
|
+
watcher.add = vi.fn();
|
|
560
|
+
return { watcher };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
it("registers the canonical snapshot path with chokidar (AC9)", async () => {
|
|
564
|
+
const plugin = { name: "vite-plugin-ruact" };
|
|
565
|
+
const snapshotJson = path.join(tmpdir, "snap.json");
|
|
566
|
+
const generatedTs = path.join(tmpdir, "out.ts");
|
|
567
|
+
installServerFunctionsHooks(plugin, { snapshotJson, generatedTs });
|
|
568
|
+
|
|
569
|
+
await plugin.configResolved({ root: tmpdir });
|
|
570
|
+
const server = buildMockServer();
|
|
571
|
+
await plugin.configureServer(server);
|
|
572
|
+
|
|
573
|
+
expect(server.watcher.add).toHaveBeenCalledWith(path.resolve(snapshotJson));
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("re-runs codegen when the watcher emits 'change' for the snapshot path", async () => {
|
|
577
|
+
const plugin = { name: "vite-plugin-ruact" };
|
|
578
|
+
const snapshotJson = path.join(tmpdir, "snap.json");
|
|
579
|
+
const generatedTs = path.join(tmpdir, "out.ts");
|
|
580
|
+
fs.writeFileSync(snapshotJson, JSON.stringify(baseSnapshot()));
|
|
581
|
+
installServerFunctionsHooks(plugin, { snapshotJson, generatedTs });
|
|
582
|
+
|
|
583
|
+
await plugin.configResolved({ root: tmpdir });
|
|
584
|
+
const server = buildMockServer();
|
|
585
|
+
await plugin.configureServer(server);
|
|
586
|
+
|
|
587
|
+
fs.writeFileSync(
|
|
588
|
+
snapshotJson,
|
|
589
|
+
JSON.stringify(
|
|
590
|
+
baseSnapshot({
|
|
591
|
+
functions: [{ ruby_symbol: "demo_ping", js_identifier: "demoPing", kind: "action" }],
|
|
592
|
+
}),
|
|
593
|
+
),
|
|
594
|
+
);
|
|
595
|
+
server.watcher.emit("change", snapshotJson);
|
|
596
|
+
|
|
597
|
+
expect(fs.readFileSync(generatedTs, "utf8")).toContain("export const demoPing");
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("re-runs codegen when the watcher emits 'add' for the snapshot path", async () => {
|
|
601
|
+
const plugin = { name: "vite-plugin-ruact" };
|
|
602
|
+
const snapshotJson = path.join(tmpdir, "snap.json");
|
|
603
|
+
const generatedTs = path.join(tmpdir, "out.ts");
|
|
604
|
+
installServerFunctionsHooks(plugin, { snapshotJson, generatedTs });
|
|
605
|
+
|
|
606
|
+
await plugin.configResolved({ root: tmpdir });
|
|
607
|
+
const server = buildMockServer();
|
|
608
|
+
await plugin.configureServer(server);
|
|
609
|
+
|
|
610
|
+
fs.writeFileSync(snapshotJson, JSON.stringify(baseSnapshot()));
|
|
611
|
+
server.watcher.emit("add", snapshotJson);
|
|
612
|
+
|
|
613
|
+
expect(fs.existsSync(generatedTs)).toBe(true);
|
|
614
|
+
expect(fs.readFileSync(generatedTs, "utf8")).toContain("AUTO-GENERATED");
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("ignores events for unrelated files", async () => {
|
|
618
|
+
const plugin = { name: "vite-plugin-ruact" };
|
|
619
|
+
const snapshotJson = path.join(tmpdir, "snap.json");
|
|
620
|
+
const generatedTs = path.join(tmpdir, "out.ts");
|
|
621
|
+
installServerFunctionsHooks(plugin, { snapshotJson, generatedTs });
|
|
622
|
+
|
|
623
|
+
await plugin.configResolved({ root: tmpdir });
|
|
624
|
+
const server = buildMockServer();
|
|
625
|
+
await plugin.configureServer(server);
|
|
626
|
+
|
|
627
|
+
server.watcher.emit("change", path.join(tmpdir, "other.json"));
|
|
628
|
+
expect(fs.existsSync(generatedTs)).toBe(false);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("matches non-canonical event paths against the snapshot path (Chunk 2 m1 — " +
|
|
632
|
+
"path.resolve canonicalization)", async () => {
|
|
633
|
+
const plugin = { name: "vite-plugin-ruact" };
|
|
634
|
+
const snapshotJson = path.join(tmpdir, "snap.json");
|
|
635
|
+
const generatedTs = path.join(tmpdir, "out.ts");
|
|
636
|
+
fs.writeFileSync(snapshotJson, JSON.stringify(baseSnapshot()));
|
|
637
|
+
installServerFunctionsHooks(plugin, { snapshotJson, generatedTs });
|
|
638
|
+
|
|
639
|
+
await plugin.configResolved({ root: tmpdir });
|
|
640
|
+
const server = buildMockServer();
|
|
641
|
+
await plugin.configureServer(server);
|
|
642
|
+
|
|
643
|
+
fs.writeFileSync(
|
|
644
|
+
snapshotJson,
|
|
645
|
+
JSON.stringify(
|
|
646
|
+
baseSnapshot({
|
|
647
|
+
functions: [{ ruby_symbol: "categories", js_identifier: "categories", kind: "query" }],
|
|
648
|
+
}),
|
|
649
|
+
),
|
|
650
|
+
);
|
|
651
|
+
// Emit a path that's logically the same file but with `./` inserted
|
|
652
|
+
const noncanonical = path.join(path.dirname(snapshotJson), ".", path.basename(snapshotJson));
|
|
653
|
+
server.watcher.emit("change", noncanonical);
|
|
654
|
+
|
|
655
|
+
expect(fs.readFileSync(generatedTs, "utf8")).toContain("export const categories");
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("resolves relative event paths against rootDir, not process.cwd() " +
|
|
659
|
+
"(Re-run m4 2026-05-14)", async () => {
|
|
660
|
+
const plugin = { name: "vite-plugin-ruact" };
|
|
661
|
+
const snapshotJson = path.join(tmpdir, "snap.json");
|
|
662
|
+
const generatedTs = path.join(tmpdir, "out.ts");
|
|
663
|
+
fs.writeFileSync(snapshotJson, JSON.stringify(baseSnapshot()));
|
|
664
|
+
installServerFunctionsHooks(plugin, { snapshotJson, generatedTs });
|
|
665
|
+
|
|
666
|
+
// rootDir is tmpdir; cwd is the test runner's cwd (NOT tmpdir).
|
|
667
|
+
await plugin.configResolved({ root: tmpdir });
|
|
668
|
+
const server = buildMockServer();
|
|
669
|
+
await plugin.configureServer(server);
|
|
670
|
+
|
|
671
|
+
fs.writeFileSync(
|
|
672
|
+
snapshotJson,
|
|
673
|
+
JSON.stringify(
|
|
674
|
+
baseSnapshot({
|
|
675
|
+
functions: [{ ruby_symbol: "demo_ping", js_identifier: "demoPing", kind: "action" }],
|
|
676
|
+
}),
|
|
677
|
+
),
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
// Chokidar can emit a path relative to the watched root (not cwd).
|
|
681
|
+
// path.relative(rootDir, snapshotJson) is "snap.json" — when watcher
|
|
682
|
+
// emits that bare name, we must resolve it against rootDir.
|
|
683
|
+
const relative = path.relative(tmpdir, snapshotJson);
|
|
684
|
+
server.watcher.emit("change", relative);
|
|
685
|
+
|
|
686
|
+
expect(fs.readFileSync(generatedTs, "utf8")).toContain("export const demoPing");
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
|
|
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
|
+
describe("Story 9.3 — route-driven (v2) render + parity", () => {
|
|
745
|
+
const v2Fixture = {
|
|
746
|
+
version: 2,
|
|
747
|
+
generated_at: "2026-06-09T00:00:00Z",
|
|
748
|
+
functions: [
|
|
749
|
+
{
|
|
750
|
+
js_identifier: "createPost",
|
|
751
|
+
kind: "action",
|
|
752
|
+
http_method: "POST",
|
|
753
|
+
path: "/posts",
|
|
754
|
+
segments: [],
|
|
755
|
+
controller: "posts",
|
|
756
|
+
action: "create",
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
js_identifier: "updatePost",
|
|
760
|
+
kind: "action",
|
|
761
|
+
http_method: "PATCH",
|
|
762
|
+
path: "/posts/:id",
|
|
763
|
+
segments: ["id"],
|
|
764
|
+
controller: "posts",
|
|
765
|
+
action: "update",
|
|
766
|
+
},
|
|
767
|
+
],
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
it("dispatches on version 2 and emits _makeServerFunction calls with real path+verb", () => {
|
|
771
|
+
const out = render(v2Fixture);
|
|
772
|
+
expect(out).toContain('import { _makeServerFunction } from "ruact/server-functions-runtime";');
|
|
773
|
+
expect(out).toContain('_makeServerFunction({ method: "POST", path: "/posts", segments: [] });');
|
|
774
|
+
expect(out).toContain('_makeServerFunction({ method: "PATCH", path: "/posts/:id", segments: ["id"] });');
|
|
775
|
+
expect(out).toContain('export { revalidate } from "ruact/server-functions-runtime";');
|
|
776
|
+
// v2 actions keep the Story 8.2 intersection signature (form action support)
|
|
777
|
+
expect(out).toContain("& ((formData: FormData) => Promise<void>)");
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it("emits the empty-v2 module when no routes are exposed", () => {
|
|
781
|
+
const out = render({ version: 2, generated_at: "2026-06-09T00:00:00Z", functions: [] });
|
|
782
|
+
expect(out).toContain("void _makeServerFunction;");
|
|
783
|
+
expect(out).toContain("(no server functions exposed yet");
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("rejects a v2 entry with an invalid http_method", () => {
|
|
787
|
+
expect(() =>
|
|
788
|
+
render({
|
|
789
|
+
version: 2,
|
|
790
|
+
generated_at: "x",
|
|
791
|
+
functions: [
|
|
792
|
+
{ js_identifier: "createPost", kind: "action", http_method: "GET", path: "/posts", segments: [] },
|
|
793
|
+
],
|
|
794
|
+
}),
|
|
795
|
+
).toThrow(/invalid.*http_method/i);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
it("rejects a v2 entry declaring a segment absent from the path", () => {
|
|
799
|
+
expect(() =>
|
|
800
|
+
render({
|
|
801
|
+
version: 2,
|
|
802
|
+
generated_at: "x",
|
|
803
|
+
functions: [
|
|
804
|
+
{ js_identifier: "updatePost", kind: "action", http_method: "PATCH", path: "/posts", segments: ["id"] },
|
|
805
|
+
],
|
|
806
|
+
}),
|
|
807
|
+
).toThrow(/absent from path/);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it("rejects a v2 path with an undeclared dynamic segment (bidirectional guard)", () => {
|
|
811
|
+
expect(() =>
|
|
812
|
+
render({
|
|
813
|
+
version: 2,
|
|
814
|
+
generated_at: "x",
|
|
815
|
+
functions: [
|
|
816
|
+
{ js_identifier: "updatePost", kind: "action", http_method: "PATCH", path: "/posts/:id", segments: [] },
|
|
817
|
+
],
|
|
818
|
+
}),
|
|
819
|
+
).toThrow(/not declared in segments/);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it("rejects a v2 segment that only substring-matches a longer path token", () => {
|
|
823
|
+
expect(() =>
|
|
824
|
+
render({
|
|
825
|
+
version: 2,
|
|
826
|
+
generated_at: "x",
|
|
827
|
+
functions: [
|
|
828
|
+
{ js_identifier: "updatePost", kind: "action", http_method: "PATCH", path: "/posts/:id_extra", segments: ["id"] },
|
|
829
|
+
],
|
|
830
|
+
}),
|
|
831
|
+
).toThrow(/absent from path/);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it("rejects a v2 entry named after the v2 runtime accessor (_makeServerFunction)", () => {
|
|
835
|
+
expect(() =>
|
|
836
|
+
render({
|
|
837
|
+
version: 2,
|
|
838
|
+
generated_at: "x",
|
|
839
|
+
functions: [
|
|
840
|
+
{ js_identifier: "_makeServerFunction", kind: "action", http_method: "POST", path: "/x", segments: [] },
|
|
841
|
+
],
|
|
842
|
+
}),
|
|
843
|
+
).toThrow(/reserved/);
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it("Ruby's render and JS's render produce byte-identical v2 output", () => {
|
|
847
|
+
const jsOutput = render(v2Fixture);
|
|
848
|
+
const gemLibPath = path.resolve(
|
|
849
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
850
|
+
"..",
|
|
851
|
+
"..",
|
|
852
|
+
"..",
|
|
853
|
+
"lib",
|
|
854
|
+
);
|
|
855
|
+
const fixtureJson = JSON.stringify(v2Fixture);
|
|
856
|
+
const script =
|
|
857
|
+
'require "ruact/server_functions/codegen"; ' +
|
|
858
|
+
'require "json"; ' +
|
|
859
|
+
`print Ruact::ServerFunctions::Codegen.render(JSON.parse('${fixtureJson}'))`;
|
|
860
|
+
const rubyOutput = execFileSync("ruby", ["-I", gemLibPath, "-e", script], {
|
|
861
|
+
encoding: "utf8",
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
expect(jsOutput).toBe(rubyOutput);
|
|
865
|
+
});
|
|
866
|
+
});
|