Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

363 lines
12 KiB
TypeScript

/**
* Coverage for the manifest publisher field and its check/write-back.
*
* Two subjects:
* - The Zod schema's PublisherSchema (accepts DID or handle; rejects
* anything else).
* - `checkPublisher` and `writePublisherBack` in `manifest/publisher.ts`.
*
* The handle-resolution path is covered indirectly: we stub the resolver
* by passing values that look like a DID directly, which bypasses
* `@atcute/identity-resolver`. Real handle resolution requires DNS, which
* we don't exercise in unit tests.
*/
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Did } from "@atcute/lexicons/syntax";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { checkPublisher, writePublisherBack } from "../src/manifest/publisher.js";
import { ManifestSchema, PublisherSchema } from "../src/manifest/schema.js";
const SESSION_DID = "did:plc:abc123def456" as Did;
const OTHER_DID = "did:plc:xyz789otherrr" as Did;
describe("PublisherSchema", () => {
it("accepts a did:plc identifier", () => {
expect(PublisherSchema.parse("did:plc:abc123")).toBe("did:plc:abc123");
});
it("accepts a did:web identifier", () => {
expect(PublisherSchema.parse("did:web:example.com")).toBe("did:web:example.com");
});
it("accepts a handle", () => {
expect(PublisherSchema.parse("example.com")).toBe("example.com");
expect(PublisherSchema.parse("jane.bsky.social")).toBe("jane.bsky.social");
});
it("rejects a bare slug", () => {
const result = PublisherSchema.safeParse("not-a-handle-or-did");
expect(result.success).toBe(false);
});
it("rejects an empty string", () => {
const result = PublisherSchema.safeParse("");
expect(result.success).toBe(false);
});
it("rejects a malformed did", () => {
// `did:` without method+id is not a valid DID.
const result = PublisherSchema.safeParse("did:");
expect(result.success).toBe(false);
});
});
describe("ManifestSchema with publisher", () => {
const minimal = {
slug: "my-plugin",
version: "0.1.0",
license: "MIT",
author: { name: "Jane Doe" },
security: { email: "security@example.com" },
};
it("accepts a manifest with a DID publisher", () => {
const result = ManifestSchema.safeParse({
...minimal,
publisher: "did:plc:abc123",
});
expect(result.success).toBe(true);
});
it("accepts a manifest with a handle publisher", () => {
const result = ManifestSchema.safeParse({
...minimal,
publisher: "example.com",
});
expect(result.success).toBe(true);
});
it("rejects a manifest without a publisher", () => {
// publisher is required for the runtime to compute the plugin's
// AT URI. The author must fill it in before any local-dev or
// publish run.
const result = ManifestSchema.safeParse(minimal);
expect(result.success).toBe(false);
});
it("rejects a manifest with an invalid publisher", () => {
const result = ManifestSchema.safeParse({
...minimal,
publisher: "not-valid",
});
expect(result.success).toBe(false);
});
});
describe("checkPublisher", () => {
it("returns 'unpinned' when the manifest has no publisher", async () => {
const result = await checkPublisher({
manifestPublisher: undefined,
sessionDid: SESSION_DID,
});
expect(result.kind).toBe("unpinned");
});
it("returns 'match' when a pinned DID equals the session DID", async () => {
const result = await checkPublisher({
manifestPublisher: SESSION_DID,
sessionDid: SESSION_DID,
});
expect(result.kind).toBe("match");
if (result.kind === "match") {
expect(result.pinnedDid).toBe(SESSION_DID);
}
});
it("returns 'mismatch' when a pinned DID differs from the session DID", async () => {
const result = await checkPublisher({
manifestPublisher: OTHER_DID,
sessionDid: SESSION_DID,
});
expect(result.kind).toBe("mismatch");
if (result.kind === "mismatch") {
expect(result.pinnedDid).toBe(OTHER_DID);
expect(result.pinnedDisplay).toBe(OTHER_DID);
}
});
// Handle resolution requires DNS / .well-known reachability. We test
// the DID code path directly; the handle path is exercised in
// integration tests separately (and against a mock resolver in
// publisher-handle.test.ts when we add one later).
});
describe("writePublisherBack", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "emdash-publisher-test-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("inserts publisher after license, preserving comments and order", async () => {
const path = join(dir, "emdash-plugin.jsonc");
const source = `{
// Top-level comment
"slug": "my-plugin",
"version": "0.1.0",
"license": "MIT",
"author": { "name": "Jane Doe" },
"security": { "email": "security@example.com" }
}
`;
await writeFile(path, source, "utf8");
await writePublisherBack({ manifestPath: path, sessionDid: SESSION_DID });
const updated = await readFile(path, "utf8");
// The new key is present, the comment survives, and the DID is
// written verbatim.
expect(updated).toContain(`"publisher": "${SESSION_DID}"`);
expect(updated).toContain("// Top-level comment");
expect(updated).toContain('"license": "MIT"');
// The publisher must land AFTER license. We test ordering by
// finding the byte offset of each key.
const licenseIdx = updated.indexOf('"license"');
const publisherIdx = updated.indexOf('"publisher"');
const authorIdx = updated.indexOf('"author"');
expect(licenseIdx).toBeLessThan(publisherIdx);
expect(publisherIdx).toBeLessThan(authorIdx);
});
it("appends a // <handle> comment when a session handle is provided", async () => {
const path = join(dir, "emdash-plugin.jsonc");
const source = `{
"slug": "my-plugin",
"version": "0.1.0",
"license": "MIT",
"author": { "name": "Jane Doe" },
"security": { "email": "security@example.com" }
}
`;
await writeFile(path, source, "utf8");
await writePublisherBack({
manifestPath: path,
sessionDid: SESSION_DID,
sessionHandle: "jane.bsky.social",
});
const updated = await readFile(path, "utf8");
// The comment lives on the same line as the DID; the comma (if
// any) is between the DID and the comment per JSONC convention.
expect(updated).toMatch(/"publisher": "did:plc:abc123def456",? \/\/ jane\.bsky\.social/);
// And the round-trip still parses cleanly (the comment doesn't
// break JSONC).
const { loadManifest } = await import("../src/manifest/load.js");
const { manifest } = await loadManifest(path);
expect(manifest.publisher).toBe(SESSION_DID);
});
it("anchors the comment to the publisher property even when the DID appears in another field", async () => {
// Regression: a previous implementation searched for the DID
// string anywhere in the document, which would attach the
// `// <handle>` comment to whichever field happened to contain
// the DID-shaped substring first. Real plugins that document a
// "previous publisher was did:plc:..." in the description (e.g.
// after a maintainer transfer) would have triggered this.
const path = join(dir, "emdash-plugin.jsonc");
const source = `{
"slug": "my-plugin",
"version": "0.1.0",
"license": "MIT",
"description": "Originally published as ${SESSION_DID}. See changelog.",
"author": { "name": "Jane Doe" },
"security": { "email": "security@example.com" }
}
`;
await writeFile(path, source, "utf8");
await writePublisherBack({
manifestPath: path,
sessionDid: SESSION_DID,
sessionHandle: "jane.bsky.social",
});
const updated = await readFile(path, "utf8");
// The handle comment lives on the publisher line, NOT on the
// description line — confirm by inspecting each.
const lines = updated.split("\n");
const descriptionLine = lines.find((l) => l.includes('"description"'))!;
const publisherLine = lines.find((l) => l.includes('"publisher"'))!;
expect(descriptionLine).toBeDefined();
expect(publisherLine).toBeDefined();
expect(descriptionLine).not.toMatch(/\/\/ jane\.bsky\.social/);
expect(publisherLine).toMatch(/\/\/ jane\.bsky\.social/);
});
it("omits the comment when no handle is provided", async () => {
const path = join(dir, "emdash-plugin.jsonc");
const source = `{
"slug": "my-plugin",
"version": "0.1.0",
"license": "MIT",
"author": { "name": "Jane Doe" },
"security": { "email": "security@example.com" }
}
`;
await writeFile(path, source, "utf8");
await writePublisherBack({ manifestPath: path, sessionDid: SESSION_DID });
const updated = await readFile(path, "utf8");
// The DID line has no trailing `//` comment.
const publisherLine = updated.split("\n").find((l) => l.includes('"publisher"'));
expect(publisherLine).toBeDefined();
expect(publisherLine).not.toContain("//");
});
it("preserves the source's indentation (2-space)", async () => {
// Regression: an earlier version hard-coded tab indentation in
// the modify() formattingOptions, which silently reformatted
// any 2-space-indented manifest to tabs on first publish. The
// detector sniffs the source's existing indent and matches it.
const path = join(dir, "emdash-plugin.jsonc");
const source = [
"{",
' "slug": "my-plugin",',
' "version": "0.1.0",',
' "license": "MIT",',
' "author": { "name": "Jane Doe" },',
' "security": { "email": "security@example.com" }',
"}",
"",
].join("\n");
await writeFile(path, source, "utf8");
await writePublisherBack({ manifestPath: path, sessionDid: SESSION_DID });
const updated = await readFile(path, "utf8");
// Pre-existing 2-space lines should still be 2-space, no tab
// characters should have appeared anywhere.
expect(updated).not.toContain("\t");
// The new publisher line should also use 2 spaces.
const publisherLine = updated.split("\n").find((l) => l.includes('"publisher"'))!;
expect(publisherLine.startsWith(" ")).toBe(true);
expect(publisherLine.startsWith("\t")).toBe(false);
});
it("does not overwrite an existing publisher (defensive re-parse)", async () => {
const path = join(dir, "emdash-plugin.jsonc");
const source = `{
"slug": "my-plugin",
"version": "0.1.0",
"license": "MIT",
"publisher": "did:plc:user-pinned-already",
"author": { "name": "Jane Doe" },
"security": { "email": "security@example.com" }
}
`;
await writeFile(path, source, "utf8");
let warnings = 0;
let infos = 0;
await writePublisherBack({
manifestPath: path,
sessionDid: SESSION_DID,
onInfo: () => infos++,
onWarn: () => warnings++,
});
const updated = await readFile(path, "utf8");
// File unchanged: the existing publisher wins.
expect(updated).toContain('"publisher": "did:plc:user-pinned-already"');
expect(updated).not.toContain(SESSION_DID);
expect(infos).toBe(1);
expect(warnings).toBe(0);
});
it("does not fail when the file is missing (warns only)", async () => {
const path = join(dir, "no-such-file.jsonc");
let warnings = 0;
await writePublisherBack({
manifestPath: path,
sessionDid: SESSION_DID,
onWarn: () => warnings++,
});
// The publish has already succeeded by the time write-back runs;
// a missing file at this point is surprising but never fatal.
expect(warnings).toBe(1);
});
it("does not fail when the file no longer parses (warns only)", async () => {
const path = join(dir, "broken.jsonc");
// User broke the file while we were publishing.
await writeFile(path, '{ "license": "MIT", broken syntax', "utf8");
let warnings = 0;
await writePublisherBack({
manifestPath: path,
sessionDid: SESSION_DID,
onWarn: () => warnings++,
});
expect(warnings).toBe(1);
});
it("produces a JSONC document that round-trips through the loader", async () => {
const path = join(dir, "emdash-plugin.jsonc");
const source = `{
"slug": "my-plugin",
"version": "0.1.0",
"license": "MIT",
"author": { "name": "Jane Doe" },
"security": { "email": "security@example.com" }
}
`;
await writeFile(path, source, "utf8");
await writePublisherBack({ manifestPath: path, sessionDid: SESSION_DID });
// Re-load through the actual loader. If write-back produced
// malformed JSONC or a value the schema rejects, this throws.
const { loadManifest } = await import("../src/manifest/load.js");
const { manifest } = await loadManifest(path);
expect(manifest.publisher).toBe(SESSION_DID);
expect(manifest.license).toBe("MIT");
expect(manifest.author?.name).toBe("Jane Doe");
});
});