clone

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

feat/141-demo-review-fixes
lofi / package / create_core_test.ts
create_core_test.ts 14.3K
1import { join } from "node:path";2import packageManifest from "../deno.json" with { type: "json" };3import { createProject } from "./create_core.ts";4import { STARTER_FILES } from "./starter_template.ts";5import { LOFI_VERSION } from "./version.ts";67function assert(condition: unknown, message: string): asserts condition {8 if (!condition) throw new Error(message);9}1011function assertEquals(actual: unknown, expected: unknown): void {12 const canonical = (value: unknown): unknown => {13 if (Array.isArray(value)) return value.map(canonical);14 if (value !== null && typeof value === "object") {15 return Object.fromEntries(16 Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(17 ([key, child]) => [key, canonical(child)],18 ),19 );20 }21 return value;22 };23 const actualJson = JSON.stringify(canonical(actual));24 const expectedJson = JSON.stringify(canonical(expected));25 if (actualJson !== expectedJson) {26 throw new Error(`expected ${expectedJson}, received ${actualJson}`);27 }28}2930async function assertRejects(31 action: () => Promise<unknown>,32 messageIncludes: string,33): Promise<void> {34 try {35 await action();36 } catch (error) {37 const message = error instanceof Error ? error.message : String(error);38 assert(message.includes(messageIncludes), `expected error to include ${messageIncludes}`);39 return;40 }41 throw new Error("expected promise to reject");42}4344async function fileManifest(root: string): Promise<Record<string, string>> {45 const manifest: Record<string, string> = {};46 async function visit(path: string, prefix = "") {47 const entries = [...Deno.readDirSync(path)].sort((left, right) =>48 left.name.localeCompare(right.name)49 );50 for (const entry of entries) {51 const relative = prefix ? `${prefix}/${entry.name}` : entry.name;52 const child = join(path, entry.name);53 if (entry.isDirectory) await visit(child, relative);54 else if (entry.isFile) {55 const digest = await crypto.subtle.digest("SHA-256", await Deno.readFile(child));56 manifest[relative] = Array.from(new Uint8Array(digest))57 .map((byte) => byte.toString(16).padStart(2, "0"))58 .join("");59 }60 }61 }62 await visit(root);63 return manifest;64}6566function snapshotDelta(67 actual: Readonly<Record<string, string>>,68 expected: Readonly<Record<string, string>>,69): string[] {70 return [...new Set([...Object.keys(actual), ...Object.keys(expected)])].sort().filter((path) =>71 actual[path] !== expected[path]72 );73}7475function makeTestRoot(): Promise<string> {76 return Deno.makeTempDir({ dir: ".", prefix: ".lofi-create-test-" });77}7879// Declared before the snapshot test so that snapshot updates regenerate the80// mirror first and the snapshot is taken from current sources.81Deno.test("starter mirror stays byte-identical to the reference app sources", async () => {82 const mirrored = STARTER_FILES.filter((path) => /\.tsx?$/.test(path));83 const update = Deno.env.get("LOFI_UPDATE_SNAPSHOT") === "1";84 const stale: string[] = [];85 for (const relativePath of mirrored) {86 const source = await Deno.readFile(87 new URL(`../apps/reference/${relativePath}`, import.meta.url),88 );89 const mirrorUrl = new URL(`./starter/${relativePath}.txt`, import.meta.url);90 if (update) {91 await Deno.mkdir(new URL(".", mirrorUrl), { recursive: true });92 await Deno.writeFile(mirrorUrl, source);93 continue;94 }95 const mirror = await Deno.readFile(mirrorUrl).catch(() => null);96 if (97 mirror === null || mirror.length !== source.length ||98 mirror.some((byte, index) => byte !== source[index])99 ) {100 stale.push(relativePath);101 }102 }103 assert(104 stale.length === 0,105 `starter mirror is stale for: ${106 stale.join(", ")107 }. Run \`deno task test:update:create\` to refresh package/starter.`,108 );109 const expectedMirrorFiles = new Set([...mirrored.map((path) => `${path}.txt`), "README.md"]);110 const mirrorRoot = new URL("./starter/", import.meta.url);111 const strays: string[] = [];112 async function visit(url: URL, prefix: string): Promise<void> {113 for await (const entry of Deno.readDir(url)) {114 const relative = prefix ? `${prefix}/${entry.name}` : entry.name;115 if (entry.isDirectory) await visit(new URL(`${entry.name}/`, url), relative);116 else if (!expectedMirrorFiles.has(relative)) strays.push(relative);117 }118 }119 await visit(mirrorRoot, "");120 assert(121 strays.length === 0,122 `starter mirror contains files with no reference-app source: ${strays.join(", ")}`,123 );124});125126Deno.test("createProject materializes the complete starter snapshot", async () => {127 const cwd = await makeTestRoot();128 try {129 const result = await createProject({ cwd, name: "starter" });130 assertEquals(result.nextCommands, ["cd starter", "deno task dev"]);131 const actual = await fileManifest(result.destination);132 const expected = JSON.parse(133 await Deno.readTextFile(new URL("./testdata/starter.snapshot.json", import.meta.url)),134 );135 if (Deno.env.get("LOFI_UPDATE_SNAPSHOT") === "1") {136 await Deno.writeTextFile(137 new URL("./testdata/starter.snapshot.json", import.meta.url),138 `${JSON.stringify(actual, null, 2)}\n`,139 );140 } else {141 const changed = snapshotDelta(actual, expected);142 assert(143 changed.length === 0,144 `generated starter snapshot changed: ${145 changed.join(", ")146 }. Review the generated files, then run \`deno task test:update:create\` to accept them.`,147 );148 }149 const config = JSON.parse(await Deno.readTextFile(join(result.destination, "deno.json")));150 assertEquals(config.imports["@nzip/lofi"], "jsr:@nzip/lofi@0.8.1");151 assertEquals(config.imports["@nzip/lofi/"], "jsr:@nzip/lofi@0.8.1/");152 assertEquals(config.imports["@nzip/lofi/testing"], "jsr:@nzip/lofi@0.8.1/testing");153 const lofiSpecifiers = Object.entries(config.imports)154 .filter(([name]) => name.startsWith("@nzip/lofi/"))155 .map(([, specifier]) => String(specifier));156 assert(157 lofiSpecifiers.length === 19,158 `expected one package prefix, access, schema, two integrations, six commands, seven recipes, and testing, received ${lofiSpecifiers.length}`,159 );160 assert(161 lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.8.1/")),162 "generated lofi commands do not resolve through one exact package version",163 );164 const appSource = await Deno.readTextFile(join(result.destination, "src", "app.ts"));165 assert(appSource.includes('name: "starter"'), "generated app.ts does not carry the app name");166 assert(167 appSource.includes('databaseName: "starter"'),168 "generated app.ts does not carry the database namespace",169 );170 assert(171 !appSource.includes("lofi-prototype") && !appSource.includes("repositoryUrl"),172 "generated app.ts still carries the reference app's identity",173 );174 const manifest = JSON.parse(175 await Deno.readTextFile(join(result.destination, "public", "manifest.webmanifest")),176 );177 assertEquals(178 [manifest.name, manifest.short_name, manifest.id],179 ["starter", "starter", "./starter"],180 );181 const index = await Deno.readTextFile(182 join(result.destination, "src", "pages", "index.astro"),183 );184 assert(index.includes('<Shell title="starter">'), "generated index.astro keeps the lofi title");185 for (const relativePath of STARTER_FILES.filter((path) => /\.(?:tsx?|astro)$/.test(path))) {186 const source = await Deno.readTextFile(join(result.destination, relativePath));187 assert(188 !source.includes("jsr:@nzip/lofi") && !source.includes("npm:preact"),189 `generated ${relativePath} bypasses the pinned import map with a direct specifier`,190 );191 }192 const fmt = await new Deno.Command(Deno.execPath(), {193 args: ["fmt", "--check"],194 cwd: result.destination,195 stdout: "piped",196 stderr: "piped",197 }).output();198 assert(199 fmt.success,200 `generated project fails deno fmt --check:\n${new TextDecoder().decode(fmt.stderr)}`,201 );202 const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];203 for (204 const icon of [205 "apple-touch-icon.png",206 "icon-192.png",207 "icon-512.png",208 "icon-maskable-512.png",209 ]210 ) {211 const bytes = await Deno.readFile(join(result.destination, "public", icon));212 assert(213 pngSignature.every((byte, index) => bytes[index] === byte),214 `generated ${icon} was not copied as binary PNG data`,215 );216 }217 } finally {218 await Deno.remove(cwd, { recursive: true });219 }220});221222Deno.test("package manifest and generated version stay coupled", () => {223 assertEquals(packageManifest.name, "@nzip/lofi");224 assertEquals(packageManifest.version, LOFI_VERSION);225 assert(226 !Object.keys(packageManifest.imports).some((name) => name.startsWith("@nzip/lofi")),227 "published package config must not redirect its own public imports to local files",228 );229 assertEquals(Object.keys(packageManifest.exports).sort(), [230 ".",231 "./access",232 "./astro",233 "./build",234 "./create",235 "./dev",236 "./doctor",237 "./preact",238 "./preview",239 "./provision",240 "./recipes/file-handler",241 "./recipes/launch-handler",242 "./recipes/protocol-handler",243 "./recipes/related-app-discovery",244 "./recipes/scope-extension",245 "./recipes/web-share",246 "./recipes/window-controls-overlay",247 "./schema",248 "./schema/store",249 "./test",250 "./testing",251 ]);252 assertEquals(packageManifest.exports["."], "./package/runtime/mod.ts");253 assertEquals(packageManifest.exports["./access"], "./package/access/mod.ts");254 assertEquals(packageManifest.exports["./astro"], "./package/astro/mod.ts");255 assertEquals(packageManifest.exports["./preact"], "./package/preact/mod.ts");256 assertEquals(257 packageManifest.exports["./recipes/file-handler"],258 "./package/recipes/file-handler.ts",259 );260 assertEquals(261 packageManifest.exports["./recipes/launch-handler"],262 "./package/recipes/launch-handler.ts",263 );264 assertEquals(265 packageManifest.exports["./recipes/protocol-handler"],266 "./package/recipes/protocol-handler.ts",267 );268 assertEquals(269 packageManifest.exports["./recipes/related-app-discovery"],270 "./package/recipes/related-app-discovery.ts",271 );272 assertEquals(273 packageManifest.exports["./recipes/scope-extension"],274 "./package/recipes/scope-extension.ts",275 );276 assertEquals(277 packageManifest.exports["./recipes/web-share"],278 "./package/recipes/web-share.ts",279 );280 assertEquals(281 packageManifest.exports["./recipes/window-controls-overlay"],282 "./package/recipes/window-controls-overlay.ts",283 );284 assertEquals(packageManifest.exports["./testing"], "./package/testing/mod.ts");285});286287Deno.test("reference template uses portable explicit package paths", async () => {288 const config = JSON.parse(289 await Deno.readTextFile(new URL("../apps/reference/deno.json", import.meta.url)),290 );291 assert(!("links" in config), "reference template must not publish a Deno links override");292 const lofiImports = Object.entries(config.imports as Record<string, string>)293 .filter(([name]) => name === "@nzip/lofi" || name.startsWith("@nzip/lofi/"));294 assert(295 lofiImports.every(([, target]) => target.startsWith("../../package/")),296 "reference template must resolve lofi through explicit local package paths",297 );298});299300Deno.test("createProject refuses a non-empty destination without changing it", async () => {301 const cwd = await makeTestRoot();302 const destination = join(cwd, "taken");303 const marker = join(destination, "keep.txt");304 try {305 await Deno.mkdir(destination);306 await Deno.writeTextFile(marker, "untouched");307 await assertRejects(() => createProject({ cwd, name: "taken" }), "no files were changed");308 assertEquals(await Deno.readTextFile(marker), "untouched");309 assertEquals([...Deno.readDirSync(destination)].map((entry) => entry.name), ["keep.txt"]);310 } finally {311 await Deno.remove(cwd, { recursive: true });312 }313});314315Deno.test("createProject refuses path traversal without creating an outside directory", async () => {316 const cwd = await makeTestRoot();317 const outside = join(cwd, "..", "outside-from-lofi-create-test");318 try {319 await Deno.remove(outside, { recursive: true }).catch(() => undefined);320 await assertRejects(321 () => createProject({ cwd, name: "../outside-from-lofi-create-test" }),322 "'..'",323 );324 try {325 await Deno.stat(outside);326 throw new Error("path traversal created an outside destination");327 } catch (error) {328 assert(error instanceof Deno.errors.NotFound, "unexpected outside traversal path state");329 }330 } finally {331 await Deno.remove(cwd, { recursive: true });332 await Deno.remove(outside, { recursive: true }).catch(() => undefined);333 }334});335336Deno.test("createProject accepts an existing empty directory and a local package override", async () => {337 const cwd = await makeTestRoot();338 try {339 await Deno.mkdir(join(cwd, "nested", "starter"), { recursive: true });340 const result = await createProject({341 cwd,342 name: "nested/starter",343 packagePrefix: "file:///clean-room/package/",344 });345 const config = JSON.parse(await Deno.readTextFile(join(result.destination, "deno.json")));346 assertEquals(config.imports["@nzip/lofi"], "file:///clean-room/package/runtime/mod.ts");347 assertEquals(config.imports["@nzip/lofi/"], "file:///clean-room/package/");348 assert(349 !await Array.fromAsync(Deno.readDir(result.destination)).then((entries) =>350 entries.some((entry) => entry.name === ".env")351 ),352 "generated project must not contain .env",353 );354 } finally {355 await Deno.remove(cwd, { recursive: true });356 }357});358359Deno.test({360 name: "createProject refuses destinations reached through a symbolic link",361 ignore: Deno.build.os === "windows",362 async fn() {363 const cwd = await makeTestRoot();364 const outside = await makeTestRoot();365 try {366 const link = await new Deno.Command("ln", {367 args: ["-s", outside, join(cwd, "linked")],368 stdout: "null",369 stderr: "piped",370 }).output();371 assert(372 link.success,373 `failed to create test symlink: ${new TextDecoder().decode(link.stderr)}`,374 );375 await assertRejects(376 () => createProject({ cwd, name: "linked/starter" }),377 "crosses symbolic link",378 );379 assertEquals([...Deno.readDirSync(outside)].length, 0);380 } finally {381 await Deno.remove(cwd, { recursive: true });382 await Deno.remove(outside, { recursive: true });383 }384 },385});