Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
|
||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||
|
||
import {
|
||
PROJECT_NAME_PATTERN,
|
||
generateEncryptionKey,
|
||
isDirNonEmpty,
|
||
parseTargetArg,
|
||
sanitizePackageName,
|
||
writeEncryptionKey,
|
||
} from "../src/utils.js";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// sanitizePackageName
|
||
// ---------------------------------------------------------------------------
|
||
describe("sanitizePackageName", () => {
|
||
it("passes through a valid lowercase name unchanged", () => {
|
||
expect(sanitizePackageName("my-site")).toBe("my-site");
|
||
});
|
||
|
||
it("lowercases uppercase characters", () => {
|
||
expect(sanitizePackageName("My-Site")).toBe("my-site");
|
||
});
|
||
|
||
it("replaces spaces with hyphens", () => {
|
||
expect(sanitizePackageName("my cool site")).toBe("my-cool-site");
|
||
});
|
||
|
||
it("replaces dots with hyphens", () => {
|
||
expect(sanitizePackageName("my.site")).toBe("my-site");
|
||
});
|
||
|
||
it("replaces underscores with hyphens", () => {
|
||
expect(sanitizePackageName("my_site")).toBe("my-site");
|
||
});
|
||
|
||
it("strips leading hyphens", () => {
|
||
expect(sanitizePackageName("--my-site")).toBe("my-site");
|
||
});
|
||
|
||
it("strips trailing hyphens", () => {
|
||
expect(sanitizePackageName("my-site--")).toBe("my-site");
|
||
});
|
||
|
||
it("strips both leading and trailing hyphens", () => {
|
||
expect(sanitizePackageName("---my-site---")).toBe("my-site");
|
||
});
|
||
|
||
it("handles mixed invalid characters", () => {
|
||
expect(sanitizePackageName("My Cool Site!@#2024")).toBe("my-cool-site---2024");
|
||
});
|
||
|
||
it("handles a name that is entirely invalid characters", () => {
|
||
expect(sanitizePackageName("!!!")).toBe("my-site");
|
||
});
|
||
|
||
it("handles an empty string", () => {
|
||
expect(sanitizePackageName("")).toBe("my-site");
|
||
});
|
||
|
||
it("handles a single period (basename of root on some systems)", () => {
|
||
// basename("/") on some platforms can return "/" which sanitises to "my-site"
|
||
// but basename of a relative "." is ".", which becomes empty after stripping
|
||
expect(sanitizePackageName(".")).toBe("my-site");
|
||
});
|
||
|
||
it("handles names starting with numbers", () => {
|
||
expect(sanitizePackageName("123-project")).toBe("123-project");
|
||
});
|
||
|
||
it("handles unicode characters", () => {
|
||
expect(sanitizePackageName("mön-prøject")).toBe("m-n-pr-ject");
|
||
});
|
||
|
||
it("collapses multiple consecutive invalid chars into individual hyphens", () => {
|
||
// Each invalid char becomes a separate hyphen – no collapsing
|
||
expect(sanitizePackageName("a b")).toBe("a---b");
|
||
});
|
||
|
||
it("handles CamelCase directory names", () => {
|
||
expect(sanitizePackageName("MyProject")).toBe("myproject");
|
||
});
|
||
|
||
it("handles paths that look like scoped packages", () => {
|
||
// The @ and / are both invalid, so they become hyphens
|
||
expect(sanitizePackageName("@scope/package")).toBe("scope-package");
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PROJECT_NAME_PATTERN
|
||
// ---------------------------------------------------------------------------
|
||
describe("PROJECT_NAME_PATTERN", () => {
|
||
const valid = ["my-site", "blog", "a", "123", "my-cool-site-2"];
|
||
const invalid = ["My-Site", "my site", "my.site", "my_site", ".", ".hidden", "@scope/pkg", ""];
|
||
|
||
for (const name of valid) {
|
||
it(`accepts "${name}"`, () => {
|
||
expect(PROJECT_NAME_PATTERN.test(name)).toBe(true);
|
||
});
|
||
}
|
||
|
||
for (const name of invalid) {
|
||
it(`rejects "${name}"`, () => {
|
||
expect(PROJECT_NAME_PATTERN.test(name)).toBe(false);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// parseTargetArg
|
||
// ---------------------------------------------------------------------------
|
||
describe("parseTargetArg", () => {
|
||
it("returns undefined when no arguments are passed", () => {
|
||
// process.argv always has at least [node, script]
|
||
expect(parseTargetArg(["node", "script.js"])).toBeUndefined();
|
||
});
|
||
|
||
it('returns "." when a dot is the first positional argument', () => {
|
||
expect(parseTargetArg(["node", "script.js", "."])).toBe(".");
|
||
});
|
||
|
||
it("returns the project name when passed as a positional argument", () => {
|
||
expect(parseTargetArg(["node", "script.js", "my-project"])).toBe("my-project");
|
||
});
|
||
|
||
it("skips flags and returns the first positional argument", () => {
|
||
expect(parseTargetArg(["node", "script.js", "--verbose", "my-project"])).toBe("my-project");
|
||
});
|
||
|
||
it("skips all flags when no positional argument exists", () => {
|
||
expect(parseTargetArg(["node", "script.js", "--verbose", "--debug"])).toBeUndefined();
|
||
});
|
||
|
||
it("returns the first positional argument when multiple are passed", () => {
|
||
expect(parseTargetArg(["node", "script.js", "first", "second"])).toBe("first");
|
||
});
|
||
|
||
it("treats a single-hyphen flag as a flag, not a positional arg", () => {
|
||
expect(parseTargetArg(["node", "script.js", "-v", "my-project"])).toBe("my-project");
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// isDirNonEmpty
|
||
// ---------------------------------------------------------------------------
|
||
describe("isDirNonEmpty", () => {
|
||
let tempDir: string;
|
||
|
||
beforeEach(() => {
|
||
tempDir = mkdtempSync(join(tmpdir(), "create-emdash-test-"));
|
||
});
|
||
|
||
afterEach(() => {
|
||
rmSync(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
it("returns false for an empty directory", () => {
|
||
expect(isDirNonEmpty(tempDir)).toBe(false);
|
||
});
|
||
|
||
it("returns true for a directory with files", () => {
|
||
writeFileSync(join(tempDir, "file.txt"), "hello");
|
||
expect(isDirNonEmpty(tempDir)).toBe(true);
|
||
});
|
||
|
||
it("returns true for a directory with subdirectories", () => {
|
||
mkdirSync(join(tempDir, "subdir"));
|
||
expect(isDirNonEmpty(tempDir)).toBe(true);
|
||
});
|
||
|
||
it("returns false for a non-existent path", () => {
|
||
expect(isDirNonEmpty(join(tempDir, "does-not-exist"))).toBe(false);
|
||
});
|
||
|
||
it("returns false for a path that is a file, not a directory", () => {
|
||
const filePath = join(tempDir, "a-file.txt");
|
||
writeFileSync(filePath, "content");
|
||
// readdirSync on a file throws ENOTDIR, which the catch handles
|
||
expect(isDirNonEmpty(filePath)).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// generateEncryptionKey — format alignment with `emdash`'s parser
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// The vendored generator must emit values that pass the canonical-base64url
|
||
// check in `packages/core/src/config/secrets.ts`. If the prefix or body
|
||
// length ever drifts in core, this test won't catch it directly — but the
|
||
// shape assertion here is the same shape `parseEncryptionKeys` checks, so
|
||
// any encoding regression will be caught immediately.
|
||
//
|
||
// We don't import from `emdash` directly because `create-emdash` ships
|
||
// without the heavy core dep; the duplication is intentional.
|
||
describe("generateEncryptionKey", () => {
|
||
it("produces the v1 prefix and a 43-char unpadded base64url body", () => {
|
||
const key = generateEncryptionKey();
|
||
expect(key).toMatch(/^emdash_enc_v1_[A-Za-z0-9_-]{43}$/);
|
||
});
|
||
|
||
it("body decodes to exactly 32 bytes", () => {
|
||
const key = generateEncryptionKey();
|
||
const body = key.slice("emdash_enc_v1_".length);
|
||
// base64url -> Uint8Array; rely on Node's built-in handling.
|
||
const bytes = Buffer.from(body, "base64url");
|
||
expect(bytes.length).toBe(32);
|
||
});
|
||
|
||
it("body is canonical (re-encoding decoded bytes yields the same string)", () => {
|
||
// Aligns with `parseEncryptionKeys`'s canonical check. If this
|
||
// fails, the generator and parser have drifted apart.
|
||
const key = generateEncryptionKey();
|
||
const body = key.slice("emdash_enc_v1_".length);
|
||
const bytes = Buffer.from(body, "base64url");
|
||
const reencoded = bytes.toString("base64url");
|
||
expect(reencoded).toBe(body);
|
||
});
|
||
|
||
it("produces unique values across calls", () => {
|
||
expect(generateEncryptionKey()).not.toBe(generateEncryptionKey());
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// writeEncryptionKey — parallel coverage with the core CLI's helper
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// These cases mirror `tests/unit/cli/secrets-commands.test.ts` in the core
|
||
// package. The two implementations are independently maintained (per
|
||
// scaffold-time-no-emdash-dep constraint) so both need their own tests.
|
||
describe("writeEncryptionKey", () => {
|
||
let tempDir: string;
|
||
const fileName = ".dev.vars";
|
||
const sample = "emdash_enc_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||
|
||
beforeEach(() => {
|
||
tempDir = mkdtempSync(join(tmpdir(), "create-emdash-key-"));
|
||
});
|
||
|
||
afterEach(() => {
|
||
rmSync(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
function read(): string {
|
||
return readFileSync(join(tempDir, fileName), "utf-8");
|
||
}
|
||
|
||
it("creates a new file with a trailing newline when none exists", () => {
|
||
const result = writeEncryptionKey(tempDir, fileName);
|
||
expect(result).toBe("wrote");
|
||
const content = read();
|
||
expect(content).toMatch(/^EMDASH_ENCRYPTION_KEY=emdash_enc_v1_[A-Za-z0-9_-]{43}\n$/);
|
||
});
|
||
|
||
it("appends to an existing file without clobbering other vars", () => {
|
||
writeFileSync(join(tempDir, fileName), "OTHER=value\nFOO=bar\n");
|
||
const result = writeEncryptionKey(tempDir, fileName);
|
||
expect(result).toBe("wrote");
|
||
const content = read();
|
||
expect(content).toMatch(
|
||
/^OTHER=value\nFOO=bar\nEMDASH_ENCRYPTION_KEY=emdash_enc_v1_[A-Za-z0-9_-]{43}\n$/,
|
||
);
|
||
});
|
||
|
||
it("appends to a file that lacks a trailing newline", () => {
|
||
writeFileSync(join(tempDir, fileName), "OTHER=value");
|
||
const result = writeEncryptionKey(tempDir, fileName);
|
||
expect(result).toBe("wrote");
|
||
const content = read();
|
||
expect(content).toMatch(
|
||
/^OTHER=value\nEMDASH_ENCRYPTION_KEY=emdash_enc_v1_[A-Za-z0-9_-]{43}\n$/,
|
||
);
|
||
});
|
||
|
||
it("skips when a populated entry already exists", () => {
|
||
writeFileSync(join(tempDir, fileName), `EMDASH_ENCRYPTION_KEY=${sample}\nOTHER=value\n`);
|
||
const result = writeEncryptionKey(tempDir, fileName);
|
||
expect(result).toBe("skipped");
|
||
const content = read();
|
||
// File untouched.
|
||
expect(content).toBe(`EMDASH_ENCRYPTION_KEY=${sample}\nOTHER=value\n`);
|
||
});
|
||
|
||
it("treats an empty-value entry as not-set and replaces it", () => {
|
||
writeFileSync(join(tempDir, fileName), `OTHER=value\nEMDASH_ENCRYPTION_KEY=\nMORE=stuff\n`);
|
||
const result = writeEncryptionKey(tempDir, fileName);
|
||
expect(result).toBe("wrote");
|
||
const content = read();
|
||
expect(content).toMatch(
|
||
/^OTHER=value\nEMDASH_ENCRYPTION_KEY=emdash_enc_v1_[A-Za-z0-9_-]{43}\nMORE=stuff\n$/,
|
||
);
|
||
});
|
||
|
||
it("always ends with a trailing newline, even when replacing in-place in a file without one", () => {
|
||
writeFileSync(join(tempDir, fileName), `OTHER=value\nEMDASH_ENCRYPTION_KEY=`);
|
||
const result = writeEncryptionKey(tempDir, fileName);
|
||
expect(result).toBe("wrote");
|
||
const content = read();
|
||
expect(content.endsWith("\n")).toBe(true);
|
||
});
|
||
});
|