Files
create/test/generate.test.ts
zeekay 2e1b7dcaf8 feat: @luxwallet/create — stamp a branded Lux Wallet from one brand.json
Generator that scaffolds a ready-to-run branded Lux Wallet web app for a new
chain/org from a single BrandConfig. One command per new chain.

- CLI `create-lux-wallet <brand-ref> [outDir] [--force]` + programmatic
  generate(brand, outDir): validates the brand via @luxwallet/brand's
  defineBrand, then stamps apps/web/ (Vite + React) that mounts @luxwallet/ui
  via WalletUIProvider, authenticates with @hanzo/iam (HIP-0111), reaches
  chains through @luxwallet/sdk, and ships the brand's /brand.json.
- DRY runtime-brand.json strategy: ONE brand-neutral app shell, byte-identical
  across brands (enforced by test). Only public/brand.json + public/logo.svg
  differ. No per-brand forks. *.tmpl files get {{brand.*}} interpolation.
- Example brands lux/hanzo/zoo/pars with canonical @luxwallet/chains ids
  (lux-c 96369, zoo 200200, hanzo 36963, pars 494949) + _template.json.
- New chains: add to @luxwallet/chains registry, reference id in brand.chains[].
- MIT, zero GPL — never imports @luxfi/wallet. @luxwallet/* + @hanzo/* are
  workspace deps with a documented switch to npm versions once published.

Verified headlessly: CLI tsc --noEmit clean; 7/7 node:test pass (tree,
brand.json validity via defineBrand, no token leak, DRY shell identity, force,
resolution); sample generation of all four brands validates.
2026-06-23 15:28:28 -07:00

136 lines
4.7 KiB
TypeScript

/**
* Tests for @luxwallet/create. Node's built-in test runner (stdlib, zero deps).
*
* Run the BUILT output (dist/) the way consumers do:
* pnpm build && node --test
*
* (We import from ../dist so the test exercises the shipped artifact, and so it
* runs as plain JS under `node --test` without a TS loader.)
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { generate, loadBrand, listBundledBrands, parseBrand } from "../dist/index.js";
import { defineBrand } from "@luxwallet/brand";
function freshDir(): string {
return mkdtempSync(join(tmpdir(), "lw-create-"));
}
test("listBundledBrands returns the four example brands", () => {
const brands = listBundledBrands();
for (const id of ["lux", "hanzo", "zoo", "pars"]) {
assert.ok(brands.includes(id), `expected bundled brand ${id}`);
}
assert.ok(!brands.includes("_template"), "_template must be excluded");
});
test("every bundled brand validates via defineBrand", () => {
for (const id of listBundledBrands()) {
const brand = loadBrand(id);
assert.equal(brand.id, id);
assert.doesNotThrow(() => defineBrand(brand));
}
});
test("generate stamps a complete tree and a VALID brand.json", () => {
const out = freshDir();
try {
const brand = loadBrand("lux");
const result = generate(brand, out, { force: true });
// Expected files exist.
const web = join(out, "apps", "web");
for (const f of [
"package.json",
"index.html",
"tsconfig.json",
"vite.config.ts",
"src/main.tsx",
"src/App.tsx",
"src/Login.tsx",
"public/brand.json",
"public/logo.svg",
"README.md",
]) {
assert.ok(existsSync(join(web, f)), `missing ${f}`);
}
assert.ok(result.files.length >= 10);
// Emitted brand.json round-trips through defineBrand.
const emitted = parseBrand(readFileSync(join(web, "public", "brand.json"), "utf8"));
assert.equal(emitted.id, "lux");
assert.equal(emitted.iam.clientId, "lux-wallet");
assert.deepEqual(emitted.chains, ["lux-c-mainnet", "lux-x-mainnet", "lux-p-mainnet"]);
} finally {
rmSync(out, { recursive: true, force: true });
}
});
test("no template tokens leak and no .tmpl files are emitted", () => {
const out = freshDir();
try {
generate(loadBrand("hanzo"), out, { force: true });
const web = join(out, "apps", "web");
const pkg = readFileSync(join(web, "package.json"), "utf8");
assert.match(pkg, /"name":\s*"hanzo-wallet-web"/);
assert.ok(!pkg.includes("{{brand"), "package.json still has tokens");
assert.ok(!existsSync(join(web, "package.json.tmpl")), ".tmpl leaked");
const readme = readFileSync(join(web, "README.md"), "utf8");
assert.ok(!readme.includes("{{brand"), "README still has tokens");
} finally {
rmSync(out, { recursive: true, force: true });
}
});
test("app shell is byte-identical across brands (DRY)", () => {
const a = freshDir();
const b = freshDir();
try {
generate(loadBrand("lux"), a, { force: true });
generate(loadBrand("zoo"), b, { force: true });
const shell = [
"src/main.tsx",
"src/App.tsx",
"src/Login.tsx",
"src/vite-env.d.ts",
"index.html",
"tsconfig.json",
"vite.config.ts",
"public/logo.svg",
];
for (const f of shell) {
const fa = readFileSync(join(a, "apps", "web", f), "utf8");
const fb = readFileSync(join(b, "apps", "web", f), "utf8");
assert.equal(fa, fb, `${f} differs between brands — shell must be brand-neutral`);
}
// brand.json MUST differ (that's the whole point).
const ba = readFileSync(join(a, "apps", "web", "public", "brand.json"), "utf8");
const bb = readFileSync(join(b, "apps", "web", "public", "brand.json"), "utf8");
assert.notEqual(ba, bb, "brand.json must differ per brand");
} finally {
rmSync(a, { recursive: true, force: true });
rmSync(b, { recursive: true, force: true });
}
});
test("generate refuses a non-empty dir without force", () => {
const out = freshDir();
try {
generate(loadBrand("lux"), out, { force: true });
assert.throws(() => generate(loadBrand("lux"), out), /not empty/);
// force overwrites cleanly.
assert.doesNotThrow(() => generate(loadBrand("pars"), out, { force: true }));
const emitted = parseBrand(readFileSync(join(out, "apps", "web", "public", "brand.json"), "utf8"));
assert.equal(emitted.id, "pars");
} finally {
rmSync(out, { recursive: true, force: true });
}
});
test("loadBrand rejects an unknown ref", () => {
assert.throws(() => loadBrand("definitely-not-a-brand"), /not found/);
});