clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/prf-provision-custody
lofi / package / create_core_test.ts
create_core_test.ts 10.8K
1import { join } from "node:path";2import packageManifest from "../deno.json" with { type: "json" };3import { createProject } from "./create_core.ts";4import { LOFI_VERSION } from "./version.ts";56function assert(condition: unknown, message: string): asserts condition {7 if (!condition) throw new Error(message);8}910function assertEquals(actual: unknown, expected: unknown): void {11 const canonical = (value: unknown): unknown => {12 if (Array.isArray(value)) return value.map(canonical);13 if (value !== null && typeof value === "object") {14 return Object.fromEntries(15 Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(16 ([key, child]) => [key, canonical(child)],17 ),18 );19 }20 return value;21 };22 const actualJson = JSON.stringify(canonical(actual));23 const expectedJson = JSON.stringify(canonical(expected));24 if (actualJson !== expectedJson) {25 throw new Error(`expected ${expectedJson}, received ${actualJson}`);26 }27}2829async function assertRejects(30 action: () => Promise<unknown>,31 messageIncludes: string,32): Promise<void> {33 try {34 await action();35 } catch (error) {36 const message = error instanceof Error ? error.message : String(error);37 assert(message.includes(messageIncludes), `expected error to include ${messageIncludes}`);38 return;39 }40 throw new Error("expected promise to reject");41}4243async function fileManifest(root: string): Promise<Record<string, string>> {44 const manifest: Record<string, string> = {};45 async function visit(path: string, prefix = "") {46 const entries = [...Deno.readDirSync(path)].sort((left, right) =>47 left.name.localeCompare(right.name)48 );49 for (const entry of entries) {50 const relative = prefix ? `${prefix}/${entry.name}` : entry.name;51 const child = join(path, entry.name);52 if (entry.isDirectory) await visit(child, relative);53 else if (entry.isFile) {54 const digest = await crypto.subtle.digest("SHA-256", await Deno.readFile(child));55 manifest[relative] = Array.from(new Uint8Array(digest))56 .map((byte) => byte.toString(16).padStart(2, "0"))57 .join("");58 }59 }60 }61 await visit(root);62 return manifest;63}6465function snapshotDelta(66 actual: Readonly<Record<string, string>>,67 expected: Readonly<Record<string, string>>,68): string[] {69 return [...new Set([...Object.keys(actual), ...Object.keys(expected)])].sort().filter((path) =>70 actual[path] !== expected[path]71 );72}7374function makeTestRoot(): Promise<string> {75 return Deno.makeTempDir({ dir: ".", prefix: ".lofi-create-test-" });76}7778Deno.test("createProject materializes the complete starter snapshot", async () => {79 const cwd = await makeTestRoot();80 try {81 const result = await createProject({ cwd, name: "starter" });82 assertEquals(result.nextCommands, ["cd starter", "deno task dev"]);83 const actual = await fileManifest(result.destination);84 const expected = JSON.parse(85 await Deno.readTextFile(new URL("./testdata/starter.snapshot.json", import.meta.url)),86 );87 if (Deno.env.get("LOFI_UPDATE_SNAPSHOT") === "1") {88 await Deno.writeTextFile(89 new URL("./testdata/starter.snapshot.json", import.meta.url),90 `${JSON.stringify(actual, null, 2)}\n`,91 );92 } else {93 const changed = snapshotDelta(actual, expected);94 assert(95 changed.length === 0,96 `generated starter snapshot changed: ${97 changed.join(", ")98 }. Review the generated files, then run \`deno task test:update:create\` to accept them.`,99 );100 }101 const config = JSON.parse(await Deno.readTextFile(join(result.destination, "deno.json")));102 assertEquals(config.imports["@nzip/lofi"], "jsr:@nzip/lofi@0.5.0");103 assertEquals(config.imports["@nzip/lofi/"], "jsr:@nzip/lofi@0.5.0/");104 assertEquals(config.imports["@nzip/lofi/testing"], "jsr:@nzip/lofi@0.5.0/testing");105 const lofiSpecifiers = Object.entries(config.imports)106 .filter(([name]) => name.startsWith("@nzip/lofi/"))107 .map(([, specifier]) => String(specifier));108 assert(109 lofiSpecifiers.length === 19,110 `expected one package prefix, access, schema, two integrations, six commands, seven recipes, and testing, received ${lofiSpecifiers.length}`,111 );112 assert(113 lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.5.0/")),114 "generated lofi commands do not resolve through one exact package version",115 );116 const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];117 for (118 const icon of [119 "apple-touch-icon.png",120 "icon-192.png",121 "icon-512.png",122 "icon-maskable-512.png",123 ]124 ) {125 const bytes = await Deno.readFile(join(result.destination, "public", icon));126 assert(127 pngSignature.every((byte, index) => bytes[index] === byte),128 `generated ${icon} was not copied as binary PNG data`,129 );130 }131 } finally {132 await Deno.remove(cwd, { recursive: true });133 }134});135136Deno.test("package manifest and generated version stay coupled", () => {137 assertEquals(packageManifest.name, "@nzip/lofi");138 assertEquals(packageManifest.version, LOFI_VERSION);139 assert(140 !Object.keys(packageManifest.imports).some((name) => name.startsWith("@nzip/lofi")),141 "published package config must not redirect its own public imports to local files",142 );143 assertEquals(Object.keys(packageManifest.exports).sort(), [144 ".",145 "./access",146 "./astro",147 "./build",148 "./create",149 "./dev",150 "./doctor",151 "./preact",152 "./preview",153 "./provision",154 "./recipes/file-handler",155 "./recipes/launch-handler",156 "./recipes/protocol-handler",157 "./recipes/related-app-discovery",158 "./recipes/scope-extension",159 "./recipes/web-share",160 "./recipes/window-controls-overlay",161 "./schema",162 "./schema/store",163 "./test",164 "./testing",165 ]);166 assertEquals(packageManifest.exports["."], "./package/runtime/mod.ts");167 assertEquals(packageManifest.exports["./access"], "./package/access/mod.ts");168 assertEquals(packageManifest.exports["./astro"], "./package/astro/mod.ts");169 assertEquals(packageManifest.exports["./preact"], "./package/preact/mod.ts");170 assertEquals(171 packageManifest.exports["./recipes/file-handler"],172 "./package/recipes/file-handler.ts",173 );174 assertEquals(175 packageManifest.exports["./recipes/launch-handler"],176 "./package/recipes/launch-handler.ts",177 );178 assertEquals(179 packageManifest.exports["./recipes/protocol-handler"],180 "./package/recipes/protocol-handler.ts",181 );182 assertEquals(183 packageManifest.exports["./recipes/related-app-discovery"],184 "./package/recipes/related-app-discovery.ts",185 );186 assertEquals(187 packageManifest.exports["./recipes/scope-extension"],188 "./package/recipes/scope-extension.ts",189 );190 assertEquals(191 packageManifest.exports["./recipes/web-share"],192 "./package/recipes/web-share.ts",193 );194 assertEquals(195 packageManifest.exports["./recipes/window-controls-overlay"],196 "./package/recipes/window-controls-overlay.ts",197 );198 assertEquals(packageManifest.exports["./testing"], "./package/testing/mod.ts");199});200201Deno.test("reference template uses portable explicit package paths", async () => {202 const config = JSON.parse(203 await Deno.readTextFile(new URL("../apps/reference/deno.json", import.meta.url)),204 );205 assert(!("links" in config), "reference template must not publish a Deno links override");206 const lofiImports = Object.entries(config.imports as Record<string, string>)207 .filter(([name]) => name === "@nzip/lofi" || name.startsWith("@nzip/lofi/"));208 assert(209 lofiImports.every(([, target]) => target.startsWith("../../package/")),210 "reference template must resolve lofi through explicit local package paths",211 );212});213214Deno.test("createProject refuses a non-empty destination without changing it", async () => {215 const cwd = await makeTestRoot();216 const destination = join(cwd, "taken");217 const marker = join(destination, "keep.txt");218 try {219 await Deno.mkdir(destination);220 await Deno.writeTextFile(marker, "untouched");221 await assertRejects(() => createProject({ cwd, name: "taken" }), "no files were changed");222 assertEquals(await Deno.readTextFile(marker), "untouched");223 assertEquals([...Deno.readDirSync(destination)].map((entry) => entry.name), ["keep.txt"]);224 } finally {225 await Deno.remove(cwd, { recursive: true });226 }227});228229Deno.test("createProject refuses path traversal without creating an outside directory", async () => {230 const cwd = await makeTestRoot();231 const outside = join(cwd, "..", "outside-from-lofi-create-test");232 try {233 await Deno.remove(outside, { recursive: true }).catch(() => undefined);234 await assertRejects(235 () => createProject({ cwd, name: "../outside-from-lofi-create-test" }),236 "'..'",237 );238 try {239 await Deno.stat(outside);240 throw new Error("path traversal created an outside destination");241 } catch (error) {242 assert(error instanceof Deno.errors.NotFound, "unexpected outside traversal path state");243 }244 } finally {245 await Deno.remove(cwd, { recursive: true });246 await Deno.remove(outside, { recursive: true }).catch(() => undefined);247 }248});249250Deno.test("createProject accepts an existing empty directory and a local package override", async () => {251 const cwd = await makeTestRoot();252 try {253 await Deno.mkdir(join(cwd, "nested", "starter"), { recursive: true });254 const result = await createProject({255 cwd,256 name: "nested/starter",257 packagePrefix: "file:///clean-room/package/",258 });259 const config = JSON.parse(await Deno.readTextFile(join(result.destination, "deno.json")));260 assertEquals(config.imports["@nzip/lofi"], "file:///clean-room/package/runtime/mod.ts");261 assertEquals(config.imports["@nzip/lofi/"], "file:///clean-room/package/");262 assert(263 !await Array.fromAsync(Deno.readDir(result.destination)).then((entries) =>264 entries.some((entry) => entry.name === ".env")265 ),266 "generated project must not contain .env",267 );268 } finally {269 await Deno.remove(cwd, { recursive: true });270 }271});272273Deno.test({274 name: "createProject refuses destinations reached through a symbolic link",275 ignore: Deno.build.os === "windows",276 async fn() {277 const cwd = await makeTestRoot();278 const outside = await makeTestRoot();279 try {280 const link = await new Deno.Command("ln", {281 args: ["-s", outside, join(cwd, "linked")],282 stdout: "null",283 stderr: "piped",284 }).output();285 assert(286 link.success,287 `failed to create test symlink: ${new TextDecoder().decode(link.stderr)}`,288 );289 await assertRejects(290 () => createProject({ cwd, name: "linked/starter" }),291 "crosses symbolic link",292 );293 assertEquals([...Deno.readDirSync(outside)].length, 0);294 } finally {295 await Deno.remove(cwd, { recursive: true });296 await Deno.remove(outside, { recursive: true });297 }298 },299});