import {
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
let testDataDir: string;
import {
createApp,
deleteApp,
generateAppDirName,
getApp,
getAppDirPath,
getAppsDir,
resolveAppDir,
slugify,
updateApp,
validateDirName,
} from "../apps/app-store.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function freshTempDir(): string {
return join(
tmpdir(),
`vellum-app-dir-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
}
function makeAppParams(name: string) {
return {
name,
schemaJson: "{}",
htmlDefinition: "
Hello
",
};
}
// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------
beforeEach(() => {
testDataDir = freshTempDir();
process.env.VELLUM_WORKSPACE_DIR = testDataDir;
});
afterEach(() => {
if (existsSync(testDataDir)) {
rmSync(testDataDir, { recursive: true, force: true });
}
});
// ---------------------------------------------------------------------------
// slugify()
// ---------------------------------------------------------------------------
describe("slugify()", () => {
test("normal names", () => {
expect(slugify("My Cool App")).toBe("my-cool-app");
});
test("special characters are replaced with hyphens", () => {
expect(slugify("hello@world!#$%")).toBe("hello-world");
});
test("unicode characters are replaced", () => {
expect(slugify("café résumé")).toBe("caf-r-sum");
});
test("emoji-only names produce fallback slug", () => {
const result = slugify("🚀🎉");
expect(result).toMatch(/^app-[a-f0-9]{8}$/);
});
test("empty string produces fallback slug", () => {
const result = slugify("");
expect(result).toMatch(/^app-[a-f0-9]{8}$/);
});
test("very long names are truncated to 60 chars", () => {
const longName = "a".repeat(100);
const result = slugify(longName);
expect(result.length).toBeLessThanOrEqual(60);
expect(result).toBe("a".repeat(60));
});
test("truncation removes trailing hyphens", () => {
// Create a name where position 60 lands on a hyphen sequence
const name = "a".repeat(58) + "--bbb";
const result = slugify(name);
expect(result.length).toBeLessThanOrEqual(60);
expect(result).not.toMatch(/-$/);
});
test("names with only hyphens produce fallback slug", () => {
const result = slugify("---");
expect(result).toMatch(/^app-[a-f0-9]{8}$/);
});
test("leading and trailing hyphens are stripped", () => {
expect(slugify("-hello-world-")).toBe("hello-world");
});
test("consecutive hyphens are collapsed", () => {
expect(slugify("hello---world")).toBe("hello-world");
});
});
// ---------------------------------------------------------------------------
// generateAppDirName()
// ---------------------------------------------------------------------------
describe("generateAppDirName()", () => {
test("returns base slug when no collision", () => {
const result = generateAppDirName("My App", new Set());
expect(result).toBe("my-app");
});
test("appends -2 on first collision", () => {
const result = generateAppDirName("My App", new Set(["my-app"]));
expect(result).toBe("my-app-2");
});
test("escalates numeric suffix on multiple collisions", () => {
const existing = new Set(["my-app", "my-app-2", "my-app-3"]);
const result = generateAppDirName("My App", existing);
expect(result).toBe("my-app-4");
});
test("collision with truncated names still deduplicates", () => {
const longName = "a".repeat(100);
const base = slugify(longName);
const existing = new Set([base]);
const result = generateAppDirName(longName, existing);
expect(result).toBe(`${base}-2`);
});
});
// ---------------------------------------------------------------------------
// createApp() — directory named after slug
// ---------------------------------------------------------------------------
describe("createApp()", () => {
test("directory is named after slug, not UUID", () => {
const app = createApp(makeAppParams("My Test App"));
const appsDir = getAppsDir();
// dirName should be the slug
expect(app.dirName).toBe("my-test-app");
// JSON file should be named after slug
expect(existsSync(join(appsDir, "my-test-app.json"))).toBe(true);
// Directory should be named after slug
expect(existsSync(join(appsDir, "my-test-app"))).toBe(true);
// UUID-named files should NOT exist
expect(existsSync(join(appsDir, `${app.id}.json`))).toBe(false);
});
test("dirName is persisted in JSON", () => {
const app = createApp(makeAppParams("Slug Test"));
const appsDir = getAppsDir();
const jsonPath = join(appsDir, "slug-test.json");
const persisted = JSON.parse(readFileSync(jsonPath, "utf-8"));
expect(persisted.dirName).toBe("slug-test");
expect(persisted.id).toBe(app.id);
});
test("index.html is in the slugified directory", () => {
createApp(makeAppParams("Html App"));
const appsDir = getAppsDir();
const indexPath = join(appsDir, "html-app", "index.html");
expect(existsSync(indexPath)).toBe(true);
expect(readFileSync(indexPath, "utf-8")).toBe("Hello
");
});
test("deduplicates dirNames across multiple creates", () => {
const app1 = createApp(makeAppParams("Duplicate"));
const app2 = createApp(makeAppParams("Duplicate"));
expect(app1.dirName).toBe("duplicate");
expect(app2.dirName).toBe("duplicate-2");
});
});
// ---------------------------------------------------------------------------
// createApp() + updateApp() — frozen dirName invariant
// ---------------------------------------------------------------------------
describe("frozen dirName invariant", () => {
test("renaming an app does NOT change its directory name", () => {
const app = createApp(makeAppParams("Original Name"));
const appsDir = getAppsDir();
expect(app.dirName).toBe("original-name");
expect(existsSync(join(appsDir, "original-name.json"))).toBe(true);
// Rename the app
const updated = updateApp(app.id, { name: "New Name" });
expect(updated.name).toBe("New Name");
// Directory and files should still be at original slug
expect(existsSync(join(appsDir, "original-name.json"))).toBe(true);
expect(existsSync(join(appsDir, "original-name"))).toBe(true);
// New name slug should NOT exist as files
expect(existsSync(join(appsDir, "new-name.json"))).toBe(false);
});
});
// ---------------------------------------------------------------------------
// getApp() — lookup by UUID with slugified dirs
// ---------------------------------------------------------------------------
describe("getApp()", () => {
test("lookup by UUID works with slugified dirs", () => {
const created = createApp(makeAppParams("Lookup App"));
const fetched = getApp(created.id);
expect(fetched).not.toBeNull();
expect(fetched!.id).toBe(created.id);
expect(fetched!.name).toBe("Lookup App");
expect(fetched!.htmlDefinition).toBe("Hello
");
});
test("backward compat: works when JSON has no dirName (uses id as fallback)", () => {
const appsDir = getAppsDir();
const fakeId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
// Write a JSON file with no dirName, using the UUID as the filename
const appData = {
id: fakeId,
name: "Legacy App",
schemaJson: "{}",
createdAt: Date.now(),
updatedAt: Date.now(),
};
writeFileSync(
join(appsDir, `${fakeId}.json`),
JSON.stringify(appData, null, 2),
);
// Create directory with index.html
const appDir = join(appsDir, fakeId);
mkdirSync(appDir, { recursive: true });
writeFileSync(join(appDir, "index.html"), "legacy
", "utf-8");
const fetched = getApp(fakeId);
expect(fetched).not.toBeNull();
expect(fetched!.id).toBe(fakeId);
expect(fetched!.name).toBe("Legacy App");
expect(fetched!.htmlDefinition).toBe("legacy
");
});
});
// ---------------------------------------------------------------------------
// deleteApp() — cleans up slugified files
// ---------------------------------------------------------------------------
describe("deleteApp()", () => {
test("cleans up slugified directory, JSON, and preview files", () => {
const app = createApp({
...makeAppParams("Delete Me"),
preview: "base64-preview-data",
});
const appsDir = getAppsDir();
// Verify files exist before deletion
expect(existsSync(join(appsDir, "delete-me.json"))).toBe(true);
expect(existsSync(join(appsDir, "delete-me"))).toBe(true);
expect(existsSync(join(appsDir, "delete-me.preview"))).toBe(true);
deleteApp(app.id);
// All files should be gone
expect(existsSync(join(appsDir, "delete-me.json"))).toBe(false);
expect(existsSync(join(appsDir, "delete-me"))).toBe(false);
expect(existsSync(join(appsDir, "delete-me.preview"))).toBe(false);
});
});
// ---------------------------------------------------------------------------
// resolveAppDir() — validation rejects malicious dirName values
// When a JSON file has an invalid dirName, resolveAppDir defensively falls
// back to using the app ID instead of the malicious dirName. The
// validateDirName() call inside the try/catch causes the invalid entry to
// be skipped, and the function returns the safe fallback.
// ---------------------------------------------------------------------------
describe("resolveAppDir() validation", () => {
test("falls back to id when dirName contains path traversal (..)", () => {
const appsDir = getAppsDir();
const fakeId = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff";
writeFileSync(
join(appsDir, `${fakeId}.json`),
JSON.stringify({ id: fakeId, name: "Evil", dirName: "../etc" }),
);
// Should NOT use the malicious dirName — falls back to id
const result = resolveAppDir(fakeId);
expect(result.dirName).toBe(fakeId);
expect(result.appDir).toBe(join(appsDir, fakeId));
});
test("falls back to id when dirName contains forward slash", () => {
const appsDir = getAppsDir();
const fakeId = "cccccccc-dddd-eeee-ffff-aaaaaaaaaaaa";
writeFileSync(
join(appsDir, `${fakeId}.json`),
JSON.stringify({ id: fakeId, name: "Evil", dirName: "foo/bar" }),
);
const result = resolveAppDir(fakeId);
expect(result.dirName).toBe(fakeId);
});
test("falls back to id when dirName contains backslash", () => {
const appsDir = getAppsDir();
const fakeId = "dddddddd-eeee-ffff-aaaa-bbbbbbbbbbbb";
writeFileSync(
join(appsDir, `${fakeId}.json`),
JSON.stringify({ id: fakeId, name: "Evil", dirName: "foo\\bar" }),
);
const result = resolveAppDir(fakeId);
expect(result.dirName).toBe(fakeId);
});
test("falls back to id when dirName is empty string", () => {
const appsDir = getAppsDir();
const fakeId = "eeeeeeee-ffff-aaaa-bbbb-cccccccccccc";
writeFileSync(
join(appsDir, `${fakeId}.json`),
JSON.stringify({ id: fakeId, name: "Evil", dirName: "" }),
);
// Empty string is falsy, so `parsed.dirName || id` falls back to the app ID.
// This prevents appDir from resolving to the apps root directory.
const result = resolveAppDir(fakeId);
expect(result.dirName).toBe(fakeId);
expect(result.appDir).toBe(join(appsDir, fakeId));
});
});
// ---------------------------------------------------------------------------
// validateDirName() — direct unit tests
// ---------------------------------------------------------------------------
describe("validateDirName()", () => {
test("accepts valid slug names", () => {
expect(() => validateDirName("my-cool-app")).not.toThrow();
expect(() => validateDirName("app-123")).not.toThrow();
});
test("rejects git pathspec metacharacters", () => {
expect(() => validateDirName("app*")).toThrow("git pathspec");
expect(() => validateDirName("app?")).toThrow("git pathspec");
expect(() => validateDirName("app[0]")).toThrow("git pathspec");
expect(() => validateDirName("app:foo")).toThrow("git pathspec");
expect(() => validateDirName("app(1)")).toThrow("git pathspec");
});
test("rejects path traversal", () => {
expect(() => validateDirName("..")).toThrow("Invalid dirName");
expect(() => validateDirName("foo/bar")).toThrow("Invalid dirName");
});
});
// ---------------------------------------------------------------------------
// getAppDirPath() — returns correct path
// ---------------------------------------------------------------------------
describe("getAppDirPath()", () => {
test("returns correct path for slugified apps", () => {
const app = createApp(makeAppParams("Path Test"));
const appsDir = getAppsDir();
const result = getAppDirPath(app.id);
expect(result).toBe(join(appsDir, "path-test"));
});
test("returns correct path for legacy apps (no dirName)", () => {
const appsDir = getAppsDir();
const fakeId = "11111111-2222-3333-4444-555555555555";
// Write a legacy JSON with no dirName
writeFileSync(
join(appsDir, `${fakeId}.json`),
JSON.stringify({ id: fakeId, name: "Legacy" }),
);
const result = getAppDirPath(fakeId);
expect(result).toBe(join(appsDir, fakeId));
});
});
// ---------------------------------------------------------------------------
// Guard test: no file outside app-store.ts constructs getAppsDir() + appId
// (Note: the primary guard test is in app-dir-path-guard.test.ts; this is
// a complementary check that we can import and use the guard-relevant
// functions without issues.)
// ---------------------------------------------------------------------------
describe("guard: getAppsDir + appId path construction", () => {
test("app-dir-path-guard.test.ts exists and covers this concern", () => {
// This is a meta-test to ensure the guard test file is present
expect(existsSync(join(__dirname, "app-dir-path-guard.test.ts"))).toBe(
true,
);
});
});