gofront 1.1.0 → 1.2.0

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/README.md CHANGED
@@ -395,6 +395,7 @@ gofront <input> --minify minify output (built-in minifier)
395
395
  gofront <input> --minify --mangle minify and rename local identifiers
396
396
  gofront <file.go> --ast dump AST (debug)
397
397
  gofront <file.go> --tokens dump tokens (debug)
398
+ gofront test [dir] [--dom] run unit tests (-v verbose, -run <regex>)
398
399
  gofront prep [dir] copy static assets and bundle vendor dependencies
399
400
  gofront init [dir] scaffold a new project
400
401
  gofront --version / -v print version
@@ -403,6 +404,58 @@ gofront --help / -h print this help
403
404
 
404
405
  ---
405
406
 
407
+ ## Unit testing (`gofront test`)
408
+
409
+ GoFront provides native, Go-idiomatic unit testing out of the box:
410
+
411
+ - Files ending in `*_test.go` are automatically excluded from production builds (`gofront .`, `gofront -o app.js`) and compiled only when running tests.
412
+ - Built-in `testing` standard library package with `*testing.T`.
413
+ - Discover and execute `func TestXxx(t *testing.T)` functions with standard Go-style output.
414
+
415
+ ### Writing a test
416
+
417
+ ```go
418
+ package store
419
+
420
+ import "testing"
421
+
422
+ func TestAddTodo(t *testing.T) {
423
+ store := NewTodoStore()
424
+ store.Add("Buy milk")
425
+
426
+ if store.Count() != 1 {
427
+ t.Errorf("expected 1 item, got %d", store.Count())
428
+ }
429
+ }
430
+ ```
431
+
432
+ Subtests and skips are fully supported:
433
+
434
+ ```go
435
+ func TestFilter(t *testing.T) {
436
+ t.Run("active", func(t *testing.T) {
437
+ // subtest logic
438
+ })
439
+ t.Run("completed", func(t *testing.T) {
440
+ if testing.Short() {
441
+ t.Skip("skipping in short mode")
442
+ }
443
+ })
444
+ }
445
+ ```
446
+
447
+ ### Running tests
448
+
449
+ ```sh
450
+ gofront test # run tests in current directory
451
+ gofront test ./src # run tests in specific directory
452
+ gofront test -v # verbose output (RUN, PASS/FAIL, logs)
453
+ gofront test -run "Todo" # run only tests matching regex pattern
454
+ gofront test --dom # initialize JSDOM for DOM/gom/templ tests
455
+ ```
456
+
457
+ ---
458
+
406
459
  ## Static assets & vendor bundling
407
460
 
408
461
  GoFront includes native tooling to manage static assets and package external npm dependencies for the browser without requiring external wrappers or complex build tools.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gofront",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "author": "Luuk van Venrooij",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,8 +36,11 @@
36
36
  "build:gom": "node src/index.js example/gom/src -o example/gom/app.js",
37
37
  "build:templ": "node src/index.js example/templ/src -o example/templ/app.js",
38
38
  "build:all": "npm run build:simple && npm run build:reactive && npm run build:gom && npm run build:templ",
39
- "test:all": "npm run test:unit && npm run test:e2e",
39
+ "test": "npm run test:unit",
40
+ "test:all": "npm run test:unit && npm run test:examples && npm run test:examples:dom && npm run test:e2e",
40
41
  "test:unit": "node test/unit/run.js",
42
+ "test:examples": "node src/index.js test example/simple/src && node src/index.js test example/simple/src/utils && node src/index.js test example/reactive/src && node src/index.js test example/reactive/src/utils && node src/index.js test example/gom/src && node src/index.js test example/gom/src/utils && node src/index.js test example/templ/src && node src/index.js test example/templ/src/utils",
43
+ "test:examples:dom": "node src/index.js test --dom example/simple/src && node src/index.js test --dom example/gom/src && node src/index.js test --dom example/templ/src",
41
44
  "test:coverage": "c8 --include='src/**' --reporter=text --reporter=lcov node test/unit/run.js",
42
45
  "test:e2e": "npm run build:all && playwright test",
43
46
  "format": "biome format --write .",
package/src/cli-core.js CHANGED
@@ -6,6 +6,7 @@ import { basename, dirname, join, resolve } from "node:path";
6
6
  import { copyAssets } from "./asset-manager.js";
7
7
  import { compileDir, compileSingleFile } from "./compiler.js";
8
8
  import { minify } from "./minifier.js";
9
+ import { runTests } from "./test-runner.js";
9
10
  import { bundleVendor } from "./vendor.js";
10
11
 
11
12
  export function runCompile(inputPath, isDir, options) {
@@ -72,3 +73,8 @@ export async function handlePrep(targetDir, options = {}) {
72
73
  const vendor = await bundleVendor(resolvedTarget, options.vendorConfig);
73
74
  return { assets, vendor };
74
75
  }
76
+
77
+ export async function handleTest(targetDir, options = {}) {
78
+ const resolvedTarget = resolve(targetDir);
79
+ return runTests(resolvedTarget, options);
80
+ }
@@ -345,6 +345,8 @@ export const expressionGenMethods = {
345
345
  return this._genRegexpMethodCall(expr.func.field, expr);
346
346
  if (recvName === "time.Time")
347
347
  return this._genTimeMethodCall(expr.func.field, expr);
348
+ if (recvName === "testing.T")
349
+ return this._genTestingMethodCall(expr.func.field, expr);
348
350
  return undefined;
349
351
  },
350
352
 
@@ -752,6 +754,10 @@ export const expressionGenMethods = {
752
754
  const cmp = `__equal(${l}, ${r})`;
753
755
  return expr.op === "==" ? cmp : `!${cmp}`;
754
756
  }
757
+ if (l === "null" || r === "null") {
758
+ const op = expr.op === "==" ? "==" : "!=";
759
+ return `${l} ${op} ${r}`;
760
+ }
755
761
  }
756
762
  const op = expr.op === "==" ? "===" : expr.op === "!=" ? "!==" : expr.op;
757
763
  return `${l} ${op} ${r}`;
@@ -30,6 +30,7 @@ import {
30
30
  HELPER_PATH_CLEAN,
31
31
  HELPER_S,
32
32
  HELPER_SPRINTF,
33
+ HELPER_TESTING,
33
34
  HELPER_TIME_FMT,
34
35
  HELPER_TIME_PARSE,
35
36
  } from "./runtime.js";
