Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
368
packages/create-emdash/tests/flags.test.ts
Normal file
368
packages/create-emdash/tests/flags.test.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FlagError, HELP_TEXT, parseFlags, validateProjectName, wantsHelp } from "../src/flags.js";
|
||||
|
||||
/**
|
||||
* `parseArgs` consumes argv from index 2 onward, mirroring `process.argv`.
|
||||
* Helper keeps tests readable.
|
||||
*/
|
||||
function argv(...args: string[]): string[] {
|
||||
return ["node", "create-emdash", ...args];
|
||||
}
|
||||
|
||||
describe("parseFlags — defaults", () => {
|
||||
it("returns no values when called with no args", () => {
|
||||
const flags = parseFlags(argv());
|
||||
expect(flags).toEqual({ yes: false, force: false, help: false });
|
||||
});
|
||||
|
||||
it("treats yes/force/help as false when omitted (not undefined)", () => {
|
||||
// Important: index.ts checks these directly, so they must be
|
||||
// boolean — never undefined.
|
||||
const flags = parseFlags(argv());
|
||||
expect(flags.yes).toBe(false);
|
||||
expect(flags.force).toBe(false);
|
||||
expect(flags.help).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFlags — positional name", () => {
|
||||
it("captures the project name from the first positional", () => {
|
||||
const flags = parseFlags(argv("my-blog"));
|
||||
expect(flags.name).toBe("my-blog");
|
||||
});
|
||||
|
||||
it('accepts "." for current directory', () => {
|
||||
const flags = parseFlags(argv("."));
|
||||
expect(flags.name).toBe(".");
|
||||
});
|
||||
|
||||
it("does NOT validate the name itself — that's the resolver's job", () => {
|
||||
// Validation moved to validateProjectName so the prompt path and the
|
||||
// flag path use the same rule with the same error message. parseFlags
|
||||
// is purely structural; it stores whatever the user typed. The
|
||||
// resolver enforces PROJECT_NAME_PATTERN before scaffolding.
|
||||
const flags = parseFlags(argv("My-Site"));
|
||||
expect(flags.name).toBe("My-Site");
|
||||
});
|
||||
|
||||
it("accepts a positional after flags", () => {
|
||||
const flags = parseFlags(argv("--yes", "my-blog"));
|
||||
expect(flags.name).toBe("my-blog");
|
||||
expect(flags.yes).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects extra positionals as a likely typo", () => {
|
||||
// `npm create emdash my blog` (space instead of hyphen) is the
|
||||
// killer case: without this check it parses as name="my", drops
|
||||
// "blog" silently, and creates a project literally named "my".
|
||||
expect(() => parseFlags(argv("my", "blog"))).toThrow(FlagError);
|
||||
expect(() => parseFlags(argv("my", "blog"))).toThrow(/Unexpected extra/);
|
||||
});
|
||||
|
||||
it("does not reject extra-position errors when the second token is a flag", () => {
|
||||
// argv ordering shouldn't trip the extra-positional check — flags
|
||||
// are removed by parseArgs before we count positionals.
|
||||
const flags = parseFlags(argv("my-blog", "--yes"));
|
||||
expect(flags.name).toBe("my-blog");
|
||||
expect(flags.yes).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateProjectName", () => {
|
||||
it("returns undefined for a valid name", () => {
|
||||
expect(validateProjectName("my-site")).toBeUndefined();
|
||||
expect(validateProjectName("blog")).toBeUndefined();
|
||||
expect(validateProjectName("a")).toBeUndefined();
|
||||
expect(validateProjectName("123")).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for "." (current-dir sentinel)', () => {
|
||||
expect(validateProjectName(".")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns an error message for invalid names", () => {
|
||||
expect(validateProjectName("My-Site")).toMatch(/lowercase letters/);
|
||||
expect(validateProjectName("my site")).toMatch(/lowercase letters/);
|
||||
expect(validateProjectName("my.site")).toMatch(/lowercase letters/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFlags — --template", () => {
|
||||
it("accepts a bare template key", () => {
|
||||
expect(parseFlags(argv("--template", "blog")).template).toBe("blog");
|
||||
expect(parseFlags(argv("--template", "starter")).template).toBe("starter");
|
||||
expect(parseFlags(argv("--template", "marketing")).template).toBe("marketing");
|
||||
expect(parseFlags(argv("--template", "portfolio")).template).toBe("portfolio");
|
||||
});
|
||||
|
||||
it("rejects unknown template keys", () => {
|
||||
expect(() => parseFlags(argv("--template", "nope"))).toThrow(FlagError);
|
||||
expect(() => parseFlags(argv("--template", "nope"))).toThrow(/--template/);
|
||||
});
|
||||
|
||||
it("accepts the combined <platform>:<template> form", () => {
|
||||
const flags = parseFlags(argv("--template", "cloudflare:blog"));
|
||||
expect(flags.platform).toBe("cloudflare");
|
||||
expect(flags.template).toBe("blog");
|
||||
});
|
||||
|
||||
it("accepts node:starter via the combined form", () => {
|
||||
const flags = parseFlags(argv("--template", "node:starter"));
|
||||
expect(flags.platform).toBe("node");
|
||||
expect(flags.template).toBe("starter");
|
||||
});
|
||||
|
||||
it("rejects an unknown platform prefix in the combined form", () => {
|
||||
expect(() => parseFlags(argv("--template", "vercel:blog"))).toThrow(FlagError);
|
||||
expect(() => parseFlags(argv("--template", "vercel:blog"))).toThrow(/platform prefix/);
|
||||
});
|
||||
|
||||
it("rejects an unknown template name in the combined form", () => {
|
||||
expect(() => parseFlags(argv("--template", "cloudflare:nope"))).toThrow(FlagError);
|
||||
});
|
||||
|
||||
it("errors when --platform and --template platform-prefix disagree (platform first)", () => {
|
||||
// Easy to trip over if a user copies a flag set; surfacing an error
|
||||
// is friendlier than silently letting one win.
|
||||
expect(() => parseFlags(argv("--platform", "node", "--template", "cloudflare:blog"))).toThrow(
|
||||
FlagError,
|
||||
);
|
||||
expect(() => parseFlags(argv("--platform", "node", "--template", "cloudflare:blog"))).toThrow(
|
||||
/conflicts/,
|
||||
);
|
||||
});
|
||||
|
||||
it("errors when --platform and --template platform-prefix disagree (template first)", () => {
|
||||
// Same as above but argv order reversed — conflict detection must
|
||||
// be order-independent. Without the reconcile-at-end pattern this
|
||||
// test passes by accident.
|
||||
expect(() => parseFlags(argv("--template", "cloudflare:blog", "--platform", "node"))).toThrow(
|
||||
FlagError,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts --platform and --template platform-prefix when they agree", () => {
|
||||
const flags = parseFlags(argv("--platform", "cloudflare", "--template", "cloudflare:blog"));
|
||||
expect(flags.platform).toBe("cloudflare");
|
||||
expect(flags.template).toBe("blog");
|
||||
});
|
||||
|
||||
it("rejects empty halves of the combined form", () => {
|
||||
expect(() => parseFlags(argv("--template", ":"))).toThrow(FlagError);
|
||||
expect(() => parseFlags(argv("--template", ":blog"))).toThrow(FlagError);
|
||||
expect(() => parseFlags(argv("--template", "cloudflare:"))).toThrow(FlagError);
|
||||
});
|
||||
|
||||
it("treats subsequent colons as part of the template name (which then fails)", () => {
|
||||
// `cloudflare:blog:extra` -> platformPart="cloudflare", templatePart="blog:extra".
|
||||
// templatePart isn't a valid template, so we get the template error.
|
||||
expect(() => parseFlags(argv("--template", "cloudflare:blog:extra"))).toThrow(
|
||||
/--template name must be one of/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFlags — --platform", () => {
|
||||
it("accepts valid platforms", () => {
|
||||
expect(parseFlags(argv("--platform", "node")).platform).toBe("node");
|
||||
expect(parseFlags(argv("--platform", "cloudflare")).platform).toBe("cloudflare");
|
||||
});
|
||||
|
||||
it("rejects unknown platforms", () => {
|
||||
expect(() => parseFlags(argv("--platform", "vercel"))).toThrow(FlagError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFlags — package manager", () => {
|
||||
it("accepts --pm with valid managers", () => {
|
||||
expect(parseFlags(argv("--pm", "pnpm")).packageManager).toBe("pnpm");
|
||||
expect(parseFlags(argv("--pm", "npm")).packageManager).toBe("npm");
|
||||
expect(parseFlags(argv("--pm", "yarn")).packageManager).toBe("yarn");
|
||||
expect(parseFlags(argv("--pm", "bun")).packageManager).toBe("bun");
|
||||
});
|
||||
|
||||
it("accepts --package-manager as an alias", () => {
|
||||
expect(parseFlags(argv("--package-manager", "pnpm")).packageManager).toBe("pnpm");
|
||||
});
|
||||
|
||||
it("rejects unknown package managers", () => {
|
||||
expect(() => parseFlags(argv("--pm", "deno"))).toThrow(FlagError);
|
||||
});
|
||||
|
||||
it("accepts --pm and --package-manager together when they agree", () => {
|
||||
const flags = parseFlags(argv("--pm", "pnpm", "--package-manager", "pnpm"));
|
||||
expect(flags.packageManager).toBe("pnpm");
|
||||
});
|
||||
|
||||
it("errors when --pm and --package-manager disagree", () => {
|
||||
expect(() => parseFlags(argv("--pm", "pnpm", "--package-manager", "npm"))).toThrow(FlagError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFlags — install toggle", () => {
|
||||
it("--install sets install: true", () => {
|
||||
expect(parseFlags(argv("--install")).install).toBe(true);
|
||||
});
|
||||
|
||||
it("--no-install sets install: false", () => {
|
||||
expect(parseFlags(argv("--no-install")).install).toBe(false);
|
||||
});
|
||||
|
||||
it("install is undefined when neither flag is passed", () => {
|
||||
expect(parseFlags(argv()).install).toBeUndefined();
|
||||
});
|
||||
|
||||
it("errors when both --install and --no-install are passed", () => {
|
||||
expect(() => parseFlags(argv("--install", "--no-install"))).toThrow(FlagError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFlags — --yes / -y", () => {
|
||||
it("--yes sets yes: true", () => {
|
||||
expect(parseFlags(argv("--yes")).yes).toBe(true);
|
||||
});
|
||||
|
||||
it("-y sets yes: true", () => {
|
||||
expect(parseFlags(argv("-y")).yes).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFlags — --force", () => {
|
||||
it("--force sets force: true", () => {
|
||||
expect(parseFlags(argv("--force")).force).toBe(true);
|
||||
});
|
||||
|
||||
it("force defaults to false", () => {
|
||||
expect(parseFlags(argv()).force).toBe(false);
|
||||
});
|
||||
|
||||
it("--force composes with --yes", () => {
|
||||
const flags = parseFlags(argv("--yes", "--force"));
|
||||
expect(flags.yes).toBe(true);
|
||||
expect(flags.force).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wantsHelp", () => {
|
||||
it("returns true when --help is present", () => {
|
||||
expect(wantsHelp(["node", "script", "--help"])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when -h is present", () => {
|
||||
expect(wantsHelp(["node", "script", "-h"])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true even when --help is preceded by an invalid flag", () => {
|
||||
// This is the whole point of wantsHelp — we want help even when
|
||||
// strict parseArgs would reject the rest of argv.
|
||||
expect(wantsHelp(["node", "script", "--templat", "x", "--help"])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when neither flag is present", () => {
|
||||
expect(wantsHelp(["node", "script"])).toBe(false);
|
||||
expect(wantsHelp(["node", "script", "my-blog", "--yes"])).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match a positional that happens to spell 'help'", () => {
|
||||
// A user passing `help` as a project name is invalid in
|
||||
// PROJECT_NAME_PATTERN terms (it's actually fine — `help` is
|
||||
// lowercase letters), but the point is wantsHelp matches the flag
|
||||
// only, not arbitrary tokens.
|
||||
expect(wantsHelp(["node", "script", "help"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFlags — --help / -h", () => {
|
||||
it("--help sets help: true", () => {
|
||||
expect(parseFlags(argv("--help")).help).toBe(true);
|
||||
});
|
||||
|
||||
it("-h sets help: true", () => {
|
||||
expect(parseFlags(argv("-h")).help).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFlags — unknown flags", () => {
|
||||
it("throws on unknown flags rather than silently ignoring them", () => {
|
||||
// parseArgs in strict mode (the default) throws TypeError. We
|
||||
// don't re-wrap it because the message it produces is already
|
||||
// user-facing-friendly: 'Unknown option \'--templat\''.
|
||||
expect(() => parseFlags(argv("--templat", "blog"))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFlags — full one-shot install line", () => {
|
||||
it("parses the example from the issue", () => {
|
||||
const flags = parseFlags(
|
||||
argv("my-blog", "--template", "cloudflare:blog", "--pm", "pnpm", "--yes"),
|
||||
);
|
||||
expect(flags).toEqual({
|
||||
name: "my-blog",
|
||||
platform: "cloudflare",
|
||||
template: "blog",
|
||||
packageManager: "pnpm",
|
||||
yes: true,
|
||||
force: false,
|
||||
help: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses split-flag form", () => {
|
||||
const flags = parseFlags(
|
||||
argv(
|
||||
"my-blog",
|
||||
"--platform",
|
||||
"node",
|
||||
"--template",
|
||||
"starter",
|
||||
"--pm",
|
||||
"npm",
|
||||
"--no-install",
|
||||
"--yes",
|
||||
),
|
||||
);
|
||||
expect(flags).toEqual({
|
||||
name: "my-blog",
|
||||
platform: "node",
|
||||
template: "starter",
|
||||
packageManager: "npm",
|
||||
install: false,
|
||||
yes: true,
|
||||
force: false,
|
||||
help: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses cwd-overwrite form (--yes . --force)", () => {
|
||||
const flags = parseFlags(argv(".", "--yes", "--force"));
|
||||
expect(flags.name).toBe(".");
|
||||
expect(flags.yes).toBe(true);
|
||||
expect(flags.force).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HELP_TEXT", () => {
|
||||
it("documents every supported flag", () => {
|
||||
// Cheap lint to keep HELP_TEXT in sync with parseFlags. If you
|
||||
// add a new flag and don't document it, this fails.
|
||||
for (const flag of [
|
||||
"--template",
|
||||
"--platform",
|
||||
"--pm",
|
||||
"--package-manager",
|
||||
"--install",
|
||||
"--no-install",
|
||||
"--yes",
|
||||
"--force",
|
||||
"--help",
|
||||
]) {
|
||||
expect(HELP_TEXT).toContain(flag);
|
||||
}
|
||||
});
|
||||
|
||||
it("documents the short-form aliases", () => {
|
||||
expect(HELP_TEXT).toContain("-y");
|
||||
expect(HELP_TEXT).toContain("-h");
|
||||
});
|
||||
});
|
||||
305
packages/create-emdash/tests/utils.test.ts
Normal file
305
packages/create-emdash/tests/utils.test.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user