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

263 lines
9.5 KiB
TypeScript

import { join } from "node:path";
import { test, expect } from "../fixtures";
// The edit route preserves the entry's locale as a `?locale=` search param
// (see #1242), so the URL may carry a query string after the ULID.
const CONTENT_EDIT_URL_PATTERN = /\/content\/posts\/[A-Z0-9]+(?:\?.*)?$/;
// Shared 1x1 PNG fixture used by the media upload flows.
const TEST_IMAGE_PATH = join(process.cwd(), "e2e/fixtures/assets/test-image.png");
function apiHeaders(token: string, baseUrl: string) {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"X-EmDash-Request": "1",
Origin: baseUrl,
};
}
test.describe("Bylines", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});
test("creates and edits a guest byline in admin", async ({ admin, page }) => {
const unique = Date.now();
const initialName = `Guest Byline ${unique}`;
const updatedName = `Guest Byline Updated ${unique}`;
await admin.goto("/bylines");
await admin.waitForShell();
await admin.waitForLoading();
await page.getByRole("button", { name: "New" }).click();
await page.getByLabel("Display name").fill(initialName);
await page.getByLabel("Slug").fill(`guest-byline-${unique}`);
await page.getByRole("switch", { name: "Guest byline" }).click();
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByRole("button", { name: initialName })).toBeVisible({ timeout: 5000 });
await page.getByRole("button", { name: initialName }).click();
await page.getByLabel("Display name").fill(updatedName);
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByRole("button", { name: updatedName })).toBeVisible({ timeout: 5000 });
});
test("sets a byline avatar via the media picker and preserves it across edits (#1250)", async ({
admin,
page,
serverInfo,
}) => {
const unique = Date.now();
const name = `Avatar Byline ${unique}`;
const slug = `avatar-byline-${unique}`;
const headers = apiHeaders(serverInfo.token, serverInfo.baseUrl);
const getByline = async (id: string) => {
const response = await fetch(`${serverInfo.baseUrl}/_emdash/api/admin/bylines/${id}`, {
headers,
});
expect(response.ok).toBe(true);
const body: any = await response.json();
return body.data as { avatarMediaId: string | null };
};
// Create the byline up front via API so there's a stable id to assert
// against. It starts with no avatar — the field had no UI control before
// this fix, so the only way to set it was programmatically.
const createResponse = await fetch(`${serverInfo.baseUrl}/_emdash/api/admin/bylines`, {
method: "POST",
headers,
body: JSON.stringify({ displayName: name, slug, isGuest: true }),
});
expect(createResponse.ok).toBe(true);
const createBody: any = await createResponse.json();
const bylineId = createBody.data.id as string;
expect(createBody.data.avatarMediaId).toBeNull();
await admin.goto("/bylines");
await admin.waitForShell();
await admin.waitForLoading();
// Open the byline in the editor and confirm the avatar field renders.
await page.getByRole("button", { name }).click();
await expect(page.getByText("Avatar", { exact: true })).toBeVisible();
// Open the avatar picker and upload an image. The picker auto-selects
// the freshly uploaded item, enabling the Insert button.
await page.getByRole("button", { name: "Select image" }).click();
const dialog = page.locator('[role="dialog"]').filter({ hasText: "Select Avatar" });
await expect(dialog).toBeVisible();
const uploadDone = page.waitForResponse(
(res) => /\/api\/media/.test(res.url()) && res.request().method() === "POST" && res.ok(),
{ timeout: 15000 },
);
await dialog.locator('input[type="file"]').setInputFiles(TEST_IMAGE_PATH);
await uploadDone;
// Two "Insert" buttons exist (the disabled "Insert from URL" action and
// the footer confirm); the confirm enables once an item is selected.
await dialog.getByRole("button", { name: "Insert", disabled: false }).click();
await expect(dialog).not.toBeVisible();
// Persist the byline and wait for the PUT to land.
const firstSave = page.waitForResponse(
(res) =>
res.url().includes(`/api/admin/bylines/${bylineId}`) &&
res.request().method() === "PUT" &&
res.ok(),
{ timeout: 10000 },
);
await page.getByRole("button", { name: "Save" }).click();
await firstSave;
// The avatar id is now persisted through the UI.
const afterSet = await getByline(bylineId);
expect(afterSet.avatarMediaId).toBeTruthy();
const avatarId = afterSet.avatarMediaId;
// Regression guard for #1250: editing another field through the UI must
// not wipe the avatar. The PUT route coerces a missing `avatarMediaId`
// back to null, so before the fix every save dropped the avatar.
await page.getByLabel("Display name").fill(`${name} edited`);
const secondSave = page.waitForResponse(
(res) =>
res.url().includes(`/api/admin/bylines/${bylineId}`) &&
res.request().method() === "PUT" &&
res.ok(),
{ timeout: 10000 },
);
await page.getByRole("button", { name: "Save" }).click();
await secondSave;
const afterEdit = await getByline(bylineId);
expect(afterEdit.avatarMediaId).toBe(avatarId);
});
test("assigns and reorders bylines, preserves bylines on ownership change", async ({
admin,
page,
serverInfo,
}) => {
const unique = Date.now();
const primaryName = `Primary Writer ${unique}`;
const secondaryName = `Secondary Writer ${unique}`;
const headers = apiHeaders(serverInfo.token, serverInfo.baseUrl);
const createByline = async (displayName: string, slug: string) => {
const response = await fetch(`${serverInfo.baseUrl}/_emdash/api/admin/bylines`, {
method: "POST",
headers,
body: JSON.stringify({
displayName,
slug,
isGuest: true,
}),
});
expect(response.ok).toBe(true);
const body: any = await response.json();
return body.data.id as string;
};
// Create two bylines for the test post. IDs aren't needed downstream;
// the test selects them by name via the bylines combobox.
await createByline(primaryName, `primary-writer-${unique}`);
await createByline(secondaryName, `secondary-writer-${unique}`);
await admin.goToNewContent("posts");
await admin.waitForLoading();
await admin.fillField("title", `Byline E2E Post ${unique}`);
await admin.clickSave();
await expect(page).toHaveURL(CONTENT_EDIT_URL_PATTERN, { timeout: 10000 });
const contentId = page.url().split("/").pop();
expect(contentId).toBeTruthy();
await admin.waitForLoading();
// Scope the byline picker to the Bylines section to avoid hitting the Ownership combobox
const bylinesSidebar = page
.getByRole("heading", { name: "Bylines" })
.locator("xpath=ancestor::div[contains(@class,'p-4')]")
.first();
// The picker is a debounced server search: type a name, wait for the result
// button to appear, click it, then wait for the credit row to commit before
// the next add (the search debounce + React commit race otherwise drops one).
const bylineSearch = bylinesSidebar.getByLabel("Search bylines");
const creditRow = (displayName: string) =>
bylinesSidebar.locator("p.text-sm.font-medium").filter({ hasText: displayName });
const addByline = async (displayName: string) => {
await bylineSearch.fill(displayName);
const result = bylinesSidebar.getByRole("button", { name: displayName });
await expect(result).toBeVisible({ timeout: 5000 });
await result.click();
await expect(creditRow(displayName)).toBeVisible({ timeout: 5000 });
};
await addByline(primaryName);
await addByline(secondaryName);
// Move the secondary credit above the primary via its own row's "Up" button,
// then confirm the reorder committed before saving.
const secondaryCreditRow = bylinesSidebar
.locator("div.rounded-lg.border.p-2")
.filter({ hasText: secondaryName });
await secondaryCreditRow.getByLabel("Role label").fill("Co-author");
await secondaryCreditRow.getByRole("button", { name: "Up" }).click();
await expect(bylinesSidebar.locator("p.text-sm.font-medium").first()).toContainText(
secondaryName,
);
await admin.clickSave();
await admin.waitForSaveComplete();
await expect(bylinesSidebar.locator("p.text-sm.font-medium").first()).toContainText(
secondaryName,
);
const ownershipUpdateResponse = await fetch(
`${serverInfo.baseUrl}/_emdash/api/content/posts/${contentId as string}`,
{
method: "PUT",
headers,
body: JSON.stringify({ authorId: null }),
},
);
expect(ownershipUpdateResponse.ok).toBe(true);
await page.reload();
await admin.waitForShell();
await admin.waitForLoading();
const bylineSectionAfterReload = page
.getByRole("heading", { name: "Bylines" })
.locator("xpath=ancestor::div[contains(@class,'p-4')]")
.first();
await expect(bylineSectionAfterReload.locator("p.text-sm.font-medium").first()).toContainText(
secondaryName,
);
const contentResponse = await fetch(
`${serverInfo.baseUrl}/_emdash/api/content/posts/${contentId as string}`,
{ headers },
);
expect(contentResponse.ok).toBe(true);
const contentBody: any = await contentResponse.json();
const item = contentBody.data?.item;
expect(item.byline?.displayName).toBe(secondaryName);
expect(item.bylines).toHaveLength(2);
expect(item.bylines[0]?.byline?.displayName).toBe(secondaryName);
expect(item.bylines[1]?.byline?.displayName).toBe(primaryName);
const secondaryCredit = item.bylines.find(
(credit: any) => credit?.byline?.displayName === secondaryName,
);
expect(secondaryCredit?.roleLabel).toBe("Co-author");
});
});