@@ -71,6 +72,7 @@ export class CodeGen {
71
72
  this._usesPathClean = false;
72
73
  this._usesTimeFmt = false;
73
74
  this._usesTimeParse = false;
75
+ this._usesTesting = false;
74
76
  // Iterator (range-over-func) context
75
77
  this._inIteratorBody = false;
76
78
  this._iterDepth = 0;
@@ -137,20 +139,24 @@ export class CodeGen {
137
139
  }
138
140
  }
139
141
 
140
- _callInitAndMain(initNames, program) {
142
+ _callInitAndMain(initNames, program, isTest = false) {
141
143
  for (const name of initNames) this.line(`${name}();`);
142
- if (program.decls.some((d) => d.kind === "FuncDecl" && d.name === "main"))
144
+ if (
145
+ !isTest &&
146
+ program.decls.some((d) => d.kind === "FuncDecl" && d.name === "main")
147
+ )
143
148
  this.line("main();");
144
149
  }
145
150
 
146
- generate(program) {
151
+ generate(program, options = {}) {
152
+ const isTest = options.isTest ?? false;
147
153
  const methods = this._collectDecls(program);
148
154
  this._emitJsImports();
149
155
  this._emitTypeDecls(program, methods);
150
156
  this._emitVarConstDecls(program);
151
157
  const initNames = this._emitFuncDecls(program);
152
- this._callInitAndMain(initNames, program);
153
- this._prependHelpers();
158
+ this._callInitAndMain(initNames, program, isTest);
159
+ this._prependHelpers(isTest);
154
160
  while (this.out[0] === "") this.out.shift();
155
161
  return this.out.join("\n");
156
162
  }
@@ -221,7 +227,8 @@ export class CodeGen {
221
227
  return methods;
222
228
  }
223
229
 
224
- _prependHelpers() {
230
+ _prependHelpers(isTest = false) {
231
+ const needsTesting = isTest || this._usesTesting;
225
232
  const HELPER_MAP = [
226
233
  [this._usesLen, HELPER_LEN],
227
234
  [this._usesAppend, HELPER_APPEND],
@@ -229,12 +236,13 @@ export class CodeGen {
229
236
  [this._usesEqual, HELPER_EQUAL],
230
237
  [this._usesCmul, HELPER_CMUL],
231
238
  [this._usesCdiv, HELPER_CDIV],
232
- [this._usesSprintf, HELPER_SPRINTF],
239
+ [this._usesSprintf || needsTesting, HELPER_SPRINTF],
233
240
  [this._usesError, HELPER_ERROR],
234
241
  [this._usesErrorIs, HELPER_ERROR_IS],
235
242
  [this._usesPathClean, HELPER_PATH_CLEAN],
236
243
  [this._usesTimeFmt, HELPER_TIME_FMT],
237
244
  [this._usesTimeParse, HELPER_TIME_PARSE],
245
+ [needsTesting, HELPER_TESTING],
238
246
  ];
239
247
  const helpers = HELPER_MAP.filter(([flag]) => flag).map(([, h]) => h);
240
248
  if (helpers.length > 0) this.out.unshift(...helpers, "");
@@ -242,14 +250,14 @@ export class CodeGen {
242
250
 
243
251
  // Generate a single bundle from multiple programs (same-package multi-file).
244
252
  // Annotates each decl with its source file index before merging.
245
- generateAll(programs) {
253
+ generateAll(programs, options = {}) {
246
254
  for (let i = 0; i < programs.length; i++) {
247
255
  for (const decl of programs[i].decls) {
248
256
  decl._srcFileIdx = i;
249
257
  }
250
258
  }
251
259
  const merged = { decls: programs.flatMap((p) => p.decls) };
252
- return this.generate(merged);
260
+ return this.generate(merged, options);
253
261
  }
254
262
 
255
263
  // Returns a source map JSON string for the last generate() call.
@@ -56,9 +56,19 @@ export const HELPER_SPRINTF = `var __sprintf = __sprintf || function(f, ...a) {
56
56
  case "s": return pad(String(v == null ? "<nil>" : v), width, false);
57
57
  case "d": return pad(String(Math.trunc(Number(v))), width, zero);
58
58
  case "v": {
59
- if (typeof v === "object" && v !== null && "re" in v && "im" in v) {
60
- const sign = v.im >= 0 ? "+" : "";
61
- return pad("(" + v.re + sign + v.im + "i)", width, false);
59
+ if (typeof v === "object" && v !== null) {
60
+ if ("re" in v && "im" in v) {
61
+ const sign = v.im >= 0 ? "+" : "";
62
+ return pad("(" + v.re + sign + v.im + "i)", width, false);
63
+ }
64
+ if (typeof v.Error === "function") {
65
+ return pad(String(v.Error()), width, false);
66
+ }
67
+ try {
68
+ return pad(JSON.stringify(v), width, false);
69
+ } catch {
70
+ return pad(String(v), width, false);
71
+ }
62
72
  }
63
73
  return pad(String(v == null ? "<nil>" : v), width, false);
64
74
  }
@@ -149,3 +159,108 @@ export const HELPER_TIME_PARSE = `var __timeParse = __timeParse || function(layo
149
159
  return [{ _d: new Date(0) }, "parsing time: cannot parse"];
150
160
  }
151
161
  };`;
162
+
163
+ export const HELPER_TESTING = `var __GoFront_FailNow = __GoFront_FailNow || class extends Error {
164
+ constructor() { super("FailNow"); this.name = "__GoFront_FailNow"; }
165
+ };
166
+ var __GoFront_SkipNow = __GoFront_SkipNow || class extends Error {
167
+ constructor() { super("SkipNow"); this.name = "__GoFront_SkipNow"; }
168
+ };
169
+ var __GoFront_T = __GoFront_T || class {
170
+ constructor(name = "", parent = null) {
171
+ this._name = name;
172
+ this._parent = parent;
173
+ this.failed = false;
174
+ this.skipped = false;
175
+ this._logs = [];
176
+ this._start = typeof performance !== "undefined" ? performance.now() : Date.now();
177
+ }
178
+ Name() { return this._name; }
179
+ Fail() {
180
+ this.failed = true;
181
+ if (this._parent) this._parent.Fail();
182
+ }
183
+ Failed() { return this.failed; }
184
+ FailNow() {
185
+ this.Fail();
186
+ throw new __GoFront_FailNow();
187
+ }
188
+ Log(...args) {
189
+ const msg = args.map(a => {
190
+ if (typeof a === "object" && a !== null) {
191
+ try { return JSON.stringify(a); } catch { return String(a); }
192
+ }
193
+ return String(a);
194
+ }).join(" ");
195
+ this._logs.push(msg);
196
+ }
197
+ Logf(format, ...args) {
198
+ const msg = typeof __sprintf === "function" ? __sprintf(format, ...args) : format;
199
+ this._logs.push(msg);
200
+ }
201
+ Error(...args) {
202
+ this.Fail();
203
+ this.Log(...args);
204
+ }
205
+ Errorf(format, ...args) {
206
+ this.Fail();
207
+ this.Logf(format, ...args);
208
+ }
209
+ Fatal(...args) {
210
+ this.Log(...args);
211
+ this.FailNow();
212
+ }
213
+ Fatalf(format, ...args) {
214
+ this.Logf(format, ...args);
215
+ this.FailNow();
216
+ }
217
+ Skip(...args) {
218
+ this.skipped = true;
219
+ if (args.length > 0) this.Log(...args);
220
+ throw new __GoFront_SkipNow();
221
+ }
222
+ Skipf(format, ...args) {
223
+ this.skipped = true;
224
+ this.Logf(format, ...args);
225
+ throw new __GoFront_SkipNow();
226
+ }
227
+ Skipped() { return this.skipped; }
228
+ Helper() {}
229
+ Run(name, fn) {
230
+ const subName = this._name ? \`\${this._name}/\${name}\` : name;
231
+ const subT = new __GoFront_T(subName, this);
232
+ if (typeof globalThis.__onSubtestStart === "function") globalThis.__onSubtestStart(subName);
233
+ try {
234
+ const res = fn(subT);
235
+ if (res && typeof res.then === "function") {
236
+ return res.then(() => {
237
+ if (typeof globalThis.__onSubtestEnd === "function") globalThis.__onSubtestEnd(subT);
238
+ return !subT.failed;
239
+ }).catch(e => {
240
+ if (e instanceof __GoFront_FailNow) {
241
+ // expected
242
+ } else if (e instanceof __GoFront_SkipNow) {
243
+ // expected
244
+ } else {
245
+ subT.Fail();
246
+ subT.Log("panic: " + (e?.message ?? String(e)));
247
+ }
248
+ if (typeof globalThis.__onSubtestEnd === "function") globalThis.__onSubtestEnd(subT);
249
+ return false;
250
+ });
251
+ }
252
+ } catch (e) {
253
+ if (e instanceof __GoFront_FailNow) {
254
+ // expected on FailNow
255
+ } else if (e instanceof __GoFront_SkipNow) {
256
+ // expected on Skip
257
+ } else {
258
+ subT.Fail();
259
+ subT.Log("panic: " + (e?.message ?? String(e)));
260
+ }
261
+ }
262
+ if (typeof globalThis.__onSubtestEnd === "function") globalThis.__onSubtestEnd(subT);
263
+ return !subT.failed;
264
+ }
265
+ };
266
+ var __testing_T = __GoFront_T;`;
@@ -18,6 +18,7 @@ import { slicesMethods } from "./slices.js";
18
18
  import { sortMethods } from "./sort.js";
19
19
  import { strconvMethods } from "./strconv.js";
20
20
  import { stringsMethods } from "./strings.js";
21
+ import { testingMethods } from "./testing.js";
21
22
  import { timeMethods } from "./time.js";
22
23
  import { unicodeMethods } from "./unicode.js";
23
24
  import { utf8Methods } from "./utf8.js";
@@ -45,6 +46,7 @@ const STDLIB_METHOD_MAP = {
45
46
  rand: "_genRand",
46
47
  utf8: "_genUtf8",
47
48
  path: "_genPath",
49
+ testing: "_genTesting",
48
50
  };
49
51
 
50
52
  const dispatchMethods = {
@@ -94,5 +96,6 @@ export const stdlibGenMethods = {
94
96
  ...timeMethods,
95
97
  ...unicodeMethods,
96
98
  ...utf8Methods,
99
+ ...testingMethods,
97
100
  ...dispatchMethods,
98
101
  };
@@ -0,0 +1,34 @@
1
+ // CodeGen for Go `testing` package and `*testing.T` methods.
2
+
3
+ /** @typedef {import('../index.js').CodeGen} CodeGen */
4
+
5
+ /** @type {ThisType<CodeGen>} */
6
+ export const testingMethods = {
7
+ _genTesting(fn, _a) {
8
+ switch (fn) {
9
+ case "Short":
10
+ return "false";
11
+ case "Verbose":
12
+ return "Boolean(globalThis.__gofront_verbose)";
13
+ }
14
+ return undefined;
15
+ },
16
+
17
+ _genTestingMethodCall(method, expr) {
18
+ this._usesTesting = true;
19
+ const recv = expr.func.expr;
20
+ const t = this.genExpr(recv);
21
+ const args = expr.args
22
+ .map((a) => (a._spread ? `...${this.genExpr(a)}` : this.genExpr(a)))
23
+ .join(", ");
24
+ if (
25
+ method === "Errorf" ||
26
+ method === "Fatalf" ||
27
+ method === "Logf" ||
28
+ method === "Skipf"
29
+ ) {
30
+ this._usesSprintf = true;
31
+ }
32
+ return `${t}.${method}(${args})`;
33
+ },
34
+ };
package/src/compiler.js CHANGED
@@ -62,9 +62,13 @@ function parseGoFrontFile(filePath) {
62
62
  return ast;
63
63
  }
64
64
 
65
- function gwFilesIn(dir) {
65
+ export function gwFilesIn(dir, { includeTests = false } = {}) {
66
66
  return readdirSync(dir)
67
- .filter((f) => f.endsWith(".go") || f.endsWith(".templ"))
67
+ .filter((f) => {
68
+ if (!f.endsWith(".go") && !f.endsWith(".templ")) return false;
69
+ if (!includeTests && f.endsWith("_test.go")) return false;
70
+ return true;
71
+ })
68
72
  .sort() // deterministic order
69
73
  .map((f) => join(dir, f));
70
74
  }
@@ -206,11 +210,17 @@ export function compileSingleFile(inputPath, options = {}) {
206
210
  }
207
211
 
208
212
  export function compileDir(dir, options = {}) {
209
- const files = gwFilesIn(dir);
213
+ const files = gwFilesIn(dir, { includeTests: options.includeTests ?? false });
210
214
  if (files.length === 0) throw new Error(`No .go files found in ${dir}`);
211
215
  return compileFiles(files, { ...options, fromDir: dir });
212
216
  }
213
217
 
218
+ export function compilePackageTests(dir, options = {}) {
219
+ const files = gwFilesIn(dir, { includeTests: true });
220
+ if (files.length === 0) throw new Error(`No .go files found in ${dir}`);
221
+ return compileFiles(files, { ...options, fromDir: dir, isTest: true });
222
+ }
223
+
214
224
  export function compileFiles(files, options = {}) {
215
225
  const fromDir = options.fromDir ?? dirname(resolve(files[0]));
216
226
 
@@ -262,7 +272,9 @@ export function compileFiles(files, options = {}) {
262
272
 
263
273
  // ── 4. Code generation ────────────────────────────────────────
264
274
  const codegen = new CodeGen(checker, jsImports, bundledPackages);
265
- const mainJs = codegen.generateAll(programs);
275
+ const mainJs = codegen.generateAll(programs, {
276
+ isTest: options.isTest ?? false,
277
+ });
266
278
 
267
279
  let js = preambles.length > 0 ? `${preambles.join("\n")}\n${mainJs}` : mainJs;
268
280
 
@@ -278,6 +290,7 @@ export function compileFiles(files, options = {}) {
278
290
  return {
279
291
  pkgName,
280
292
  js,
293
+ programs,
281
294
  exportedSymbols: checker.getExportedSymbols(),
282
295
  exportedTypes: checker.getExportedTypes(),
283
296
  };
package/src/index.js CHANGED
@@ -21,7 +21,13 @@ const { version } = _require("../package.json");
21
21
  import { mkdirSync, statSync, watch, writeFileSync } from "node:fs";
22
22
  import { dirname, resolve } from "node:path";
23
23
  import { copyAssets } from "./asset-manager.js";
24
- import { handleInit, handlePrep, maybeMinify, runCompile } from "./cli-core.js";
24
+ import {
25
+ handleInit,
26
+ handlePrep,
27
+ handleTest,
28
+ maybeMinify,
29
+ runCompile,
30
+ } from "./cli-core.js";
25
31
  import { createDevServer, liveReloadClient } from "./dev-server.js";
26
32
 
27
33
  // ── Parse CLI args ───────────────────────────────────────────
@@ -51,7 +57,8 @@ Usage:
51
57
  gofront <input> --minify --mangle Minify and mangle identifiers
52
58
  gofront <file.go> --ast Dump AST (debug)
53
59
  gofront <file.go> --tokens Dump tokens (debug)
54
- gofront prep [dir] Copy static assets and bundle vendor dependencies
60
+ gofront test [dir] [--dom] Run unit tests (-v verbose, -run <regex>)
61
+ gofront prep [dir] [--minify] Copy static assets and bundle vendor dependencies
55
62
  gofront init [dir] Scaffold a new GoFront project
56
63
  gofront --version Print version
57
64
  `.trim(),
@@ -62,18 +69,27 @@ Usage:
62
69
  // ── prep / vendor subcommand ──────────────────────────────────
63
70
 
64
71
  if (args[0] === "prep" || args[0] === "vendor") {
65
- const targetArg = args[1] ?? ".";
72
+ const hasMinify = args.includes("--minify");
73
+ const filteredArgs = args.slice(1).filter((a) => !a.startsWith("--"));
74
+ const targetArg = filteredArgs[0] ?? ".";
66
75
  const targetDir = resolve(targetArg);
67
76
  try {
68
- const { assets, vendor } = await handlePrep(targetDir);
77
+ const vendorConfig = hasMinify ? { minify: true } : {};
78
+ const { assets, vendor } = await handlePrep(targetDir, {
79
+ vendorConfig,
80
+ });
69
81
  if (assets.copied > 0 || assets.skipped > 0) {
70
82
  console.error(
71
83
  `gofront: copied ${assets.copied} assets (${assets.skipped} skipped)`,
72
84
  );
73
85
  }
74
86
  if (vendor.bundled?.length > 0) {
87
+ const destStr = Array.isArray(vendor.dest)
88
+ ? vendor.dest.join(", ")
89
+ : vendor.dest;
90
+ const minStr = vendor.minify ? " (minified)" : "";
75
91
  console.error(
76
- `gofront: bundled ${vendor.bundled.length} vendor dependencies → ${vendor.dest}`,
92
+ `gofront: bundled ${vendor.bundled.length} vendor dependencies → ${destStr}${minStr}`,
77
93
  );
78
94
  }
79
95
  } catch (e) {
@@ -102,6 +118,45 @@ if (args[0] === "init") {
102
118
  process.exit(0);
103
119
  }
104
120
 
121
+ // ── test subcommand ───────────────────────────────────────────
122
+
123
+ if (args[0] === "test") {
124
+ const flags = args.slice(1);
125
+ let targetDir = ".";
126
+ let runFilter = null;
127
+ const nonFlagArgs = [];
128
+ for (let i = 0; i < flags.length; i++) {
129
+ const arg = flags[i];
130
+ if (arg === "-run" || arg === "--run") {
131
+ runFilter = flags[++i] ?? null;
132
+ continue;
133
+ }
134
+ if (arg.startsWith("-run=") || arg.startsWith("--run=")) {
135
+ runFilter = arg.slice(arg.indexOf("=") + 1);
136
+ continue;
137
+ }
138
+ if (arg.startsWith("-")) continue;
139
+ nonFlagArgs.push(arg);
140
+ }
141
+ if (nonFlagArgs.length > 0) {
142
+ targetDir = nonFlagArgs[0];
143
+ }
144
+ const verbose = flags.includes("-v");
145
+ const dom = flags.includes("--dom");
146
+
147
+ try {
148
+ const result = await handleTest(targetDir, {
149
+ verbose,
150
+ dom,
151
+ run: runFilter,
152
+ });
153
+ process.exit(result.exitCode);
154
+ } catch (e) {
155
+ console.error(`gofront: ${e.message}`);
156
+ process.exit(1);
157
+ }
158
+ }
159
+
105
160
  const inputArg = args[0];
106
161
  const outputFlag = args.indexOf("-o");
107
162
  const outputFile = outputFlag !== -1 ? args[outputFlag + 1] : null;
package/src/resolver.js CHANGED
@@ -47,6 +47,7 @@ const BUILTIN_PACKAGES = new Set([
47
47
  "regexp",
48
48
  "path",
49
49
  "path/filepath",
50
+ "testing",
50
51
  ]);
51
52
 
52
53
  // Returns true for local relative paths (start with ./ or ../)
@@ -0,0 +1,335 @@
1
+ // GoFront native test runner and harness generator.
2
+ // Handles test function discovery, test harness emission, and subprocess execution.
3
+
4
+ import { spawn } from "node:child_process";
5
+ import { createRequire } from "node:module";
6
+ import { basename, join, resolve } from "node:path";
7
+ import { compilePackageTests, gwFilesIn } from "./compiler.js";
8
+
9
+ export function isTestFunc(decl) {
10
+ if (decl.kind !== "FuncDecl") return false;
11
+ if (!decl.name.startsWith("Test")) return false;
12
+ if (decl.params?.length !== 1) return false;
13
+ const pType = decl.params[0].type;
14
+ if (pType?.kind === "PointerType" && pType.base?.name === "testing.T")
15
+ return true;
16
+ if (pType?.kind === "TypeName" && pType.name === "testing.T") return true;
17
+ return false;
18
+ }
19
+
20
+ export function discoverTests(programs) {
21
+ const testNames = [];
22
+ for (const p of programs) {
23
+ for (const d of p.decls) {
24
+ if (isTestFunc(d) && !testNames.includes(d.name)) {
25
+ testNames.push(d.name);
26
+ }
27
+ }
28
+ }
29
+ return testNames;
30
+ }
31
+
32
+ export function generateTestHarness(bundleJs, testNames, options = {}) {
33
+ const runRegex = options.run ? options.run : null;
34
+ const verbose = Boolean(options.verbose);
35
+ const dom = Boolean(options.dom);
36
+ const pkgName = options.pkgName ?? "main";
37
+
38
+ return `
39
+ ${
40
+ dom
41
+ ? `import { JSDOM } from "jsdom";
42
+ const __dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
43
+ url: "http://localhost/",
44
+ pretendToBeVisual: true,
45
+ });
46
+ globalThis.window = __dom.window;
47
+ globalThis.document = __dom.window.document;
48
+ try {
49
+ Object.defineProperty(globalThis, "navigator", {
50
+ value: __dom.window.navigator,
51
+ configurable: true,
52
+ writable: true,
53
+ });
54
+ } catch {
55
+ try { globalThis.navigator = __dom.window.navigator; } catch {}
56
+ }
57
+ globalThis.location = __dom.window.location;
58
+ globalThis.history = __dom.window.history;
59
+ globalThis.HTMLElement = __dom.window.HTMLElement;
60
+ globalThis.Element = __dom.window.Element;
61
+ globalThis.Node = __dom.window.Node;
62
+ globalThis.customElements = __dom.window.customElements;
63
+ `
64
+ : `globalThis.window = globalThis.window ?? null;
65
+ globalThis.document = globalThis.document ?? null;
66
+ `
67
+ }
68
+ globalThis.__gofront_verbose = ${verbose};
69
+
70
+ // ── Compiled Package Bundle ──
71
+ ${bundleJs}
72
+ // ─────────────────────────────
73
+
74
+ async function __runGoFrontSuite() {
75
+ const allTests = ${JSON.stringify(testNames)};
76
+ const runFilter = ${runRegex ? `new RegExp(${JSON.stringify(runRegex)})` : "null"};
77
+ const verbose = ${verbose};
78
+ const pkgName = ${JSON.stringify(pkgName)};
79
+
80
+ const testsToRun = runFilter
81
+ ? allTests.filter((name) => runFilter.test(name))
82
+ : allTests;
83
+
84
+ if (testsToRun.length === 0) {
85
+ if (allTests.length > 0 && runFilter) {
86
+ console.log("testing: warning: no tests to run matching " + ${JSON.stringify(runRegex)});
87
+ }
88
+ console.log("PASS");
89
+ console.log("ok \\t" + pkgName + "\\t0.000s");
90
+ process.exit(0);
91
+ }
92
+
93
+ let totalFailures = 0;
94
+ let totalSkipped = 0;
95
+ const suiteStart = typeof performance !== "undefined" ? performance.now() : Date.now();
96
+
97
+ globalThis.__onSubtestStart = (name) => {
98
+ if (verbose) {
99
+ console.log("=== RUN " + name);
100
+ }
101
+ };
102
+
103
+ globalThis.__onSubtestEnd = (subT) => {
104
+ const elapsed = (((typeof performance !== "undefined" ? performance.now() : Date.now()) - subT._start) / 1000).toFixed(2);
105
+ if (subT.skipped) {
106
+ console.log("--- SKIP: " + subT._name + " (" + elapsed + "s)");
107
+ for (const log of subT._logs) {
108
+ console.log(" " + log);
109
+ }
110
+ } else if (subT.failed) {
111
+ if (!verbose) {
112
+ console.log("=== RUN " + subT._name);
113
+ }
114
+ for (const log of subT._logs) {
115
+ console.log(" " + log);
116
+ }
117
+ console.log("--- FAIL: " + subT._name + " (" + elapsed + "s)");
118
+ } else if (verbose) {
119
+ for (const log of subT._logs) {
120
+ console.log(" " + log);
121
+ }
122
+ console.log("--- PASS: " + subT._name + " (" + elapsed + "s)");
123
+ }
124
+ };
125
+
126
+ const __testRegistry = {
127
+ ${testNames.map((n) => ` ${JSON.stringify(n)}: typeof ${n} !== "undefined" ? ${n} : null`).join(",\n")}
128
+ };
129
+
130
+ for (const name of testsToRun) {
131
+ const fn = __testRegistry[name] ?? (typeof globalThis[name] === "function" ? globalThis[name] : null);
132
+ if (typeof fn !== "function") {
133
+ console.error("testing: cannot find test function " + name);
134
+ totalFailures++;
135
+ continue;
136
+ }
137
+
138
+ const t = new __GoFront_T(name);
139
+ if (verbose) {
140
+ console.log("=== RUN " + name);
141
+ }
142
+
143
+ try {
144
+ const res = fn(t);
145
+ if (res && typeof res.then === "function") {
146
+ await res;
147
+ }
148
+ } catch (e) {
149
+ if (e instanceof __GoFront_FailNow) {
150
+ // expected on FailNow
151
+ } else if (e instanceof __GoFront_SkipNow) {
152
+ // expected on SkipNow
153
+ } else {
154
+ t.Fail();
155
+ t.Log("panic: " + (e?.message ?? String(e)));
156
+ }
157
+ }
158
+
159
+ const elapsed = (((typeof performance !== "undefined" ? performance.now() : Date.now()) - t._start) / 1000).toFixed(2);
160
+
161
+ if (t.skipped) {
162
+ totalSkipped++;
163
+ console.log("--- SKIP: " + name + " (" + elapsed + "s)");
164
+ for (const log of t._logs) {
165
+ console.log(" " + log);
166
+ }
167
+ } else if (t.failed) {
168
+ totalFailures++;
169
+ if (!verbose) {
170
+ console.log("=== RUN " + name);
171
+ }
172
+ for (const log of t._logs) {
173
+ console.log(" " + log);
174
+ }
175
+ console.log("--- FAIL: " + name + " (" + elapsed + "s)");
176
+ } else {
177
+ if (verbose) {
178
+ for (const log of t._logs) {
179
+ console.log(" " + log);
180
+ }
181
+ console.log("--- PASS: " + name + " (" + elapsed + "s)");
182
+ }
183
+ }
184
+ }
185
+
186
+ const suiteElapsed = (((typeof performance !== "undefined" ? performance.now() : Date.now()) - suiteStart) / 1000).toFixed(3);
187
+
188
+ if (totalFailures > 0) {
189
+ console.log("FAIL");
190
+ console.log("FAIL\\t" + pkgName + "\\t" + suiteElapsed + "s");
191
+ process.exit(1);
192
+ } else {
193
+ console.log("PASS");
194
+ console.log("ok \\t" + pkgName + "\\t" + suiteElapsed + "s");
195
+ process.exit(0);
196
+ }
197
+ }
198
+
199
+ __runGoFrontSuite();
200
+ `;
201
+ }
202
+
203
+ function validateTestOptions(options, resolvedDir) {
204
+ if (options.run) {
205
+ try {
206
+ new RegExp(options.run);
207
+ } catch (e) {
208
+ throw new Error(`gofront: invalid regex for -run: ${e.message}`);
209
+ }
210
+ }
211
+
212
+ if (options.dom) {
213
+ let jsdomFound = false;
214
+ try {
215
+ const req = createRequire(join(resolvedDir, "dummy.js"));
216
+ req.resolve("jsdom");
217
+ jsdomFound = true;
218
+ } catch {}
219
+ if (!jsdomFound) {
220
+ try {
221
+ const req = createRequire(import.meta.url);
222
+ req.resolve("jsdom");
223
+ jsdomFound = true;
224
+ } catch {}
225
+ }
226
+ if (!jsdomFound) {
227
+ throw new Error(
228
+ "gofront: --dom requires 'jsdom' to be installed. Run 'npm install --save-dev jsdom' to enable DOM testing.",
229
+ );
230
+ }
231
+ }
232
+ }
233
+
234
+ function reportEmptyTests(label, reason, options) {
235
+ const out = `? \t${label}\t[${reason}]\n`;
236
+ if (options.captureOutput) {
237
+ return { exitCode: 0, stdout: out, stderr: "", noTests: true };
238
+ }
239
+ const dest = options.stdout ?? process.stdout;
240
+ dest.write(out);
241
+ return { exitCode: 0, noTests: true };
242
+ }
243
+
244
+ function spawnTestRunner(resolvedDir, harnessJs, testCount, options) {
245
+ return new Promise((resolvePromise, rejectPromise) => {
246
+ const child = spawn(process.execPath, ["--input-type=module", "-"], {
247
+ cwd: resolvedDir,
248
+ env: process.env,
249
+ stdio: ["pipe", "pipe", "pipe"],
250
+ });
251
+
252
+ let stdout = "";
253
+ let stderr = "";
254
+
255
+ child.stdout.on("data", (chunk) => {
256
+ const str = chunk.toString();
257
+ if (options.captureOutput) {
258
+ stdout += str;
259
+ } else {
260
+ (options.stdout ?? process.stdout).write(str);
261
+ }
262
+ });
263
+
264
+ child.stderr.on("data", (chunk) => {
265
+ const str = chunk.toString();
266
+ if (options.captureOutput) {
267
+ stderr += str;
268
+ } else {
269
+ (options.stderr ?? process.stderr).write(str);
270
+ }
271
+ });
272
+
273
+ child.on("error", (err) => {
274
+ rejectPromise(err);
275
+ });
276
+
277
+ child.on("close", (exitCode) => {
278
+ resolvePromise({
279
+ exitCode: exitCode ?? 0,
280
+ stdout,
281
+ stderr,
282
+ testCount,
283
+ });
284
+ });
285
+
286
+ child.stdin.on("error", () => {});
287
+ child.stdin.end(harnessJs);
288
+ });
289
+ }
290
+
291
+ export async function runTests(targetDir, options = {}) {
292
+ const resolvedDir = resolve(targetDir);
293
+ validateTestOptions(options, resolvedDir);
294
+
295
+ const allFiles = gwFilesIn(resolvedDir, { includeTests: true });
296
+ if (allFiles.length === 0) {
297
+ throw new Error(`No .go files found in ${targetDir}`);
298
+ }
299
+
300
+ const testFiles = allFiles.filter((f) => f.endsWith("_test.go"));
301
+ if (testFiles.length === 0) {
302
+ return reportEmptyTests(basename(resolvedDir), "no test files", options);
303
+ }
304
+
305
+ let compiled;
306
+ try {
307
+ compiled = compilePackageTests(resolvedDir, options);
308
+ } catch (err) {
309
+ const out = `FAIL\t${basename(resolvedDir)} [build failed]\n${err?.message ?? String(err)}\n`;
310
+ if (options.captureOutput) {
311
+ return { exitCode: 1, stdout: "", stderr: out };
312
+ }
313
+ const dest = options.stderr ?? process.stderr;
314
+ dest.write(out);
315
+ return { exitCode: 1 };
316
+ }
317
+
318
+ const { pkgName, js, programs } = compiled;
319
+ const testNames = discoverTests(programs);
320
+
321
+ if (testNames.length === 0) {
322
+ return reportEmptyTests(pkgName, "no tests to run", options);
323
+ }
324
+
325
+ const harnessJs = generateTestHarness(bundleJsClean(js), testNames, {
326
+ ...options,
327
+ pkgName,
328
+ });
329
+
330
+ return spawnTestRunner(resolvedDir, harnessJs, testNames.length, options);
331
+ }
332
+
333
+ function bundleJsClean(js) {
334
+ return js;
335
+ }
@@ -225,7 +225,19 @@ export class TypeChecker {
225
225
  _checkTopDeclsPass(programs) {
226
226
  for (const p of programs) {
227
227
  this._setCurrentFile(p);
228
- for (const d of p.decls) this.checkTopDecl(d, this.globals);
228
+ for (const d of p.decls) {
229
+ if (d.kind === "VarDecl" || d.kind === "ConstDecl") {
230
+ this.checkTopDecl(d, this.globals);
231
+ }
232
+ }
233
+ }
234
+ for (const p of programs) {
235
+ this._setCurrentFile(p);
236
+ for (const d of p.decls) {
237
+ if (d.kind === "FuncDecl" || d.kind === "MethodDecl") {
238
+ this.checkTopDecl(d, this.globals);
239
+ }
240
+ }
229
241
  }
230
242
  }
231
243
 
@@ -308,6 +308,64 @@ export function setupExtendedGlobals(globals, types) {
308
308
  },
309
309
  });
310
310
 
311
+ const TESTING_T = {
312
+ kind: "named",
313
+ name: "testing.T",
314
+ underlying: { kind: "struct", fields: new Map(), methods: new Map() },
315
+ };
316
+ const TESTING_PTR = { kind: "pointer", base: TESTING_T };
317
+
318
+ TESTING_T.underlying.methods = new Map([
319
+ ["Error", { kind: "func", params: [ANY], returns: [VOID], variadic: true }],
320
+ [
321
+ "Errorf",
322
+ { kind: "func", params: [STRING, ANY], returns: [VOID], variadic: true },
323
+ ],
324
+ ["Fatal", { kind: "func", params: [ANY], returns: [VOID], variadic: true }],
325
+ [
326
+ "Fatalf",
327
+ { kind: "func", params: [STRING, ANY], returns: [VOID], variadic: true },
328
+ ],
329
+ ["Fail", { kind: "func", params: [], returns: [VOID] }],
330
+ ["Failed", { kind: "func", params: [], returns: [BOOL] }],
331
+ ["FailNow", { kind: "func", params: [], returns: [VOID] }],
332
+ ["Log", { kind: "func", params: [ANY], returns: [VOID], variadic: true }],
333
+ [
334
+ "Logf",
335
+ { kind: "func", params: [STRING, ANY], returns: [VOID], variadic: true },
336
+ ],
337
+ ["Skip", { kind: "func", params: [ANY], returns: [VOID], variadic: true }],
338
+ [
339
+ "Skipf",
340
+ { kind: "func", params: [STRING, ANY], returns: [VOID], variadic: true },
341
+ ],
342
+ ["Skipped", { kind: "func", params: [], returns: [BOOL] }],
343
+ ["Helper", { kind: "func", params: [], returns: [VOID] }],
344
+ [
345
+ "Run",
346
+ {
347
+ kind: "func",
348
+ params: [
349
+ STRING,
350
+ { kind: "func", params: [TESTING_PTR], returns: [VOID] },
351
+ ],
352
+ returns: [BOOL],
353
+ },
354
+ ],
355
+ ["Name", { kind: "func", params: [], returns: [STRING] }],
356
+ ]);
357
+
358
+ globals.define("testing", {
359
+ kind: "namespace",
360
+ name: "testing",
361
+ members: {
362
+ T: TESTING_T,
363
+ Short: { kind: "func", params: [], returns: [BOOL] },
364
+ Verbose: { kind: "func", params: [], returns: [BOOL] },
365
+ },
366
+ });
367
+ types.set("testing.T", TESTING_T);
368
+
311
369
  for (const name of [
312
370
  "append",
313
371
  "len",
package/src/vendor.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // Dynamically loads a bundler (rolldown or esbuild) from consumer devDependencies.
3
3
 
4
4
  import {
5
+ copyFileSync,
5
6
  existsSync,
6
7
  mkdirSync,
7
8
  readFileSync,
@@ -112,19 +113,85 @@ export function loadVendorConfig(projectDir) {
112
113
 
113
114
  let dest = null;
114
115
  let packages = dependencies;
116
+ let minify = false;
115
117
 
116
118
  if (typeof vendorConfig === "string") {
117
119
  dest = vendorConfig;
118
120
  } else if (vendorConfig && typeof vendorConfig === "object") {
119
121
  if (vendorConfig.dest) dest = vendorConfig.dest;
120
122
  if (Array.isArray(vendorConfig.packages)) packages = vendorConfig.packages;
123
+ if (typeof vendorConfig.minify === "boolean") minify = vendorConfig.minify;
121
124
  }
122
125
 
123
126
  if (!dest) {
124
127
  dest = existsSync(join(projectDir, "app")) ? "app/vendor.js" : "vendor.js";
125
128
  }
126
129
 
127
- return { dest, packages };
130
+ return { dest, packages, minify };
131
+ }
132
+
133
+ export function resolveDestinationPaths(projectRoot, dest) {
134
+ const destList = Array.isArray(dest) ? dest : [dest];
135
+ if (destList.length === 0) {
136
+ throw new Error(
137
+ "gofront: at least one vendor destination must be specified",
138
+ );
139
+ }
140
+
141
+ return destList.map((d) => {
142
+ const fullPath = resolve(projectRoot, d);
143
+ const relDest = relative(projectRoot, fullPath);
144
+ if (relDest.startsWith("..") || isAbsolute(relDest)) {
145
+ throw new Error(
146
+ `gofront: vendor destination '${d}' is outside project directory`,
147
+ );
148
+ }
149
+ return fullPath;
150
+ });
151
+ }
152
+
153
+ async function runBundler(
154
+ bundlerInfo,
155
+ { projectRoot, entryFile, destPath, minify },
156
+ ) {
157
+ if (bundlerInfo.name === "rolldown") {
158
+ const rolldownModule =
159
+ bundlerInfo.instance ??
160
+ (await import(pathToFileURL(bundlerInfo.path).href));
161
+ const rolldownFn =
162
+ rolldownModule.rolldown ||
163
+ rolldownModule.default?.rolldown ||
164
+ rolldownModule.default;
165
+
166
+ const bundle = await rolldownFn({
167
+ input: entryFile,
168
+ cwd: projectRoot,
169
+ });
170
+ await bundle.write({
171
+ file: destPath,
172
+ format: "esm",
173
+ minify: Boolean(minify),
174
+ });
175
+ await bundle.close();
176
+ return;
177
+ }
178
+
179
+ if (bundlerInfo.name === "esbuild") {
180
+ const esbuildModule =
181
+ bundlerInfo.instance ??
182
+ (await import(pathToFileURL(bundlerInfo.path).href));
183
+ const buildFn =
184
+ esbuildModule.build || esbuildModule.default?.build || esbuildModule;
185
+
186
+ await buildFn({
187
+ entryPoints: [entryFile],
188
+ bundle: true,
189
+ format: "esm",
190
+ outfile: destPath,
191
+ absWorkingDir: projectRoot,
192
+ minify: Boolean(minify),
193
+ });
194
+ }
128
195
  }
129
196
 
130
197
  export async function bundleVendor(projectDir = ".", options = {}) {
@@ -133,24 +200,19 @@ export async function bundleVendor(projectDir = ".", options = {}) {
133
200
 
134
201
  const packages = options.packages ?? config.packages;
135
202
  const dest = options.dest ?? config.dest;
203
+ const minify = options.minify ?? config.minify ?? false;
136
204
 
137
205
  if (!packages || packages.length === 0) {
138
206
  return {
139
207
  bundled: [],
140
208
  skipped: 0,
141
209
  dest,
210
+ minify: Boolean(minify),
142
211
  message: "no external dependencies found",
143
212
  };
144
213
  }
145
214
 
146
- const destPath = resolve(projectRoot, dest);
147
- const relDest = relative(projectRoot, destPath);
148
- if (relDest.startsWith("..") || isAbsolute(relDest)) {
149
- throw new Error(
150
- `gofront: vendor destination '${dest}' is outside project directory`,
151
- );
152
- }
153
-
215
+ const destPaths = resolveDestinationPaths(projectRoot, dest);
154
216
  const bundlerInfo = options.bundler ?? findBundler(projectRoot);
155
217
  if (!bundlerInfo) {
156
218
  console.warn(
@@ -161,20 +223,28 @@ export async function bundleVendor(projectDir = ".", options = {}) {
161
223
  skipped: packages.length,
162
224
  dest,
163
225
  bundler: null,
226
+ minify: Boolean(minify),
164
227
  reason: "no bundler installed",
165
228
  };
166
229
  }
167
230
 
168
- mkdirSync(dirname(destPath), { recursive: true });
231
+ for (const p of destPaths) {
232
+ mkdirSync(dirname(p), { recursive: true });
233
+ }
169
234
 
170
- // Allow custom bundler execution function (useful for tests and plugins)
171
235
  if (typeof bundlerInfo.bundle === "function") {
172
236
  await bundlerInfo.bundle({
173
237
  projectRoot,
174
238
  packages,
175
- dest: destPath,
239
+ dest: destPaths.length === 1 ? destPaths[0] : destPaths,
240
+ minify: Boolean(minify),
176
241
  });
177
- return { bundled: packages, bundler: bundlerInfo.name ?? "custom", dest };
242
+ return {
243
+ bundled: packages,
244
+ bundler: bundlerInfo.name ?? "custom",
245
+ dest,
246
+ minify: Boolean(minify),
247
+ };
178
248
  }
179
249
 
180
250
  const entryCode = generateVendorEntry(packages);
@@ -185,38 +255,15 @@ export async function bundleVendor(projectDir = ".", options = {}) {
185
255
  writeFileSync(entryFile, entryCode);
186
256
 
187
257
  try {
188
- if (bundlerInfo.name === "rolldown") {
189
- const rolldownModule =
190
- bundlerInfo.instance ??
191
- (await import(pathToFileURL(bundlerInfo.path).href));
192
- const rolldownFn =
193
- rolldownModule.rolldown ||
194
- rolldownModule.default?.rolldown ||
195
- rolldownModule.default;
196
-
197
- const bundle = await rolldownFn({
198
- input: entryFile,
199
- cwd: projectRoot,
200
- });
201
- await bundle.write({
202
- file: destPath,
203
- format: "esm",
204
- });
205
- await bundle.close();
206
- } else if (bundlerInfo.name === "esbuild") {
207
- const esbuildModule =
208
- bundlerInfo.instance ??
209
- (await import(pathToFileURL(bundlerInfo.path).href));
210
- const buildFn =
211
- esbuildModule.build || esbuildModule.default?.build || esbuildModule;
212
-
213
- await buildFn({
214
- entryPoints: [entryFile],
215
- bundle: true,
216
- format: "esm",
217
- outfile: destPath,
218
- absWorkingDir: projectRoot,
219
- });
258
+ await runBundler(bundlerInfo, {
259
+ projectRoot,
260
+ entryFile,
261
+ destPath: destPaths[0],
262
+ minify,
263
+ });
264
+
265
+ for (let i = 1; i < destPaths.length; i++) {
266
+ copyFileSync(destPaths[0], destPaths[i]);
220
267
  }
221
268
  } finally {
222
269
  if (existsSync(entryFile)) {
@@ -224,5 +271,10 @@ export async function bundleVendor(projectDir = ".", options = {}) {
224
271
  }
225
272
  }
226
273
 
227
- return { bundled: packages, bundler: bundlerInfo.name, dest };
274
+ return {
275
+ bundled: packages,
276
+ bundler: bundlerInfo.name,
277
+ dest,
278
+ minify: Boolean(minify),
279
+ };
228
280
  }