- ${profileFeedTemplate({
+ ${profileListTemplate({
profiles: members,
hasMore: false,
emptyMessage: "This starter pack has no members.",
@@ -363,13 +360,9 @@ export default async function starterPackDetailView({
})}
`
: activeTab === "feeds"
- ? feedsFeedTemplate({
- items: feeds,
- renderItem: (feedGenerator) =>
- feedGeneratorListItemTemplate({
- feedGenerator,
- currentUserDid: currentUser?.did ?? null,
- }),
+ ? feedGeneratorListTemplate({
+ feedGenerators: feeds,
+ currentUserDid: currentUser?.did ?? null,
})
: html`
${postFeedTemplate({
@@ -413,15 +406,66 @@ export default async function starterPackDetailView({
}
}
+ async function handleEditStarterPack(starterPack) {
+ const listUri = starterPack.list?.uri ?? null;
+ if (!listUri) {
+ showToast("This starter pack's list is missing", { style: "error" });
+ return;
+ }
+ const membersEntry = dataLayer.derived.$listMembers.get(listUri);
+ if (!membersEntry || membersEntry.cursor) {
+ await dataLayer.requests
+ .loadAllListMembers(listUri)
+ .catch((error) =>
+ console.warn("Failed to load starter pack members", error),
+ );
+ }
+ const members = dataLayer.derived.$listMembers.get(listUri);
+ if (!members || members.cursor) {
+ showToast("Couldn't load this starter pack's members", {
+ style: "error",
+ });
+ return;
+ }
+ const dialog = document.createElement("starter-pack-wizard-dialog");
+ dialog.dataLayer = dataLayer;
+ dialog.starterPack = starterPack;
+ dialog.members = members.members;
+ dialog.optedOutDids = members.optedOutDids;
+ dialog.addEventListener("starter-pack-update", async (event) => {
+ const { data, successCallback, errorCallback } = event.detail;
+ try {
+ await dataLayer.mutations.updateStarterPack(starterPack, data);
+ showToast("Starter pack updated");
+ successCallback();
+ } catch (error) {
+ errorCallback(error);
+ }
+ });
+ dialog.addEventListener("dialog-closed", () => {
+ dialog.remove();
+ });
+ root.querySelector("main").appendChild(dialog);
+ dialog.open();
+ }
+
+ async function handleDeleteStarterPack(starterPack) {
+ const deleted =
+ await starterPackInteractionHandler.handleDelete(starterPack);
+ if (!deleted) return;
+ const fallbackRoute = starterPack.creator?.handle
+ ? `/profile/${starterPack.creator.handle}`
+ : "/";
+ window.router.back({ fallbackRoute });
+ }
+
async function handleOptOut(list) {
- const optedOut =
- await listInteractionHandler.handleOptOutOfReferenceList(list);
+ const optedOut = await starterPackInteractionHandler.handleOptOut(list);
if (optedOut) reloadMembers(list.uri);
}
async function handleUndoOptOut(list) {
- const undone =
- await listInteractionHandler.handleUndoReferenceListOptOut(list);
+ const undone = await starterPackInteractionHandler.handleUndoOptOut(list);
if (undone) reloadMembers(list.uri);
}
@@ -437,7 +481,12 @@ export default async function starterPackDetailView({
}
async function loadPageData() {
- await dataLayer.requests.loadStarterPack(starterPackUri);
+ state.$hasFetchedStarterPack.set(false);
+ try {
+ await dataLayer.requests.loadStarterPack(starterPackUri);
+ } finally {
+ state.$hasFetchedStarterPack.set(true);
+ }
const listUri =
dataLayer.derived.$starterPacks.get(starterPackUri)?.list?.uri ?? null;
if (!listUri) return;
diff --git a/tests/e2e/mockServer.js b/tests/e2e/mockServer.js
index cc931b13..4a058dda 100644
--- a/tests/e2e/mockServer.js
+++ b/tests/e2e/mockServer.js
@@ -37,6 +37,7 @@ export class MockServer {
this.messageCounter = 0;
this.sentMessageRequests = [];
this.sendMessageFailure = null;
+ this.listMembersFailure = null;
this.createRecordFailures = new Map();
this.convoForMembersError = null;
this.chatActorStatus = {
@@ -92,6 +93,9 @@ export class MockServer {
this.savedFeedUris = [];
this.actorFeeds = new Map();
this.actorLists = new Map();
+ this.actorStarterPacks = new Map();
+ this.starterPackRecords = new Map();
+ this.putRecordCalls = [];
this.searchFeedGenerators = [];
this.searchStarterPacks = [];
this.trends = [];
@@ -114,6 +118,7 @@ export class MockServer {
// is absent from getPreferences and no putPreferences has written it.
this.searchHistory = null;
this.getProfilesDelayMs = 0;
+ this.getStarterPackDelayMs = 0;
this.putPreferencesDelayMs = 0;
// Override the source served for the local test plugin's main.js; defaults
// to the standard fixture when null.
@@ -260,6 +265,11 @@ export class MockServer {
this.starterPacks.push(...starterPacks);
}
+ addActorStarterPacks(did, starterPacks) {
+ const existing = this.actorStarterPacks.get(did) || [];
+ this.actorStarterPacks.set(did, [...existing, ...starterPacks]);
+ }
+
addListMembers(listUri, profiles) {
this.listMembers.set(listUri, profiles);
}
@@ -294,6 +304,88 @@ export class MockServer {
);
}
+ _ingestListItemRecord(record, uri) {
+ this.currentUserListItems.push({
+ uri,
+ listUri: record.list,
+ subjectDid: record.subject,
+ });
+ const profile = this.profiles.get(record.subject);
+ if (profile) {
+ const members = this.listMembers.get(record.list) || [];
+ if (!members.some((p) => p.did === profile.did)) {
+ this.listMembers.set(record.list, [profile, ...members]);
+ }
+ }
+ }
+
+ _removeListItemRecord(uri) {
+ const index = this.currentUserListItems.findIndex(
+ (item) => item.uri === uri,
+ );
+ if (index === -1) return;
+ const [removed] = this.currentUserListItems.splice(index, 1);
+ const members = this.listMembers.get(removed.listUri);
+ if (members) {
+ this.listMembers.set(
+ removed.listUri,
+ members.filter((profile) => profile.did !== removed.subjectDid),
+ );
+ }
+ }
+
+ _ingestListRecord(record, uri) {
+ this.lists.push({
+ uri,
+ cid: `bafyrei${uri.split("/").pop()}`,
+ creator: userProfile,
+ name: record.name,
+ purpose: record.purpose,
+ description: record.description || "",
+ descriptionFacets: record.descriptionFacets || [],
+ avatar: "",
+ indexedAt: record.createdAt,
+ labels: [],
+ viewer: {},
+ });
+ }
+
+ _ingestStarterPackRecord(record, uri) {
+ const rkey = uri.split("/").pop();
+ const list = this.lists.find((entry) => entry.uri === record.list);
+ const members = this.listMembers.get(record.list) || [];
+ const feeds = (record.feeds || [])
+ .map((ref) => this.feedGenerators.find((feed) => feed.uri === ref.uri))
+ .filter(Boolean);
+ const starterPack = {
+ uri,
+ cid: `bafyrei${rkey}`,
+ record: { $type: "app.bsky.graph.starterpack", ...record },
+ creator: userProfile,
+ list,
+ listItemsSample: members.slice(0, 12).map((profile) => ({
+ uri: `${record.list}/item-${profile.did}`,
+ subject: profile,
+ })),
+ feeds,
+ joinedWeekCount: 0,
+ joinedAllTimeCount: 0,
+ labels: [],
+ indexedAt: record.createdAt,
+ };
+ this.starterPackRecords.set(uri, {
+ cid: starterPack.cid,
+ value: starterPack.record,
+ });
+ this.starterPacks = this.starterPacks.filter((pack) => pack.uri !== uri);
+ this.starterPacks.push(starterPack);
+ const existing = this.actorStarterPacks.get(userProfile.did) || [];
+ this.actorStarterPacks.set(userProfile.did, [
+ starterPack,
+ ...existing.filter((pack) => pack.uri !== uri),
+ ]);
+ }
+
_withReferenceListOptOut(list) {
if (!list?.uri) return list;
let optOutUri;
@@ -349,6 +441,10 @@ export class MockServer {
this.notificationsDelayMs = delayMs;
}
+ setGetStarterPackDelay(delayMs) {
+ this.getStarterPackDelayMs = delayMs;
+ }
+
setNotificationsSeenAt(seenAt) {
this.notificationsSeenAt = seenAt;
}
@@ -488,6 +584,10 @@ export class MockServer {
this.convoMembersFailure = { status, error, message };
}
+ failListMembers({ status = 500, error = "InternalServerError", message }) {
+ this.listMembersFailure = { status, error, message };
+ }
+
setConvoDelay(convoId, delayMs) {
this.convoDelays.set(convoId, delayMs);
}
@@ -2163,6 +2263,14 @@ export class MockServer {
});
await page.route("**/xrpc/app.bsky.graph.getList*", (route) => {
+ if (this.listMembersFailure) {
+ const { status, error, message } = this.listMembersFailure;
+ return route.fulfill({
+ status,
+ contentType: "application/json",
+ body: JSON.stringify({ error, message }),
+ });
+ }
const url = new URL(route.request().url());
const listUri = url.searchParams.get("list");
const list = this._withReferenceListOptOut(
@@ -2201,20 +2309,54 @@ export class MockServer {
});
});
- await page.route("**/xrpc/app.bsky.graph.getStarterPack?*", (route) => {
- const url = new URL(route.request().url());
- const starterPackUri = url.searchParams.get("starterPack");
- const found =
- this.starterPacks.find((s) => s.uri === starterPackUri) || {};
- const starterPack = found.list
- ? { ...found, list: this._withReferenceListOptOut(found.list) }
- : found;
- return route.fulfill({
- status: 200,
- contentType: "application/json",
- body: JSON.stringify({ starterPack }),
- });
- });
+ await page.route(
+ "**/xrpc/app.bsky.graph.getStarterPack?*",
+ async (route) => {
+ if (this.getStarterPackDelayMs > 0) {
+ await new Promise((resolve) =>
+ setTimeout(resolve, this.getStarterPackDelayMs),
+ );
+ }
+ const url = new URL(route.request().url());
+ const starterPackUri = url.searchParams.get("starterPack");
+ const found =
+ this.starterPacks.find((s) => s.uri === starterPackUri) || {};
+ const starterPack = found.list
+ ? { ...found, list: this._withReferenceListOptOut(found.list) }
+ : found;
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ starterPack }),
+ });
+ },
+ );
+
+ await page.route(
+ "**/xrpc/app.bsky.graph.getActorStarterPacks*",
+ (route) => {
+ const url = new URL(route.request().url());
+ const actor = url.searchParams.get("actor") || "";
+ const cursor = url.searchParams.get("cursor") || "";
+ const limit = parseInt(url.searchParams.get("limit") || "0", 10);
+ const offset = cursor ? parseInt(cursor, 10) : 0;
+ const allPacks = this.actorStarterPacks.get(actor) || [];
+ let starterPacks, nextCursor;
+ if (limit) {
+ starterPacks = allPacks.slice(offset, offset + limit);
+ nextCursor =
+ offset + limit < allPacks.length ? String(offset + limit) : "";
+ } else {
+ starterPacks = allPacks;
+ nextCursor = "";
+ }
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ starterPacks, cursor: nextCursor }),
+ });
+ },
+ );
await page.route("**/xrpc/app.bsky.graph.getLists*", (route) => {
const url = new URL(route.request().url());
@@ -2731,15 +2873,57 @@ export class MockServer {
const body = route.request().postDataJSON();
const writes = body?.writes ?? [];
this.applyWritesCalls.push(writes);
+ const results = [];
for (const write of writes) {
- if (write.collection !== "app.bsky.feed.post") continue;
- const uri = `at://${userProfile.did}/app.bsky.feed.post/${write.rkey}`;
- this._ingestPostRecord(write.value, uri);
+ const uri = `at://${userProfile.did}/${write.collection}/${write.rkey}`;
+ const isCreate = write.$type === "com.atproto.repo.applyWrites#create";
+ const isDelete = write.$type === "com.atproto.repo.applyWrites#delete";
+ if (write.collection === "app.bsky.feed.post" && isCreate) {
+ this._ingestPostRecord(write.value, uri);
+ }
+ if (write.collection === "app.bsky.graph.listitem") {
+ if (isCreate) {
+ this._ingestListItemRecord(write.value, uri);
+ } else if (isDelete) {
+ this._removeListItemRecord(uri);
+ }
+ }
+ if (write.collection === "app.bsky.graph.list") {
+ if (isCreate) {
+ this._ingestListRecord(write.value, uri);
+ } else if (isDelete) {
+ this.lists = this.lists.filter((list) => list.uri !== uri);
+ this.listMembers.delete(uri);
+ }
+ }
+ if (write.collection === "app.bsky.graph.starterpack") {
+ if (isCreate) {
+ this._ingestStarterPackRecord(write.value, uri);
+ } else if (isDelete) {
+ this.starterPacks = this.starterPacks.filter(
+ (pack) => pack.uri !== uri,
+ );
+ this.starterPackRecords.delete(uri);
+ for (const [did, packs] of this.actorStarterPacks) {
+ this.actorStarterPacks.set(
+ did,
+ packs.filter((pack) => pack.uri !== uri),
+ );
+ }
+ }
+ }
+ if (isCreate) {
+ results.push({
+ $type: "com.atproto.repo.applyWrites#createResult",
+ uri,
+ cid: `bafyrei${write.rkey}`,
+ });
+ }
}
return route.fulfill({
status: 200,
contentType: "application/json",
- body: JSON.stringify({ results: [] }),
+ body: JSON.stringify({ results }),
});
});
@@ -2761,19 +2945,7 @@ export class MockServer {
const cid = `bafyrei${rkey}`;
if (collection === "app.bsky.graph.listitem") {
- const record = body?.record || {};
- this.currentUserListItems.push({
- uri,
- listUri: record.list,
- subjectDid: record.subject,
- });
- const profile = this.profiles.get(record.subject);
- if (profile) {
- const members = this.listMembers.get(record.list) || [];
- if (!members.some((p) => p.did === profile.did)) {
- this.listMembers.set(record.list, [profile, ...members]);
- }
- }
+ this._ingestListItemRecord(body?.record || {}, uri);
}
if (collection === "app.bsky.feed.post") {
@@ -3008,6 +3180,31 @@ export class MockServer {
}),
});
}
+ if (collection === "app.bsky.graph.starterpack") {
+ const repo = url.searchParams.get("repo");
+ const packUri = `at://${repo}/${collection}/${rkey}`;
+ const seeded = this.starterPackRecords.get(packUri);
+ const found = this.starterPacks.find((pack) => pack.uri === packUri);
+ if (seeded || found) {
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ uri: packUri,
+ cid: seeded?.cid ?? found.cid,
+ value: seeded?.value ?? found.record,
+ }),
+ });
+ }
+ return route.fulfill({
+ status: 400,
+ contentType: "application/json",
+ body: JSON.stringify({
+ error: "RecordNotFound",
+ message: `Could not locate record: ${packUri}`,
+ }),
+ });
+ }
if (collection === "app.bsky.graph.list") {
const repo = url.searchParams.get("repo");
const listUri = `at://${repo}/${collection}/${rkey}`;
@@ -3144,10 +3341,24 @@ export class MockServer {
}),
});
}
+ if (collection === "app.bsky.graph.starterpack") {
+ const repo = body?.repo;
+ const rkey = body?.rkey;
+ const packUri = `at://${repo}/${collection}/${rkey}`;
+ this.putRecordCalls.push(body);
+ const cid = `bafyreiupdated${rkey}`;
+ this.starterPackRecords.set(packUri, { cid, value: body?.record });
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ uri: packUri, cid }),
+ });
+ }
if (collection === "app.bsky.graph.list") {
const repo = body?.repo;
const rkey = body?.rkey;
const listUri = `at://${repo}/${collection}/${rkey}`;
+ this.putRecordCalls.push(body);
// Intentionally do NOT mutate the list here: the client patches its
// local list from the record it just wrote, matching real bsky
// AppView behavior (which briefly returns stale data after putRecord).
diff --git a/tests/e2e/specs/flows/addProfileToList.test.js b/tests/e2e/specs/flows/addProfileToList.test.js
index 16485f3f..0592dd5b 100644
--- a/tests/e2e/specs/flows/addProfileToList.test.js
+++ b/tests/e2e/specs/flows/addProfileToList.test.js
@@ -121,7 +121,7 @@ test.describe("Add profile to list β List Detail flow", () => {
// First page is 50 lists (the default limit).
await expect(rows).toHaveCount(50, { timeout: 5000 });
await expect(
- dialog.locator('[data-testid="add-to-lists-loading-more"]'),
+ dialog.locator('[data-testid="feed-loading-indicator"]'),
).toBeVisible();
// Scrolling the dialog list to the bottom triggers the next page.
@@ -131,7 +131,7 @@ test.describe("Add profile to list β List Detail flow", () => {
await expect(rows).toHaveCount(TOTAL_LISTS, { timeout: 5000 });
await expect(
- dialog.locator('[data-testid="add-to-lists-loading-more"]'),
+ dialog.locator('[data-testid="feed-loading-indicator"]'),
).toHaveCount(0);
});
diff --git a/tests/e2e/specs/flows/blockUser.test.js b/tests/e2e/specs/flows/blockUser.test.js
index 6b5b291a..ca4272de 100644
--- a/tests/e2e/specs/flows/blockUser.test.js
+++ b/tests/e2e/specs/flows/blockUser.test.js
@@ -65,9 +65,9 @@ test.describe("Block user flow", () => {
// Navigate back to home and verify posts are hidden
await page.goto("/");
- await expect(
- homeView.locator('[data-testid="feed-end-message"]'),
- ).toBeVisible({ timeout: 10000 });
+ await expect(homeView.locator('[data-testid="empty-state"]')).toBeVisible({
+ timeout: 10000,
+ });
// Navigate back to profile and verify blocked state persists
await page.goto(`/profile/${otherUser.did}`);
@@ -122,9 +122,9 @@ test.describe("Block user flow", () => {
});
// Verify the post is filtered out of the feed
- await expect(
- homeView.locator('[data-testid="feed-end-message"]'),
- ).toBeVisible({ timeout: 10000 });
+ await expect(homeView.locator('[data-testid="empty-state"]')).toBeVisible({
+ timeout: 10000,
+ });
// Navigate to the blocked user's profile and verify blocked state
await page.goto(`/profile/${otherUser.did}`);
@@ -237,9 +237,9 @@ test.describe("Block user flow", () => {
// Should back-navigate to home and the blocked user's post should be gone
await expect(homeView).toBeVisible({ timeout: 10000 });
- await expect(
- homeView.locator('[data-testid="feed-end-message"]'),
- ).toBeVisible({ timeout: 10000 });
+ await expect(homeView.locator('[data-testid="empty-state"]')).toBeVisible({
+ timeout: 10000,
+ });
});
test("should not back-navigate when the block confirmation is cancelled", async ({
@@ -435,9 +435,9 @@ test.describe("Block user flow", () => {
// Verify posts are hidden on home initially (user is blocked)
await page.goto("/");
const homeView = page.locator("#home-view");
- await expect(
- homeView.locator('[data-testid="feed-end-message"]'),
- ).toBeVisible({ timeout: 10000 });
+ await expect(homeView.locator('[data-testid="empty-state"]')).toBeVisible({
+ timeout: 10000,
+ });
// Navigate to blocked user's profile and unblock
await page.goto(`/profile/${blockedUser.did}`);
diff --git a/tests/e2e/specs/flows/bookmarkPost.test.js b/tests/e2e/specs/flows/bookmarkPost.test.js
index 34a054d7..cca4033a 100644
--- a/tests/e2e/specs/flows/bookmarkPost.test.js
+++ b/tests/e2e/specs/flows/bookmarkPost.test.js
@@ -82,7 +82,7 @@ test.describe("Bookmark post flow", () => {
await page.goto("/bookmarks");
await expect(
- bookmarksView.locator('[data-testid="feed-end-message"]'),
+ bookmarksView.locator('[data-testid="empty-state"]'),
).toContainText("No saved posts yet!", { timeout: 10000 });
});
});
diff --git a/tests/e2e/specs/flows/createStarterPack.test.js b/tests/e2e/specs/flows/createStarterPack.test.js
new file mode 100644
index 00000000..d686ed06
--- /dev/null
+++ b/tests/e2e/specs/flows/createStarterPack.test.js
@@ -0,0 +1,127 @@
+import { test, expect } from "../../base.js";
+import { login } from "../../helpers.js";
+import { MockServer } from "../../mockServer.js";
+import { userProfile } from "../../testData.js";
+import {
+ createFeedGenerator,
+ createProfile,
+} from "../../../shared/factories.js";
+
+function makeFollows(count) {
+ return Array.from({ length: count }, (_, i) =>
+ createProfile({
+ did: `did:plc:follow${i}`,
+ handle: `follow${i}.bsky.social`,
+ displayName: `Follow ${i}`,
+ }),
+ );
+}
+
+test.describe("Profile β create starter pack flow", () => {
+ test("creates a starter pack from the profile tab and lands on its page", async ({
+ page,
+ }) => {
+ const follows = makeFollows(9);
+ const feed = createFeedGenerator({
+ uri: "at://did:plc:feedowner/app.bsky.feed.generator/cats",
+ displayName: "Cats",
+ creatorHandle: "feeds.bsky.social",
+ });
+ const mockServer = new MockServer();
+ mockServer.addProfile(userProfile);
+ for (const profile of follows) mockServer.addProfile(profile);
+ mockServer.addProfileFollows(userProfile.did, follows);
+ mockServer.addFeedGenerators([feed]);
+ mockServer.addSearchFeedGenerators([feed]);
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto(`/profile/${userProfile.handle}`);
+
+ const profileView = page.locator("#profile-view");
+ const tab = profileView.locator('[data-testid="tab-starter-packs"]');
+ await expect(tab).toBeVisible({ timeout: 10000 });
+ await tab.click();
+
+ const createButton = profileView.locator(
+ '[data-testid="starter-pack-create-button"]',
+ );
+ await expect(createButton).toBeVisible({ timeout: 10000 });
+ await createButton.click();
+
+ const wizard = page.locator('[data-testid="starter-pack-wizard"]');
+ await expect(wizard).toHaveAttribute("data-teststate", "details");
+ await wizard
+ .locator('[data-testid="starter-pack-name-input"]')
+ .fill("Cool People");
+ await wizard
+ .locator('[data-testid="starter-pack-description-input"]')
+ .fill("Some cool people");
+ await wizard.locator('[data-testid="starter-pack-wizard-next"]').click();
+ await expect(wizard).toHaveAttribute("data-teststate", "profiles");
+
+ const toggles = wizard.locator(
+ '[data-testid="starter-pack-profile-toggle"][data-teststate="not-included"]',
+ );
+ await expect(toggles).toHaveCount(9, { timeout: 10000 });
+ const nextButton = wizard.locator(
+ '[data-testid="starter-pack-wizard-next"]',
+ );
+ await expect(nextButton).toBeDisabled();
+ const allToggles = wizard.locator(
+ '[data-testid="starter-pack-profile-toggle"]',
+ );
+ for (let i = 0; i < 7; i++) {
+ await allToggles.nth(i).click();
+ await expect(allToggles.nth(i)).toHaveAttribute(
+ "data-teststate",
+ "included",
+ );
+ }
+ await expect(
+ wizard.locator(
+ '[data-testid="starter-pack-profile-toggle"][data-teststate="included"]',
+ ),
+ ).toHaveCount(7);
+ await expect(nextButton).toBeEnabled();
+ await nextButton.click();
+ await expect(wizard).toHaveAttribute("data-teststate", "feeds");
+
+ const feedToggle = wizard.locator(
+ '[data-testid="starter-pack-feed-toggle"][data-teststate="not-included"]',
+ );
+ await expect(feedToggle).toHaveCount(1, { timeout: 10000 });
+ await feedToggle.click();
+ await expect(nextButton).toHaveText("Finish");
+ await nextButton.click();
+
+ await expect(page).toHaveURL(
+ new RegExp(`/profile/${userProfile.handle}/starter-pack/[^/]+$`),
+ { timeout: 10000 },
+ );
+ const detail = page.locator("#starter-pack-detail-view");
+ await expect(
+ detail.locator('[data-testid="starter-pack-name"]'),
+ ).toHaveText("Cool People", { timeout: 10000 });
+ await expect(
+ detail.locator('[data-testid="starter-pack-description"]'),
+ ).toContainText("Some cool people");
+ await expect(page.locator('[data-testid="toast"]')).toBeVisible();
+
+ expect(mockServer.applyWritesCalls).toHaveLength(1);
+ const writes = mockServer.applyWritesCalls[0];
+ expect(writes.map((write) => write.collection)).toEqual([
+ "app.bsky.graph.list",
+ ...Array(8).fill("app.bsky.graph.listitem"),
+ "app.bsky.graph.starterpack",
+ ]);
+ expect(writes[0].value.purpose).toBe("app.bsky.graph.defs#referencelist");
+ expect(writes[1].value.subject).toBe(userProfile.did);
+ const pack = writes[writes.length - 1].value;
+ expect(pack.name).toBe("Cool People");
+ expect(pack.feeds).toEqual([{ uri: feed.uri }]);
+ expect(pack.list).toBe(
+ `at://${userProfile.did}/app.bsky.graph.list/${writes[0].rkey}`,
+ );
+ });
+});
diff --git a/tests/e2e/specs/flows/deletePost.test.js b/tests/e2e/specs/flows/deletePost.test.js
index c63d2231..a27e04bf 100644
--- a/tests/e2e/specs/flows/deletePost.test.js
+++ b/tests/e2e/specs/flows/deletePost.test.js
@@ -53,15 +53,15 @@ test.describe("Delete post flow", () => {
// Verify the post is removed from the profile
await expect(
profileView.locator(
- '.feed-container:not([hidden]) [data-testid="feed-end-message"]',
+ '.feed-container:not([hidden]) [data-testid="empty-state"]',
),
).toBeVisible({ timeout: 10000 });
// Navigate to home and verify the post is gone
await page.goto("/");
- await expect(
- homeView.locator('[data-testid="feed-end-message"]'),
- ).toBeVisible({ timeout: 10000 });
+ await expect(homeView.locator('[data-testid="empty-state"]')).toBeVisible({
+ timeout: 10000,
+ });
});
test("should update thread view after deleting post from thread", async ({
diff --git a/tests/e2e/specs/flows/deleteStarterPack.test.js b/tests/e2e/specs/flows/deleteStarterPack.test.js
new file mode 100644
index 00000000..c774e4c1
--- /dev/null
+++ b/tests/e2e/specs/flows/deleteStarterPack.test.js
@@ -0,0 +1,85 @@
+import { test, expect } from "../../base.js";
+import { login } from "../../helpers.js";
+import { MockServer } from "../../mockServer.js";
+import { userProfile } from "../../testData.js";
+import { createProfile, createStarterPack } from "../../../shared/factories.js";
+
+const PACK_URI = `at://${userProfile.did}/app.bsky.graph.starterpack/doomed`;
+const LIST_URI = `at://${userProfile.did}/app.bsky.graph.list/doomed`;
+
+test.describe("Starter pack delete flow", () => {
+ test("deletes the pack with its list and items, then returns to the profile", async ({
+ page,
+ }) => {
+ const member = createProfile({
+ did: "did:plc:member1",
+ handle: "member1.bsky.social",
+ displayName: "Member One",
+ });
+ const starterPack = createStarterPack({
+ uri: PACK_URI,
+ name: "Doomed Pack",
+ creatorHandle: userProfile.handle,
+ list: true,
+ });
+ const mockServer = new MockServer();
+ mockServer.addProfile({ ...userProfile, associated: { starterPacks: 1 } });
+ mockServer.addProfile(member);
+ mockServer.addStarterPacks([starterPack]);
+ mockServer.addActorStarterPacks(userProfile.did, [starterPack]);
+ mockServer.addLists([starterPack.list]);
+ mockServer.addListMembers(LIST_URI, [member]);
+ mockServer.addCurrentUserListItem({
+ uri: `at://${userProfile.did}/app.bsky.graph.listitem/item1`,
+ listUri: LIST_URI,
+ subjectDid: member.did,
+ });
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto(`/profile/${userProfile.handle}`);
+ const profileView = page.locator("#profile-view");
+ const tab = profileView.locator('[data-testid="tab-starter-packs"]');
+ await expect(tab).toBeVisible({ timeout: 10000 });
+ await tab.click();
+ const packItem = profileView.locator(
+ '.feed-container:not([hidden]) [data-testid="starter-pack-list-item"]',
+ );
+ await expect(packItem).toHaveCount(1, { timeout: 10000 });
+ await packItem.click();
+
+ const detail = page.locator("#starter-pack-detail-view");
+ await expect(
+ detail.locator('[data-testid="starter-pack-name"]'),
+ ).toHaveText("Doomed Pack", { timeout: 10000 });
+ await detail.locator(".context-menu-button").click();
+ await detail
+ .locator('[data-testid="menu-action-starter-pack-delete"]')
+ .click();
+ await expect(page.locator('[data-testid="confirm-modal"]')).toBeVisible({
+ timeout: 10000,
+ });
+ await page.locator('[data-testid="modal-confirm-button"]').click();
+
+ await expect(page).toHaveURL(`/profile/${userProfile.handle}`, {
+ timeout: 10000,
+ });
+ await expect(
+ profileView.locator(
+ '.feed-container:not([hidden]) [data-testid="starter-pack-list-item"]',
+ ),
+ ).toHaveCount(0, { timeout: 10000 });
+ await expect(
+ profileView.locator(
+ '.feed-container:not([hidden]) [data-testid="empty-state"]',
+ ),
+ ).toBeVisible();
+
+ const writes = mockServer.applyWritesCalls.flat();
+ expect(writes.map((write) => [write.collection, write.rkey])).toEqual([
+ ["app.bsky.graph.listitem", "item1"],
+ ["app.bsky.graph.list", "doomed"],
+ ["app.bsky.graph.starterpack", "doomed"],
+ ]);
+ });
+});
diff --git a/tests/e2e/specs/flows/editStarterPack.test.js b/tests/e2e/specs/flows/editStarterPack.test.js
new file mode 100644
index 00000000..731e5c3d
--- /dev/null
+++ b/tests/e2e/specs/flows/editStarterPack.test.js
@@ -0,0 +1,169 @@
+import { test, expect } from "../../base.js";
+import { login } from "../../helpers.js";
+import { MockServer } from "../../mockServer.js";
+import { userProfile } from "../../testData.js";
+import { createProfile, createStarterPack } from "../../../shared/factories.js";
+
+const PACK_URI = `at://${userProfile.did}/app.bsky.graph.starterpack/mine`;
+const LIST_URI = `at://${userProfile.did}/app.bsky.graph.list/mine`;
+const PACK_PATH = `/profile/${userProfile.handle}/starter-pack/mine`;
+
+function makeMembers(count) {
+ return Array.from({ length: count }, (_, i) =>
+ createProfile({
+ did: `did:plc:member${i}`,
+ handle: `member${i}.bsky.social`,
+ displayName: `Member ${i}`,
+ }),
+ );
+}
+
+test.describe("Starter pack edit flow", () => {
+ test("renames the pack, removes a member and rewrites both records", async ({
+ page,
+ }) => {
+ const members = makeMembers(9);
+ const starterPack = createStarterPack({
+ uri: PACK_URI,
+ name: "My Pack",
+ creatorHandle: userProfile.handle,
+ description: "Before",
+ list: true,
+ });
+ const mockServer = new MockServer();
+ mockServer.addProfile(userProfile);
+ for (const profile of members) mockServer.addProfile(profile);
+ mockServer.addStarterPacks([starterPack]);
+ mockServer.addLists([starterPack.list]);
+ mockServer.addListMembers(LIST_URI, [userProfile, ...members]);
+ mockServer.addCurrentUserListItem({
+ uri: `at://${userProfile.did}/app.bsky.graph.listitem/self`,
+ listUri: LIST_URI,
+ subjectDid: userProfile.did,
+ });
+ for (const member of members) {
+ mockServer.addCurrentUserListItem({
+ uri: `at://${userProfile.did}/app.bsky.graph.listitem/${member.did.split(":").pop()}`,
+ listUri: LIST_URI,
+ subjectDid: member.did,
+ });
+ }
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto(PACK_PATH);
+ const detail = page.locator("#starter-pack-detail-view");
+ await expect(
+ detail.locator('[data-testid="starter-pack-name"]'),
+ ).toHaveText("My Pack", { timeout: 10000 });
+
+ await detail.locator(".context-menu-button").click();
+ await detail
+ .locator('[data-testid="menu-action-starter-pack-edit"]')
+ .click();
+
+ const wizard = page.locator('[data-testid="starter-pack-wizard"]');
+ await expect(wizard).toHaveAttribute("data-teststate", "details", {
+ timeout: 10000,
+ });
+ const nameInput = wizard.locator('[data-testid="starter-pack-name-input"]');
+ await expect(nameInput).toHaveValue("My Pack");
+ await nameInput.fill("Renamed Pack");
+ await wizard.locator('[data-testid="starter-pack-wizard-next"]').click();
+ await expect(wizard).toHaveAttribute("data-teststate", "profiles");
+ await expect(
+ wizard.locator('[data-testid="starter-pack-wizard-count"]'),
+ ).toHaveText("10/150");
+
+ await wizard
+ .locator('[data-testid="starter-pack-wizard-edit-selection"]')
+ .click();
+ await expect(wizard).toHaveAttribute("data-teststate", "review");
+ const removeButtons = wizard.locator(
+ '[data-testid="starter-pack-review-remove"]',
+ );
+ await expect(removeButtons).toHaveCount(9);
+ await removeButtons.first().click();
+ await expect(removeButtons).toHaveCount(8);
+ await wizard
+ .locator('[data-testid="starter-pack-wizard-review-close"]')
+ .click();
+
+ const nextButton = wizard.locator(
+ '[data-testid="starter-pack-wizard-next"]',
+ );
+ await nextButton.click();
+ await expect(wizard).toHaveAttribute("data-teststate", "feeds");
+ await expect(nextButton).toHaveText("Skip");
+ await nextButton.click();
+
+ await expect(wizard).toHaveCount(0, { timeout: 10000 });
+ await expect(
+ detail.locator('[data-testid="starter-pack-name"]'),
+ ).toHaveText("Renamed Pack");
+
+ const deletes = mockServer.applyWritesCalls
+ .flat()
+ .filter((write) => write.$type === "com.atproto.repo.applyWrites#delete");
+ expect(deletes).toHaveLength(1);
+ expect(deletes[0].collection).toBe("app.bsky.graph.listitem");
+ expect(
+ mockServer.currentUserListItems.some((item) =>
+ item.uri.endsWith(`/${deletes[0].rkey}`),
+ ),
+ ).toBe(false);
+
+ const listPut = mockServer.putRecordCalls.find(
+ (call) => call.collection === "app.bsky.graph.list",
+ );
+ expect(listPut.rkey).toBe("mine");
+ expect(listPut.record.name).toBe("Renamed Pack");
+ expect(listPut.swapRecord).toBeTruthy();
+ const packPut = mockServer.putRecordCalls.find(
+ (call) => call.collection === "app.bsky.graph.starterpack",
+ );
+ expect(packPut.rkey).toBe("mine");
+ expect(packPut.record.name).toBe("Renamed Pack");
+ expect(packPut.record.description).toBe("Before");
+ expect(packPut.record.list).toBe(LIST_URI);
+ expect(packPut.swapRecord).toBeTruthy();
+ });
+
+ test("does not open the editor when the members can't be loaded", async ({
+ page,
+ }) => {
+ const starterPack = createStarterPack({
+ uri: PACK_URI,
+ name: "My Pack",
+ creatorHandle: userProfile.handle,
+ list: true,
+ });
+ const mockServer = new MockServer();
+ mockServer.addProfile(userProfile);
+ mockServer.addStarterPacks([starterPack]);
+ mockServer.addLists([starterPack.list]);
+ mockServer.addListMembers(LIST_URI, [userProfile, ...makeMembers(9)]);
+ mockServer.failListMembers({ status: 500 });
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto(PACK_PATH);
+ const detail = page.locator("#starter-pack-detail-view");
+ await expect(
+ detail.locator('[data-testid="starter-pack-name"]'),
+ ).toHaveText("My Pack", { timeout: 10000 });
+
+ await detail.locator(".context-menu-button").click();
+ await detail
+ .locator('[data-testid="menu-action-starter-pack-edit"]')
+ .click();
+
+ await expect(page.locator('[data-testid="toast"]')).toBeVisible({
+ timeout: 10000,
+ });
+ await expect(
+ page.locator('[data-testid="starter-pack-wizard"]'),
+ ).toHaveCount(0);
+ expect(mockServer.applyWritesCalls).toHaveLength(0);
+ });
+});
diff --git a/tests/e2e/specs/flows/followUser.test.js b/tests/e2e/specs/flows/followUser.test.js
index 5578de9d..f92648cf 100644
--- a/tests/e2e/specs/flows/followUser.test.js
+++ b/tests/e2e/specs/flows/followUser.test.js
@@ -134,7 +134,7 @@ test.describe("Follow/Unfollow flow", () => {
await page.goto(`/profile/${userProfile.did}/following`);
await expect(
- followingView.locator('[data-testid="feed-end-message"]'),
+ followingView.locator('[data-testid="empty-state"]'),
).toBeVisible({ timeout: 10000 });
});
});
diff --git a/tests/e2e/specs/flows/likePost.test.js b/tests/e2e/specs/flows/likePost.test.js
index a616be2c..81191f5a 100644
--- a/tests/e2e/specs/flows/likePost.test.js
+++ b/tests/e2e/specs/flows/likePost.test.js
@@ -113,7 +113,7 @@ test.describe("Like post flow", () => {
await expect(
profileView.locator(
- '.feed-container:not([hidden]) [data-testid="feed-end-message"]',
+ '.feed-container:not([hidden]) [data-testid="empty-state"]',
),
).toBeVisible({ timeout: 10000 });
});
diff --git a/tests/e2e/specs/flows/muteUser.test.js b/tests/e2e/specs/flows/muteUser.test.js
index 5e4d2063..35ea42cd 100644
--- a/tests/e2e/specs/flows/muteUser.test.js
+++ b/tests/e2e/specs/flows/muteUser.test.js
@@ -62,9 +62,9 @@ test.describe("Mute user flow", () => {
// Navigate back to home and verify posts are filtered
await page.goto("/");
- await expect(
- homeView.locator('[data-testid="feed-end-message"]'),
- ).toBeVisible({ timeout: 10000 });
+ await expect(homeView.locator('[data-testid="empty-state"]')).toBeVisible({
+ timeout: 10000,
+ });
});
test("should mute a user from a post context menu and filter their posts", async ({
@@ -108,9 +108,9 @@ test.describe("Mute user flow", () => {
});
// Verify the post is filtered out of the feed
- await expect(
- homeView.locator('[data-testid="feed-end-message"]'),
- ).toBeVisible({ timeout: 10000 });
+ await expect(homeView.locator('[data-testid="empty-state"]')).toBeVisible({
+ timeout: 10000,
+ });
// Navigate to the muted user's profile and verify muted state
await page.goto(`/profile/${otherUser.did}`);
@@ -154,9 +154,9 @@ test.describe("Mute user flow", () => {
// Verify posts are filtered on home initially (user is muted)
await page.goto("/");
const homeView = page.locator("#home-view");
- await expect(
- homeView.locator('[data-testid="feed-end-message"]'),
- ).toBeVisible({ timeout: 10000 });
+ await expect(homeView.locator('[data-testid="empty-state"]')).toBeVisible({
+ timeout: 10000,
+ });
// Navigate to muted user's profile and unmute
await page.goto(`/profile/${mutedUser.did}`);
diff --git a/tests/e2e/specs/flows/openStarterPackFromProfile.test.js b/tests/e2e/specs/flows/openStarterPackFromProfile.test.js
new file mode 100644
index 00000000..a31df239
--- /dev/null
+++ b/tests/e2e/specs/flows/openStarterPackFromProfile.test.js
@@ -0,0 +1,58 @@
+import { test, expect } from "../../base.js";
+import { login } from "../../helpers.js";
+import { MockServer } from "../../mockServer.js";
+import { userProfile } from "../../testData.js";
+import { createStarterPack } from "../../../shared/factories.js";
+
+const PACK_URI = `at://${userProfile.did}/app.bsky.graph.starterpack/mine`;
+
+test.describe("Open starter pack from profile flow", () => {
+ test("shows the loading state, not the not-found state, while the full pack loads", async ({
+ page,
+ }) => {
+ const basicPack = createStarterPack({
+ uri: PACK_URI,
+ name: "My Pack",
+ creatorHandle: userProfile.handle,
+ });
+ const fullPack = createStarterPack({
+ uri: PACK_URI,
+ name: "My Pack",
+ creatorHandle: userProfile.handle,
+ list: true,
+ });
+ const mockServer = new MockServer();
+ mockServer.addProfile({ ...userProfile, associated: { starterPacks: 1 } });
+ mockServer.addActorStarterPacks(userProfile.did, [basicPack]);
+ mockServer.addStarterPacks([fullPack]);
+ mockServer.addLists([fullPack.list]);
+ mockServer.setGetStarterPackDelay(1500);
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto(`/profile/${userProfile.handle}`);
+ const profileView = page.locator("#profile-view");
+ const tab = profileView.locator('[data-testid="tab-starter-packs"]');
+ await expect(tab).toBeVisible({ timeout: 10000 });
+ await tab.click();
+ const packItem = profileView.locator(
+ '.feed-container:not([hidden]) [data-testid="starter-pack-list-item"]',
+ );
+ await expect(packItem).toHaveCount(1, { timeout: 10000 });
+ await packItem.click();
+
+ const detail = page.locator("#starter-pack-detail-view");
+ await expect(
+ detail.locator('[data-testid="starter-pack-detail-loading"]'),
+ ).toBeVisible({ timeout: 10000 });
+ await expect(
+ detail.locator('[data-testid="starter-pack-not-found"]'),
+ ).toHaveCount(0);
+ await expect(
+ detail.locator('[data-testid="starter-pack-name"]'),
+ ).toHaveText("My Pack", { timeout: 10000 });
+ await expect(
+ detail.locator('[data-testid="starter-pack-not-found"]'),
+ ).toHaveCount(0);
+ });
+});
diff --git a/tests/e2e/specs/flows/repostPost.test.js b/tests/e2e/specs/flows/repostPost.test.js
index 445db406..3c0c6145 100644
--- a/tests/e2e/specs/flows/repostPost.test.js
+++ b/tests/e2e/specs/flows/repostPost.test.js
@@ -94,7 +94,7 @@ test.describe("Repost flow", () => {
await expect(
profileView.locator(
- '.feed-container:not([hidden]) [data-testid="feed-end-message"]',
+ '.feed-container:not([hidden]) [data-testid="empty-state"]',
),
).toBeVisible({ timeout: 10000 });
});
diff --git a/tests/e2e/specs/views/bookmarks.view.test.js b/tests/e2e/specs/views/bookmarks.view.test.js
index 4640f5d8..75679b0c 100644
--- a/tests/e2e/specs/views/bookmarks.view.test.js
+++ b/tests/e2e/specs/views/bookmarks.view.test.js
@@ -64,7 +64,7 @@ test.describe("Bookmarks view", () => {
await bookmarksView.locator('[data-testid="bookmark-button"]').click();
await expect(
- bookmarksView.locator('[data-testid="feed-end-message"]'),
+ bookmarksView.locator('[data-testid="empty-state"]'),
).toContainText("No saved posts yet!", { timeout: 10000 });
});
@@ -83,7 +83,7 @@ test.describe("Bookmarks view", () => {
).toContainText("Saved Posts", { timeout: 10000 });
await expect(
- bookmarksView.locator('[data-testid="feed-end-message"]'),
+ bookmarksView.locator('[data-testid="empty-state"]'),
).toContainText("No saved posts yet!", { timeout: 10000 });
});
diff --git a/tests/e2e/specs/views/chat.view.test.js b/tests/e2e/specs/views/chat.view.test.js
index d5ab8927..9656a4d6 100644
--- a/tests/e2e/specs/views/chat.view.test.js
+++ b/tests/e2e/specs/views/chat.view.test.js
@@ -474,9 +474,9 @@ test.describe("Chat view", () => {
await expect(
dialog.locator('[data-testid="new-chat-search-input"]'),
).toBeVisible();
- await expect(
- dialog.locator('[data-testid="feed-end-message"]'),
- ).toContainText("Search for someone to message");
+ await expect(dialog.locator('[data-testid="empty-state"]')).toContainText(
+ "Search for someone to message",
+ );
await dialog.locator('[data-testid="new-chat-dialog-close"]').click();
await expect(dialog).not.toBeVisible();
diff --git a/tests/e2e/specs/views/chatRequests.view.test.js b/tests/e2e/specs/views/chatRequests.view.test.js
index e78dcfff..ad1aebaa 100644
--- a/tests/e2e/specs/views/chatRequests.view.test.js
+++ b/tests/e2e/specs/views/chatRequests.view.test.js
@@ -196,9 +196,9 @@ test.describe("Chat requests view", () => {
await expect(requestsView.locator(".chat-request-item")).toHaveCount(0, {
timeout: 10000,
});
- await expect(requestsView.locator(".feed-end-message")).toContainText(
- "No chat requests",
- );
+ await expect(
+ requestsView.locator('[data-testid="empty-state"]'),
+ ).toBeVisible();
});
test("should not offer accept for a locked group invite", async ({
@@ -367,9 +367,9 @@ test.describe("Chat requests view", () => {
await expect(requestsView.locator(".chat-request-item")).toHaveCount(0, {
timeout: 10000,
});
- await expect(requestsView.locator(".feed-end-message")).toContainText(
- "No chat requests",
- );
+ await expect(
+ requestsView.locator('[data-testid="empty-state"]'),
+ ).toBeVisible();
});
test("should show an old request buried past the first page of convos", async ({
@@ -514,10 +514,9 @@ test.describe("Chat requests view", () => {
await expect(
requestsView.locator('[data-testid="header-title"]'),
).toContainText("Chat requests", { timeout: 10000 });
- await expect(requestsView.locator(".feed-end-message")).toContainText(
- "No chat requests",
- { timeout: 10000 },
- );
+ await expect(
+ requestsView.locator('[data-testid="empty-state"]'),
+ ).toBeVisible({ timeout: 10000 });
});
test("should display error state when chat requests fail to load", async ({
diff --git a/tests/e2e/specs/views/feedDetail.view.test.js b/tests/e2e/specs/views/feedDetail.view.test.js
index 0acad476..3b933953 100644
--- a/tests/e2e/specs/views/feedDetail.view.test.js
+++ b/tests/e2e/specs/views/feedDetail.view.test.js
@@ -194,7 +194,7 @@ test.describe("Feed Detail view", () => {
{ timeout: 10000 },
);
- await expect(view.locator('[data-testid="feed-end-message"]')).toBeVisible({
+ await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
timeout: 10000,
});
});
diff --git a/tests/e2e/specs/views/hashtag.view.test.js b/tests/e2e/specs/views/hashtag.view.test.js
index 5bbef58c..be87562a 100644
--- a/tests/e2e/specs/views/hashtag.view.test.js
+++ b/tests/e2e/specs/views/hashtag.view.test.js
@@ -116,7 +116,7 @@ test.describe("Hashtag view", () => {
);
await expect(
- hashtagView.locator('[data-testid="feed-end-message"]'),
+ hashtagView.locator('[data-testid="empty-state"]'),
).toBeVisible({ timeout: 10000 });
});
diff --git a/tests/e2e/specs/views/home.view.test.js b/tests/e2e/specs/views/home.view.test.js
index 0b86eadb..8a9983c7 100644
--- a/tests/e2e/specs/views/home.view.test.js
+++ b/tests/e2e/specs/views/home.view.test.js
@@ -551,7 +551,9 @@ test.describe("Home view", () => {
const loadMoreError = view.locator('[data-testid="feed-load-more-error"]');
await expect(loadMoreError).toBeVisible({ timeout: 10000 });
await expect(view.locator('[data-testid="feed-item"]')).toHaveCount(41);
- await expect(view.locator('[data-testid="feed-error-view-profile"]')).toHaveCount(0);
+ await expect(
+ view.locator('[data-testid="feed-error-view-profile"]'),
+ ).toHaveCount(0);
failPagedRequests = false;
await loadMoreError.locator("button").click();
@@ -577,7 +579,7 @@ test.describe("Home view", () => {
{ timeout: 10000 },
);
- await expect(view.locator('[data-testid="feed-end-message"]')).toBeVisible({
+ await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
timeout: 10000,
});
});
diff --git a/tests/e2e/specs/views/listDetail.view.test.js b/tests/e2e/specs/views/listDetail.view.test.js
index 41d2a967..e78886b1 100644
--- a/tests/e2e/specs/views/listDetail.view.test.js
+++ b/tests/e2e/specs/views/listDetail.view.test.js
@@ -125,7 +125,7 @@ test.describe("List Detail view", () => {
await page.goto("/profile/creator1.bsky.social/lists/mylist");
const view = page.locator("#list-detail-view");
- await expect(view.locator('[data-testid="feed-end-message"]')).toBeVisible({
+ await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
timeout: 10000,
});
});
@@ -197,7 +197,7 @@ test.describe("List Detail view", () => {
await page.goto("/profile/creator1.bsky.social/lists/mylist");
const view = page.locator("#list-detail-view");
- await expect(view.locator('[data-testid="feed-end-message"]')).toBeVisible({
+ await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
timeout: 10000,
});
await expect(
@@ -221,7 +221,7 @@ test.describe("List Detail view", () => {
});
await view.locator('[data-testid="tab-people"]').click();
- await expect(view.locator('[data-testid="feed-end-message"]')).toBeVisible({
+ await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
timeout: 10000,
});
await expect(
diff --git a/tests/e2e/specs/views/notifications.view.test.js b/tests/e2e/specs/views/notifications.view.test.js
index 68a48d6d..203cf86e 100644
--- a/tests/e2e/specs/views/notifications.view.test.js
+++ b/tests/e2e/specs/views/notifications.view.test.js
@@ -39,10 +39,9 @@ test.describe("Notifications view", () => {
await page.goto("/notifications");
const view = page.locator("#notifications-view");
- await expect(view.locator(".feed-end-message")).toContainText(
- "No notifications yet!",
- { timeout: 10000 },
- );
+ await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
+ timeout: 10000,
+ });
});
test("should display a follow notification", async ({ page }) => {
@@ -646,10 +645,9 @@ test.describe("Notifications view", () => {
await page.goto("/notifications");
const view = page.locator("#notifications-view");
- await expect(view.locator(".feed-end-message")).toContainText(
- "No notifications yet!",
- { timeout: 10000 },
- );
+ await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
+ timeout: 10000,
+ });
await expect(view.locator(".notification-item")).toHaveCount(0);
});
@@ -774,9 +772,9 @@ test.describe("Notifications view", () => {
await expect(view.locator(".notification-item")).toHaveCount(1, {
timeout: 10000,
});
- await expect(view.locator(".feed-end-message")).toContainText(
- "No more notifications",
- );
+ await expect(
+ view.locator('[data-testid="feed-end-message"]'),
+ ).toBeVisible();
});
test("should display header with 'Notifications' title", async ({ page }) => {
@@ -1528,10 +1526,9 @@ test.describe("Notifications view", () => {
await view.locator(".tab-bar-button").nth(1).click();
const activePanel = view.locator(".notifications-feed:not([hidden])");
- await expect(activePanel.locator(".feed-end-message")).toContainText(
- "No notifications yet!",
- { timeout: 10000 },
- );
+ await expect(
+ activePanel.locator('[data-testid="empty-state"]'),
+ ).toBeVisible({ timeout: 10000 });
});
test("should show all notification types when switching back to All tab", async ({
diff --git a/tests/e2e/specs/views/postLikes.view.test.js b/tests/e2e/specs/views/postLikes.view.test.js
index a87a88de..42c8b571 100644
--- a/tests/e2e/specs/views/postLikes.view.test.js
+++ b/tests/e2e/specs/views/postLikes.view.test.js
@@ -151,7 +151,7 @@ test.describe("Post likes view", () => {
{ timeout: 10000 },
);
- await expect(view.locator('[data-testid="feed-end-message"]')).toBeVisible({
+ await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
timeout: 10000,
});
});
diff --git a/tests/e2e/specs/views/postReposts.view.test.js b/tests/e2e/specs/views/postReposts.view.test.js
index 89d5f00c..c219069a 100644
--- a/tests/e2e/specs/views/postReposts.view.test.js
+++ b/tests/e2e/specs/views/postReposts.view.test.js
@@ -107,7 +107,7 @@ test.describe("Post reposts view", () => {
{ timeout: 10000 },
);
- await expect(view.locator('[data-testid="feed-end-message"]')).toBeVisible({
+ await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
timeout: 10000,
});
});
diff --git a/tests/e2e/specs/views/profile.view.test.js b/tests/e2e/specs/views/profile.view.test.js
index 50abf618..a82ca5c9 100644
--- a/tests/e2e/specs/views/profile.view.test.js
+++ b/tests/e2e/specs/views/profile.view.test.js
@@ -405,7 +405,7 @@ test.describe("Profile view", () => {
const view = page.locator("#profile-view");
await expect(
- view.locator('[data-testid="feed-end-message"]').first(),
+ view.locator('[data-testid="empty-state"]').first(),
).toBeVisible({ timeout: 10000 });
});
@@ -486,12 +486,15 @@ test.describe("Profile view", () => {
const view = page.locator("#profile-view");
const tabBar = view.locator("tab-bar");
- await expect(tabBar.locator(".tab-bar-button")).toHaveCount(4, {
+ await expect(tabBar.locator(".tab-bar-button")).toHaveCount(5, {
timeout: 10000,
});
await expect(tabBar.locator(".tab-bar-button").nth(3)).toContainText(
"Likes",
);
+ await expect(
+ tabBar.locator('[data-testid="tab-starter-packs"]'),
+ ).toBeVisible();
});
test("should not show follow or chat buttons on own profile", async ({
@@ -1639,7 +1642,9 @@ test.describe("Profile view", () => {
await expect(page.locator(".post-notifications-dialog")).toBeVisible();
- await page.locator('[data-testid="post-notifications-dialog-close"]').click();
+ await page
+ .locator('[data-testid="post-notifications-dialog-close"]')
+ .click();
await expect(
page.locator(".post-notifications-dialog"),
diff --git a/tests/e2e/specs/views/profileFollowers.view.test.js b/tests/e2e/specs/views/profileFollowers.view.test.js
index 12292bb3..9d5e7caa 100644
--- a/tests/e2e/specs/views/profileFollowers.view.test.js
+++ b/tests/e2e/specs/views/profileFollowers.view.test.js
@@ -130,7 +130,7 @@ test.describe("Profile followers view", () => {
{ timeout: 10000 },
);
- await expect(view.locator('[data-testid="feed-end-message"]')).toBeVisible({
+ await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
timeout: 10000,
});
});
diff --git a/tests/e2e/specs/views/profileFollowing.view.test.js b/tests/e2e/specs/views/profileFollowing.view.test.js
index d628462e..62e2aa7e 100644
--- a/tests/e2e/specs/views/profileFollowing.view.test.js
+++ b/tests/e2e/specs/views/profileFollowing.view.test.js
@@ -101,7 +101,7 @@ test.describe("Profile following view", () => {
{ timeout: 10000 },
);
- await expect(view.locator('[data-testid="feed-end-message"]')).toBeVisible({
+ await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
timeout: 10000,
});
});
diff --git a/tests/e2e/specs/views/profileKnownFollowers.view.test.js b/tests/e2e/specs/views/profileKnownFollowers.view.test.js
index 26549e43..423fbf9d 100644
--- a/tests/e2e/specs/views/profileKnownFollowers.view.test.js
+++ b/tests/e2e/specs/views/profileKnownFollowers.view.test.js
@@ -100,7 +100,7 @@ test.describe("Profile known followers view", () => {
{ timeout: 10000 },
);
- await expect(view.locator('[data-testid="feed-end-message"]')).toBeVisible({
+ await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
timeout: 10000,
});
});
diff --git a/tests/e2e/specs/views/settings/blockedAccounts.view.test.js b/tests/e2e/specs/views/settings/blockedAccounts.view.test.js
index 11e94c6e..3a171a3a 100644
--- a/tests/e2e/specs/views/settings/blockedAccounts.view.test.js
+++ b/tests/e2e/specs/views/settings/blockedAccounts.view.test.js
@@ -44,7 +44,7 @@ test.describe("Settings Blocked Accounts view", () => {
await page.goto("/settings/blocked-accounts");
const view = page.locator("#settings-blocked-accounts-view");
- await expect(view.locator('[data-testid="feed-end-message"]')).toBeVisible({
+ await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
timeout: 10000,
});
});
diff --git a/tests/e2e/specs/views/settings/mutedAccounts.view.test.js b/tests/e2e/specs/views/settings/mutedAccounts.view.test.js
index 5574f13a..244d5aab 100644
--- a/tests/e2e/specs/views/settings/mutedAccounts.view.test.js
+++ b/tests/e2e/specs/views/settings/mutedAccounts.view.test.js
@@ -44,7 +44,7 @@ test.describe("Settings Muted Accounts view", () => {
await page.goto("/settings/muted-accounts");
const view = page.locator("#settings-muted-accounts-view");
- await expect(view.locator('[data-testid="feed-end-message"]')).toBeVisible({
+ await expect(view.locator('[data-testid="empty-state"]')).toBeVisible({
timeout: 10000,
});
});
diff --git a/tests/e2e/specs/views/starterPackDetail.view.test.js b/tests/e2e/specs/views/starterPackDetail.view.test.js
index 21ca71bf..4dd18f3b 100644
--- a/tests/e2e/specs/views/starterPackDetail.view.test.js
+++ b/tests/e2e/specs/views/starterPackDetail.view.test.js
@@ -281,9 +281,6 @@ test.describe("Starter Pack Detail view", () => {
const button = view.locator('[data-testid="starter-pack-follow-all"]');
await expect(button).toHaveAttribute("data-teststate", "idle");
- await expect(
- view.locator('[data-testid="starter-pack-share"]'),
- ).toHaveCount(0);
await button.click();
await expect(page.locator('[data-testid="toast"]')).toBeVisible({
@@ -337,11 +334,8 @@ test.describe("Starter Pack Detail view", () => {
});
});
- test.describe("Share", () => {
- test("should show Share instead of Follow all on the owner's pack and copy the link", async ({
- page,
- browserName,
- }) => {
+ test.describe("Links", () => {
+ test("should not show Follow all on the owner's pack", async ({ page }) => {
const mockServer = new MockServer();
mockServer.addStarterPacks([
createStarterPack({
@@ -353,11 +347,6 @@ test.describe("Starter Pack Detail view", () => {
]);
setupMembers(mockServer, OWN_LIST_URI);
await mockServer.setup(page);
- if (browserName === "chromium") {
- await page
- .context()
- .grantPermissions(["clipboard-read", "clipboard-write"]);
- }
const view = await openPack(
page,
@@ -370,17 +359,6 @@ test.describe("Starter Pack Detail view", () => {
await expect(
view.locator('[data-testid="starter-pack-follow-all"]'),
).toHaveCount(0);
- await view.locator('[data-testid="starter-pack-share"]').click();
-
- await expect(page.locator('[data-testid="toast"]')).toBeVisible();
- if (browserName === "chromium") {
- const clipboardText = await page.evaluate(() =>
- navigator.clipboard.readText(),
- );
- expect(clipboardText).toBe(
- `https://bsky.app/starter-pack/${userProfile.handle}/mine`,
- );
- }
});
test("should copy the link from the header menu", async ({
@@ -448,9 +426,6 @@ test.describe("Starter Pack Detail view", () => {
await expect(
view.locator('[data-testid="starter-pack-follow-all"]'),
).toHaveCount(0);
- await expect(
- view.locator('[data-testid="starter-pack-share"]'),
- ).toHaveCount(0);
await expect(
view.locator('[data-testid="starter-pack-sign-in"]'),
).toHaveAttribute("href", /^\/login/);
@@ -616,4 +591,51 @@ test.describe("Starter Pack Detail view", () => {
).toHaveCount(0);
});
});
+
+ test("should show edit and delete actions only to the owner", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ mockServer.addStarterPacks([
+ createStarterPack({
+ uri: OWN_PACK_URI,
+ name: "My Pack",
+ creatorHandle: userProfile.handle,
+ list: true,
+ }),
+ ]);
+ setupPack(mockServer);
+ setupMembers(mockServer, OWN_LIST_URI);
+ setupMembers(mockServer);
+ await mockServer.setup(page);
+
+ const ownView = await openPack(
+ page,
+ `/profile/${userProfile.handle}/starter-pack/mine`,
+ );
+ await ownView.locator(".context-menu-button").click();
+ await expect(
+ ownView.locator('[data-testid="menu-action-starter-pack-edit"]'),
+ ).toBeVisible();
+ await expect(
+ ownView.locator('[data-testid="menu-action-starter-pack-delete"]'),
+ ).toBeVisible();
+ await page.keyboard.press("Escape");
+
+ await page.goto(PACK_PATH);
+ const otherView = page.locator("#starter-pack-detail-view");
+ await expect(
+ otherView.locator('[data-testid="starter-pack-name"]'),
+ ).toHaveText("Cool Pack", { timeout: 10000 });
+ await otherView.locator(".context-menu-button").click();
+ await expect(
+ otherView.locator('[data-testid="menu-action-starter-pack-copy-link"]'),
+ ).toBeVisible();
+ await expect(
+ otherView.locator('[data-testid="menu-action-starter-pack-edit"]'),
+ ).toHaveCount(0);
+ await expect(
+ otherView.locator('[data-testid="menu-action-starter-pack-delete"]'),
+ ).toHaveCount(0);
+ });
});
diff --git a/tests/unit/specs/components/drafts-dialog.test.js b/tests/unit/specs/components/drafts-dialog.test.js
index c952b03c..9ef00ed4 100644
--- a/tests/unit/specs/components/drafts-dialog.test.js
+++ b/tests/unit/specs/components/drafts-dialog.test.js
@@ -467,10 +467,11 @@ describe("drafts-dialog", () => {
);
assert.deepEqual(resumed, 0);
resolveLoad();
+ seedDrafts([createDraftView()]);
await flushMicrotasks();
await nextFrame();
assert.deepEqual(resumed, 1);
- assert.deepEqual(container.querySelector(".loading-spinner"), null);
+ assert.deepEqual(element.querySelector(".loading-spinner"), null);
});
it("should resume immediately without a second request while a page load is pending", async () => {
@@ -495,6 +496,7 @@ describe("drafts-dialog", () => {
detail: { resume: () => resumes.push("second") },
}),
);
+ await flushMicrotasks();
assert.deepEqual(loadSpy.mock.callCount(), 1);
assert.deepEqual(resumes, ["second"]);
resolveLoad();
diff --git a/tests/unit/specs/components/manage-list-members-dialog.test.js b/tests/unit/specs/components/manage-list-members-dialog.test.js
index 2fff8f74..2d906f0a 100644
--- a/tests/unit/specs/components/manage-list-members-dialog.test.js
+++ b/tests/unit/specs/components/manage-list-members-dialog.test.js
@@ -362,9 +362,7 @@ describe("manage-list-members-dialog", () => {
cursor: null,
});
await typeQuery(element, "zzz");
- const emptyMessage = element.querySelector(
- '[data-testid="feed-end-message"]',
- );
+ const emptyMessage = element.querySelector('[data-testid="empty-state"]');
assert(emptyMessage !== null);
assert.equal(emptyMessage.textContent.trim(), "No results");
});
diff --git a/tests/unit/specs/components/new-chat-dialog.test.js b/tests/unit/specs/components/new-chat-dialog.test.js
index 2d2d9448..3bae8917 100644
--- a/tests/unit/specs/components/new-chat-dialog.test.js
+++ b/tests/unit/specs/components/new-chat-dialog.test.js
@@ -199,7 +199,7 @@ describe("new-chat-dialog", () => {
seedFollows(dataLayer, []);
const element = createDialog(dataLayer);
assert(
- [...element.querySelectorAll('[data-testid="feed-end-message"]')].some(
+ [...element.querySelectorAll('[data-testid="empty-state"]')].some(
(el) => el.textContent.includes("Search for someone to message"),
),
);
@@ -271,7 +271,7 @@ describe("new-chat-dialog", () => {
]);
const element = createDialog(dataLayer);
assert(
- [...element.querySelectorAll('[data-testid="feed-end-message"]')].some(
+ [...element.querySelectorAll('[data-testid="empty-state"]')].some(
(el) => el.textContent.includes("Search for someone to message"),
),
);
@@ -289,7 +289,7 @@ describe("new-chat-dialog", () => {
await flushMicrotasks();
await nextFrame();
assert(
- [...element.querySelectorAll('[data-testid="feed-end-message"]')].some(
+ [...element.querySelectorAll('[data-testid="empty-state"]')].some(
(el) => el.textContent.includes("Search for someone to message"),
),
);
@@ -303,7 +303,7 @@ describe("new-chat-dialog", () => {
await flushMicrotasks();
await nextFrame();
assert(
- [...element.querySelectorAll('[data-testid="feed-end-message"]')].some(
+ [...element.querySelectorAll('[data-testid="empty-state"]')].some(
(el) => el.textContent.includes("Search for someone to message"),
),
);
@@ -380,7 +380,7 @@ describe("new-chat-dialog", () => {
"",
);
assert(
- [...element.querySelectorAll('[data-testid="feed-end-message"]')].some(
+ [...element.querySelectorAll('[data-testid="empty-state"]')].some(
(el) => el.textContent.includes("Search for someone to message"),
),
);
@@ -426,7 +426,7 @@ describe("new-chat-dialog", () => {
seedSearchResults(dataLayer, []);
await typeQuery(element, "alice");
assert(
- [...element.querySelectorAll('[data-testid="feed-end-message"]')].some(
+ [...element.querySelectorAll('[data-testid="empty-state"]')].some(
(el) => el.textContent.includes("No results"),
),
);
diff --git a/tests/unit/specs/components/plugin-posts-feed.test.js b/tests/unit/specs/components/plugin-posts-feed.test.js
index 027c1bf7..d19bca1d 100644
--- a/tests/unit/specs/components/plugin-posts-feed.test.js
+++ b/tests/unit/specs/components/plugin-posts-feed.test.js
@@ -72,9 +72,7 @@ describe("plugin-posts-feed", () => {
element.setAttribute("empty-message", "Nothing here.");
mount(element, makeContext(dataLayer));
await flushMicrotasks();
- const endMessage = element.querySelector(
- "[data-testid='feed-end-message']",
- );
+ const endMessage = element.querySelector("[data-testid='empty-state']");
assert(endMessage !== null);
assert(endMessage.textContent.includes("Nothing here."));
// ensurePosts is still invoked with an empty uri list β the empty render
@@ -152,9 +150,7 @@ describe("plugin-posts-feed", () => {
element.setAttribute("empty-message", "Still nothing.");
await flushMicrotasks();
assert.deepEqual(ensurePosts.mock.callCount(), 1);
- const endMessage = element.querySelector(
- "[data-testid='feed-end-message']",
- );
+ const endMessage = element.querySelector("[data-testid='empty-state']");
assert(endMessage !== null);
assert(endMessage.textContent.includes("Still nothing."));
});
@@ -196,9 +192,7 @@ describe("plugin-posts-feed", () => {
await flushMicrotasks();
// After the stale promise resolves, we should still be showing the fresh
// state (empty list with default empty message) and not have crashed.
- const endMessage = element.querySelector(
- "[data-testid='feed-end-message']",
- );
+ const endMessage = element.querySelector("[data-testid='empty-state']");
assert(endMessage !== null);
});
});
diff --git a/tests/unit/specs/components/plugin-profiles-list.test.js b/tests/unit/specs/components/plugin-profiles-list.test.js
index 8c3aed68..7b37f162 100644
--- a/tests/unit/specs/components/plugin-profiles-list.test.js
+++ b/tests/unit/specs/components/plugin-profiles-list.test.js
@@ -105,7 +105,7 @@ describe("plugin-profiles-list", () => {
mount(element, dataLayer);
await flushMicrotasks();
assert.deepEqual(
- element.querySelector("[data-testid='feed-end-message']"),
+ element.querySelector("[data-testid='empty-state']"),
null,
);
});
@@ -229,9 +229,7 @@ describe("plugin-profiles-list", () => {
element.setAttribute("empty-message", "Still nothing.");
await flushMicrotasks();
assert.deepEqual(ensureProfiles.mock.callCount(), 1);
- const endMessage = element.querySelector(
- "[data-testid='feed-end-message']",
- );
+ const endMessage = element.querySelector("[data-testid='empty-state']");
assert(endMessage !== null);
assert(endMessage.textContent.includes("Still nothing."));
});
diff --git a/tests/unit/specs/components/starter-pack-wizard-dialog.test.js b/tests/unit/specs/components/starter-pack-wizard-dialog.test.js
new file mode 100644
index 00000000..4ed67523
--- /dev/null
+++ b/tests/unit/specs/components/starter-pack-wizard-dialog.test.js
@@ -0,0 +1,385 @@
+import { describe, it, beforeEach, afterEach, mock } from "node:test";
+import assert from "node:assert/strict";
+import { makeTestDataLayer, respondToConfirm } from "../../testHelpers.js";
+import "/js/components/starter-pack-wizard-dialog.js";
+
+describe("starter-pack-wizard-dialog", () => {
+ const originalSetTimeout = globalThis.setTimeout;
+
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ globalThis.setTimeout = (fn) => originalSetTimeout(fn, 0);
+ });
+
+ afterEach(() => {
+ globalThis.setTimeout = originalSetTimeout;
+ document.body.innerHTML = "";
+ });
+
+ async function nextFrame() {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+
+ const ME = { did: "did:plc:me", handle: "me.test", displayName: "Me" };
+ const DISCOVER_FEED_URI =
+ "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot";
+
+ function createProfile(did, extra = {}) {
+ return {
+ did,
+ handle: did.replace("did:plc:", "") + ".test",
+ displayName: did.replace("did:plc:", ""),
+ avatar: "",
+ labels: [],
+ viewer: {},
+ ...extra,
+ };
+ }
+
+ function createFeed(uri, displayName) {
+ return {
+ uri,
+ displayName,
+ avatar: "",
+ creator: { did: "did:plc:feedowner", handle: "feeds.test" },
+ };
+ }
+
+ function makeFollows(count) {
+ return Array.from({ length: count }, (_, i) =>
+ createProfile(`did:plc:follow${i}`),
+ );
+ }
+
+ function makeDataLayer({ follows = [], popularFeeds = [] } = {}) {
+ const dataLayer = makeTestDataLayer();
+ dataLayer.dataStore.$currentUser.set(ME);
+ dataLayer.dataStore.setProfiles(follows);
+ dataLayer.dataStore.$profileFollows.set(ME.did, {
+ follows,
+ cursor: null,
+ });
+ dataLayer.dataStore.$popularFeeds.set(popularFeeds);
+ dataLayer.dataStore.$pinnedItems.set([]);
+ mock.method(dataLayer.declarative, "ensureCurrentUser", async () => ME);
+ mock.method(dataLayer.declarative, "ensureProfileFollows", async () => ({
+ follows,
+ }));
+ mock.method(dataLayer.declarative, "ensurePinnedItems", async () => []);
+ mock.method(dataLayer.requests, "loadPopularFeeds", async () => {});
+ mock.method(dataLayer.requests, "loadChatRecipientSearch", async () => {});
+ mock.method(dataLayer.requests, "loadFeedSearch", async () => {});
+ return dataLayer;
+ }
+
+ function createDialog(dataLayer, props = {}) {
+ const element = document.createElement("starter-pack-wizard-dialog");
+ element.dataLayer = dataLayer;
+ Object.assign(element, props);
+ document.body.appendChild(element);
+ return element;
+ }
+
+ function query(element, testId) {
+ return element.querySelector(`[data-testid="${testId}"]`);
+ }
+
+ function queryAll(element, testId) {
+ return [...element.querySelectorAll(`[data-testid="${testId}"]`)];
+ }
+
+ function wizardState(element) {
+ return query(element, "starter-pack-wizard").getAttribute("data-teststate");
+ }
+
+ function typeInto(element, testId, value) {
+ const input = query(element, testId);
+ input.value = value;
+ input.dispatchEvent(new window.InputEvent("input", { bubbles: true }));
+ }
+
+ async function goToProfiles(element) {
+ query(element, "starter-pack-wizard-next").click();
+ await nextFrame();
+ }
+
+ async function addProfiles(element, count) {
+ const toggles = queryAll(element, "starter-pack-profile-toggle").filter(
+ (toggle) => toggle.getAttribute("data-teststate") === "not-included",
+ );
+ for (const toggle of toggles.slice(0, count)) {
+ toggle.click();
+ }
+ await nextFrame();
+ }
+
+ it("starts on the details step with the name input focused", () => {
+ const element = createDialog(makeDataLayer());
+ element.open();
+ assert.equal(wizardState(element), "details");
+ assert.equal(
+ document.activeElement,
+ query(element, "starter-pack-name-input"),
+ );
+ });
+
+ it("disables Next while the name is over the limit", async () => {
+ const element = createDialog(makeDataLayer());
+ element.open();
+ typeInto(element, "starter-pack-name-input", "x".repeat(51));
+ await nextFrame();
+ assert.equal(query(element, "starter-pack-wizard-next").disabled, true);
+ typeInto(element, "starter-pack-name-input", "Fine");
+ await nextFrame();
+ assert.equal(query(element, "starter-pack-wizard-next").disabled, false);
+ });
+
+ it("requires eight people before moving to the feeds step", async () => {
+ const element = createDialog(makeDataLayer({ follows: makeFollows(10) }));
+ element.open();
+ await goToProfiles(element);
+ assert.equal(wizardState(element), "profiles");
+ assert.equal(
+ query(element, "starter-pack-wizard-hint").textContent.trim(),
+ "Add 7 more people to continue",
+ );
+ assert.equal(query(element, "starter-pack-wizard-next").disabled, true);
+
+ await addProfiles(element, 7);
+ assert.equal(query(element, "starter-pack-wizard-hint"), null);
+ assert.equal(query(element, "starter-pack-wizard-next").disabled, false);
+ assert.equal(query(element, "starter-pack-wizard-count"), null);
+
+ await addProfiles(element, 1);
+ assert.equal(
+ query(element, "starter-pack-wizard-count").textContent.trim(),
+ "9/150",
+ );
+
+ query(element, "starter-pack-wizard-next").click();
+ await nextFrame();
+ assert.equal(wizardState(element), "feeds");
+ });
+
+ it("keeps the current user locked in the selection", async () => {
+ const me = createProfile(ME.did, { displayName: "Me" });
+ const element = createDialog(
+ makeDataLayer({ follows: [me, ...makeFollows(2)] }),
+ );
+ element.open();
+ await goToProfiles(element);
+ const selfToggle = queryAll(element, "starter-pack-profile-toggle")[0];
+ assert.equal(selfToggle.getAttribute("data-teststate"), "locked");
+ assert.equal(selfToggle.disabled, true);
+ });
+
+ it("shows opted-out members as disabled in edit mode", async () => {
+ const optedOut = createProfile("did:plc:optedout");
+ const member = createProfile("did:plc:member");
+ const element = createDialog(
+ makeDataLayer({ follows: [optedOut, member] }),
+ {
+ starterPack: {
+ uri: "at://did:plc:me/app.bsky.graph.starterpack/p",
+ record: { name: "Pack", description: "Desc" },
+ creator: ME,
+ list: { uri: "at://did:plc:me/app.bsky.graph.list/p" },
+ feeds: [],
+ },
+ members: [optedOut, member],
+ optedOutDids: [optedOut.did],
+ },
+ );
+ element.open();
+ assert.equal(query(element, "starter-pack-name-input").value, "Pack");
+ assert.equal(
+ query(element, "starter-pack-description-input").value,
+ "Desc",
+ );
+ await goToProfiles(element);
+ const states = queryAll(element, "starter-pack-profile-toggle").map(
+ (toggle) => toggle.getAttribute("data-teststate"),
+ );
+ assert.deepEqual(states, ["opted-out", "included"]);
+ });
+
+ it("caps feeds at three and hides the Discover feed from suggestions", async () => {
+ const feeds = [
+ createFeed(DISCOVER_FEED_URI, "Discover"),
+ createFeed("at://feed/1", "One"),
+ createFeed("at://feed/2", "Two"),
+ createFeed("at://feed/3", "Three"),
+ createFeed("at://feed/4", "Four"),
+ ];
+ const element = createDialog(
+ makeDataLayer({ follows: makeFollows(8), popularFeeds: feeds }),
+ );
+ element.open();
+ await goToProfiles(element);
+ await addProfiles(element, 7);
+ query(element, "starter-pack-wizard-next").click();
+ await nextFrame();
+ assert.equal(wizardState(element), "feeds");
+ let toggles = queryAll(element, "starter-pack-feed-toggle");
+ assert.equal(toggles.length, 4);
+ assert.equal(
+ toggles.every(
+ (toggle) => toggle.getAttribute("data-teststate") === "not-included",
+ ),
+ true,
+ );
+ assert.equal(
+ query(element, "starter-pack-wizard-next").textContent.trim(),
+ "Skip",
+ );
+
+ for (const toggle of toggles.slice(0, 3)) toggle.click();
+ await nextFrame();
+ toggles = queryAll(element, "starter-pack-feed-toggle");
+ assert.equal(toggles[3].getAttribute("data-teststate"), "capped");
+ assert.equal(
+ query(element, "starter-pack-wizard-count").textContent.trim(),
+ "3/3",
+ );
+ assert.equal(
+ query(element, "starter-pack-wizard-next").textContent.trim(),
+ "Finish",
+ );
+ });
+
+ it("shows the Discover feed locked in feed search results", async () => {
+ const dataLayer = makeDataLayer({
+ follows: makeFollows(8),
+ popularFeeds: [createFeed("at://feed/1", "One")],
+ });
+ const element = createDialog(dataLayer);
+ element.open();
+ await goToProfiles(element);
+ await addProfiles(element, 7);
+ query(element, "starter-pack-wizard-next").click();
+ await nextFrame();
+ assert.equal(wizardState(element), "feeds");
+
+ typeInto(element, "starter-pack-feed-search", "disc");
+ dataLayer.dataStore.$feedSearchResults.set({
+ feeds: [createFeed(DISCOVER_FEED_URI, "Discover")],
+ cursor: null,
+ });
+ await nextFrame();
+ const toggles = queryAll(element, "starter-pack-feed-toggle");
+ assert.equal(toggles.length, 1);
+ assert.equal(toggles[0].getAttribute("data-teststate"), "locked");
+ });
+
+ it("dispatches starter-pack-create with the selection and closes on success", async () => {
+ const feeds = [createFeed("at://feed/1", "One")];
+ const element = createDialog(
+ makeDataLayer({ follows: makeFollows(8), popularFeeds: feeds }),
+ );
+ element.open();
+ const events = [];
+ element.addEventListener("starter-pack-create", (event) => {
+ events.push(event.detail);
+ });
+ let closed = false;
+ element.addEventListener("dialog-closed", () => {
+ closed = true;
+ });
+ typeInto(element, "starter-pack-name-input", "My pack");
+ typeInto(element, "starter-pack-description-input", "About");
+ await goToProfiles(element);
+ await addProfiles(element, 7);
+ query(element, "starter-pack-wizard-next").click();
+ await nextFrame();
+ queryAll(element, "starter-pack-feed-toggle")[0].click();
+ await nextFrame();
+ query(element, "starter-pack-wizard-next").click();
+ await nextFrame();
+
+ assert.equal(events.length, 1);
+ const { data, successCallback } = events[0];
+ assert.equal(data.name, "My pack");
+ assert.equal(data.description, "About");
+ assert.equal(data.profiles.length, 8);
+ assert.equal(data.profiles[0].did, ME.did);
+ assert.deepEqual(
+ data.feeds.map((feed) => feed.uri),
+ ["at://feed/1"],
+ );
+ assert.equal(query(element, "starter-pack-wizard-next").disabled, true);
+
+ successCallback();
+ await nextFrame();
+ await nextFrame();
+ assert.equal(closed, true);
+ });
+
+ it("dispatches starter-pack-update in edit mode", async () => {
+ const follows = makeFollows(10);
+ const members = follows.slice(0, 3);
+ const element = createDialog(makeDataLayer({ follows }), {
+ starterPack: {
+ uri: "at://did:plc:me/app.bsky.graph.starterpack/p",
+ record: { name: "Pack", description: "" },
+ creator: ME,
+ list: { uri: "at://did:plc:me/app.bsky.graph.list/p" },
+ feeds: [createFeed("at://feed/x", "X")],
+ },
+ members,
+ optedOutDids: [],
+ });
+ element.open();
+ const events = [];
+ element.addEventListener("starter-pack-update", (event) => {
+ events.push(event.detail);
+ });
+ typeInto(element, "starter-pack-name-input", "Renamed");
+ await goToProfiles(element);
+ assert.equal(query(element, "starter-pack-wizard-count"), null);
+ assert.equal(query(element, "starter-pack-wizard-next").disabled, true);
+ await addProfiles(element, 4);
+ query(element, "starter-pack-wizard-next").click();
+ await nextFrame();
+ query(element, "starter-pack-wizard-next").click();
+ await nextFrame();
+ assert.equal(events.length, 1);
+ assert.equal(events[0].data.name, "Renamed");
+ assert.deepEqual(
+ events[0].data.feeds.map((feed) => feed.uri),
+ ["at://feed/x"],
+ );
+ });
+
+ it("removes people from the selection review panel", async () => {
+ const element = createDialog(makeDataLayer({ follows: makeFollows(3) }));
+ element.open();
+ await goToProfiles(element);
+ assert.equal(query(element, "starter-pack-wizard-edit-selection"), null);
+ await addProfiles(element, 2);
+ query(element, "starter-pack-wizard-edit-selection").click();
+ await nextFrame();
+ assert.equal(wizardState(element), "review");
+ const removeButtons = queryAll(element, "starter-pack-review-remove");
+ assert.equal(removeButtons.length, 2);
+ removeButtons[0].click();
+ await nextFrame();
+ assert.equal(queryAll(element, "starter-pack-review-remove").length, 1);
+ query(element, "starter-pack-wizard-review-close").click();
+ await nextFrame();
+ assert.equal(wizardState(element), "profiles");
+ });
+
+ it("asks before discarding a dirty wizard and closes without prompting when clean", async () => {
+ const element = createDialog(makeDataLayer());
+ element.open();
+ assert.equal(await element.confirmClose(), true);
+ typeInto(element, "starter-pack-name-input", "Dirty");
+ await nextFrame();
+ const pending = element.confirmClose();
+ await respondToConfirm(false);
+ assert.equal(await pending, false);
+ const pendingYes = element.confirmClose();
+ await respondToConfirm(true);
+ assert.equal(await pendingYes, true);
+ });
+});
diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js
index a08b9d51..bb98dd86 100644
--- a/tests/unit/specs/dataLayer/mutations.test.js
+++ b/tests/unit/specs/dataLayer/mutations.test.js
@@ -4831,6 +4831,7 @@ describe("$detailedProfiles mirroring", () => {
putProfileRecord: async () => ({}),
};
const { mutations, dataStore } = setup(mockApi);
+ dataStore.$currentUser.set(baseProfile);
await mutations.updateProfile(baseProfile, {
displayName: "Updated Name",
description: "Updated bio",
@@ -5222,6 +5223,427 @@ describe("updateList", () => {
});
});
+describe("starter pack mutations", () => {
+ const ME = "did:plc:me";
+ const currentUser = {
+ did: ME,
+ handle: "me.test",
+ displayName: "Me",
+ avatar: "",
+ associated: { lists: 1, starterPacks: 1 },
+ };
+ const packUri = `at://${ME}/app.bsky.graph.starterpack/pack1`;
+ const listUri = `at://${ME}/app.bsky.graph.list/pack1`;
+ const existingPack = {
+ uri: packUri,
+ cid: "bafypack",
+ record: {
+ $type: "app.bsky.graph.starterpack",
+ name: "Old name",
+ description: "Old description",
+ list: listUri,
+ feeds: [{ uri: "at://feed/old" }],
+ createdAt: "2025-01-01T00:00:00.000Z",
+ },
+ creator: { did: ME, handle: "me.test" },
+ list: { uri: listUri, cid: "bafylist", name: "Old name", listItemCount: 3 },
+ feeds: [{ uri: "at://feed/old", displayName: "Old feed" }],
+ };
+
+ function profile(did) {
+ return { did, handle: did.replace("did:plc:", "") + ".test", viewer: {} };
+ }
+
+ function listItemRecord(rkey, subjectDid, list = listUri) {
+ return {
+ uri: `at://${ME}/app.bsky.graph.listitem/${rkey}`,
+ value: { list, subject: subjectDid },
+ };
+ }
+
+ function setup({ listItems = [], overrides = {} } = {}) {
+ const dataStore = new DataStore(createSessionState(null));
+ const patchStore = new PatchStore();
+ dataStore.$currentUser.set(currentUser);
+ const applyWritesCalls = [];
+ const putListCalls = [];
+ const putPackCalls = [];
+ const api = {
+ session: { did: ME },
+ getListItems: async () => ({ records: listItems, cursor: "" }),
+ applyWrites: async (writes) => {
+ applyWritesCalls.push(writes);
+ return {
+ results: writes.map((write) => ({
+ uri: `at://${ME}/${write.collection}/${write.rkey}`,
+ cid: `cid-${write.rkey}`,
+ })),
+ };
+ },
+ getListRecord: async () => ({
+ cid: "listcid",
+ value: {
+ $type: "app.bsky.graph.list",
+ purpose: "app.bsky.graph.defs#referencelist",
+ name: "Old name",
+ description: "Old description",
+ createdAt: "2025-01-01T00:00:00.000Z",
+ },
+ }),
+ putListRecord: async (rkey, record, swap) => {
+ putListCalls.push({ rkey, record, swap });
+ return { cid: "newlistcid" };
+ },
+ getStarterPackRecord: async () => ({
+ cid: "packcid",
+ value: existingPack.record,
+ }),
+ putStarterPackRecord: async (rkey, record, swap) => {
+ putPackCalls.push({ rkey, record, swap });
+ return { cid: "newpackcid" };
+ },
+ ...overrides,
+ };
+ const mutations = makeMutations(api, dataStore, patchStore, {
+ requirePreferences: () => Preferences.createLoggedOutPreferences(),
+ });
+ return {
+ dataStore,
+ mutations,
+ applyWritesCalls,
+ putListCalls,
+ putPackCalls,
+ };
+ }
+
+ describe("createStarterPack", () => {
+ it("writes the list, its items and the pack in a single applyWrites call", async () => {
+ const { mutations, applyWritesCalls, dataStore } = setup();
+ const profiles = [
+ currentUser,
+ profile("did:plc:a"),
+ profile("did:plc:b"),
+ ];
+ const feeds = [{ uri: "at://feed/1", displayName: "Feed 1" }];
+
+ const view = await mutations.createStarterPack({
+ name: " My pack ",
+ description: "Hello",
+ profiles,
+ feeds,
+ });
+
+ assert.equal(applyWritesCalls.length, 1);
+ const writes = applyWritesCalls[0];
+ assert.equal(writes.length, 5);
+ assert.equal(writes[0].collection, "app.bsky.graph.list");
+ assert.equal(
+ writes[0].value.purpose,
+ "app.bsky.graph.defs#referencelist",
+ );
+ assert.equal(writes[0].value.name, "My pack");
+ assert.equal(writes[0].value.description, "Hello");
+ const listWriteUri = `at://${ME}/app.bsky.graph.list/${writes[0].rkey}`;
+ const itemWrites = writes.slice(1, 4);
+ assert.deepEqual(
+ itemWrites.map((write) => write.value.subject),
+ [ME, "did:plc:a", "did:plc:b"],
+ );
+ for (const write of itemWrites) {
+ assert.equal(write.collection, "app.bsky.graph.listitem");
+ assert.equal(write.value.list, listWriteUri);
+ }
+ const packWrite = writes[4];
+ assert.equal(packWrite.collection, "app.bsky.graph.starterpack");
+ assert.equal(packWrite.value.name, "My pack");
+ assert.equal(packWrite.value.list, listWriteUri);
+ assert.deepEqual(packWrite.value.feeds, [{ uri: "at://feed/1" }]);
+
+ assert.equal(view.record.name, "My pack");
+ assert.equal(view.list.uri, listWriteUri);
+ assert.equal(dataStore.$starterPacks.get(view.uri), view);
+ assert.equal(
+ dataStore.$starterPackUrisByList.get(listWriteUri),
+ view.uri,
+ );
+ assert.equal(dataStore.$listMembers.get(listWriteUri).items.length, 3);
+ assert.deepEqual(dataStore.$currentUser.get().associated, {
+ lists: 2,
+ starterPacks: 2,
+ });
+ });
+
+ it("falls back to a default name and omits empty description and feeds", async () => {
+ const { mutations, applyWritesCalls } = setup();
+
+ await mutations.createStarterPack({
+ name: "",
+ description: " ",
+ profiles: [currentUser],
+ feeds: [],
+ });
+
+ const writes = applyWritesCalls[0];
+ const packWrite = writes[writes.length - 1];
+ assert.equal(packWrite.value.name, "Me's Starter Pack");
+ assert.equal("description" in packWrite.value, false);
+ assert.equal("feeds" in packWrite.value, false);
+ assert.equal("description" in writes[0].value, false);
+ });
+
+ it("prepends the new pack to the current user's cached starter packs", async () => {
+ const { mutations, dataStore } = setup();
+ dataStore.$actorStarterPacks.set(ME, {
+ starterPacks: [existingPack],
+ cursor: null,
+ });
+
+ const view = await mutations.createStarterPack({
+ name: "New",
+ description: "",
+ profiles: [currentUser],
+ feeds: [],
+ });
+
+ assert.deepEqual(
+ dataStore.$actorStarterPacks.get(ME).starterPacks.map((p) => p.uri),
+ [view.uri, packUri],
+ );
+ });
+ });
+
+ describe("updateStarterPack", () => {
+ it("diffs listitems against the PDS, keeping self and opted-out members", async () => {
+ const listItems = [
+ listItemRecord("self", ME),
+ listItemRecord("keep", "did:plc:keep"),
+ listItemRecord("remove", "did:plc:remove"),
+ listItemRecord("optedout", "did:plc:optedout"),
+ listItemRecord("dupe1", "did:plc:dupe"),
+ listItemRecord("dupe2", "did:plc:dupe"),
+ listItemRecord("other", "did:plc:other", "at://other/list"),
+ ];
+ const { mutations, dataStore, applyWritesCalls } = setup({ listItems });
+ dataStore.$starterPacks.set(packUri, existingPack);
+ dataStore.$listMembers.set(listUri, {
+ items: [
+ { uri: "li-self", subject: profile(ME) },
+ { uri: "li-keep", subject: profile("did:plc:keep") },
+ { uri: "li-remove", subject: profile("did:plc:remove") },
+ {
+ uri: "li-optedout",
+ subject: profile("did:plc:optedout"),
+ subjectOptedOut: true,
+ },
+ { uri: "li-dupe", subject: profile("did:plc:dupe") },
+ ],
+ cursor: null,
+ });
+
+ await mutations.updateStarterPack(existingPack, {
+ name: "New name",
+ description: "",
+ profiles: [
+ currentUser,
+ profile("did:plc:keep"),
+ profile("did:plc:dupe"),
+ profile("did:plc:new"),
+ ],
+ feeds: [],
+ });
+
+ const flat = applyWritesCalls.flat();
+ const deletes = flat
+ .filter(
+ (write) => write.$type === "com.atproto.repo.applyWrites#delete",
+ )
+ .map((write) => write.rkey);
+ assert.deepEqual(deletes.sort(), ["dupe2", "remove"]);
+ const creates = flat.filter(
+ (write) => write.$type === "com.atproto.repo.applyWrites#create",
+ );
+ assert.deepEqual(
+ creates.map((write) => write.value.subject),
+ ["did:plc:new"],
+ );
+ assert.equal(creates[0].value.list, listUri);
+
+ const members = dataStore.$listMembers.get(listUri);
+ assert.deepEqual(
+ members.items.map((item) => item.subject.did),
+ [ME, "did:plc:keep", "did:plc:dupe", "did:plc:new", "did:plc:optedout"],
+ );
+ assert.equal(
+ members.items.find((item) => item.subject.did === "did:plc:keep").uri,
+ `at://${ME}/app.bsky.graph.listitem/keep`,
+ );
+ });
+
+ it("rewrites both the list and pack records with swap CIDs", async () => {
+ const { mutations, dataStore, putListCalls, putPackCalls } = setup({
+ listItems: [listItemRecord("self", ME)],
+ });
+ dataStore.$starterPacks.set(packUri, existingPack);
+ dataStore.$lists.set(listUri, {
+ uri: listUri,
+ name: "Old name",
+ description: "Old description",
+ });
+
+ await mutations.updateStarterPack(existingPack, {
+ name: "New name",
+ description: "New description",
+ profiles: [currentUser],
+ feeds: [
+ { uri: "at://feed/a", displayName: "A", extra: "ignored" },
+ { uri: "at://feed/b", displayName: "B" },
+ ],
+ });
+
+ assert.equal(putListCalls.length, 1);
+ assert.equal(putListCalls[0].rkey, "pack1");
+ assert.equal(putListCalls[0].swap, "listcid");
+ assert.equal(putListCalls[0].record.name, "New name");
+ assert.equal(putListCalls[0].record.description, "New description");
+ assert.equal(
+ putListCalls[0].record.purpose,
+ "app.bsky.graph.defs#referencelist",
+ );
+
+ assert.equal(putPackCalls.length, 1);
+ assert.equal(putPackCalls[0].swap, "packcid");
+ const record = putPackCalls[0].record;
+ assert.equal(record.name, "New name");
+ assert.equal(record.description, "New description");
+ assert.equal(record.createdAt, "2025-01-01T00:00:00.000Z");
+ assert.equal(typeof record.updatedAt, "string");
+ assert.deepEqual(record.feeds, [
+ { uri: "at://feed/a" },
+ { uri: "at://feed/b" },
+ ]);
+
+ const updated = dataStore.$starterPacks.get(packUri);
+ assert.equal(updated.record.name, "New name");
+ assert.equal(updated.list.name, "New name");
+ assert.equal(updated.feeds.length, 2);
+ assert.equal(updated.cid, "newpackcid");
+ assert.equal(dataStore.$lists.get(listUri).name, "New name");
+ });
+
+ it("drops description and feeds from the records when cleared", async () => {
+ const { mutations, dataStore, putListCalls, putPackCalls } = setup({
+ listItems: [listItemRecord("self", ME)],
+ });
+ dataStore.$starterPacks.set(packUri, existingPack);
+
+ await mutations.updateStarterPack(existingPack, {
+ name: "Old name",
+ description: "",
+ profiles: [currentUser],
+ feeds: [],
+ });
+
+ assert.equal("description" in putListCalls[0].record, false);
+ assert.equal("description" in putPackCalls[0].record, false);
+ assert.equal("feeds" in putPackCalls[0].record, false);
+ });
+
+ it("refuses to edit a pack owned by someone else", async () => {
+ const { mutations } = setup();
+ await assert.rejects(
+ mutations.updateStarterPack(
+ { ...existingPack, creator: { did: "did:plc:other" } },
+ { name: "x", description: "", profiles: [], feeds: [] },
+ ),
+ /owned by another account/,
+ );
+ });
+ });
+
+ describe("deleteStarterPack", () => {
+ it("deletes listitems, the list and finally the pack", async () => {
+ const { mutations, dataStore, applyWritesCalls } = setup({
+ listItems: [
+ listItemRecord("self", ME),
+ listItemRecord("a", "did:plc:a"),
+ listItemRecord("other", "did:plc:other", "at://other/list"),
+ ],
+ });
+ dataStore.$starterPacks.set(packUri, existingPack);
+ dataStore.$starterPackUrisByList.set(listUri, packUri);
+ dataStore.$lists.set(listUri, existingPack.list);
+ dataStore.$listMembers.set(listUri, { items: [], cursor: null });
+ dataStore.$actorStarterPacks.set(ME, {
+ starterPacks: [existingPack],
+ cursor: null,
+ });
+ dataStore.$starterPackSearchResults.set({
+ starterPacks: [existingPack],
+ cursor: null,
+ });
+
+ await mutations.deleteStarterPack(existingPack);
+
+ const flat = applyWritesCalls.flat();
+ assert.deepEqual(
+ flat.map((write) => [write.collection, write.rkey]),
+ [
+ ["app.bsky.graph.listitem", "self"],
+ ["app.bsky.graph.listitem", "a"],
+ ["app.bsky.graph.list", "pack1"],
+ ["app.bsky.graph.starterpack", "pack1"],
+ ],
+ );
+ assert.equal(dataStore.$starterPacks.get(packUri), null);
+ assert.equal(dataStore.$starterPackUrisByList.get(listUri), null);
+ assert.equal(dataStore.$lists.get(listUri), null);
+ assert.equal(dataStore.$listMembers.get(listUri), null);
+ assert.deepEqual(dataStore.$actorStarterPacks.get(ME).starterPacks, []);
+ assert.deepEqual(
+ dataStore.$starterPackSearchResults.get().starterPacks,
+ [],
+ );
+ assert.deepEqual(dataStore.$currentUser.get().associated, {
+ lists: 0,
+ starterPacks: 0,
+ });
+ });
+
+ it("deletes a pack whose list is missing", async () => {
+ const { mutations, applyWritesCalls } = setup();
+
+ await mutations.deleteStarterPack({ ...existingPack, list: undefined });
+
+ assert.deepEqual(applyWritesCalls, [
+ [
+ {
+ $type: "com.atproto.repo.applyWrites#delete",
+ collection: "app.bsky.graph.starterpack",
+ rkey: "pack1",
+ },
+ ],
+ ]);
+ });
+
+ it("chunks large deletes and keeps the pack in the last chunk", async () => {
+ const listItems = Array.from({ length: 60 }, (_, i) =>
+ listItemRecord(`item${i}`, `did:plc:${i}`),
+ );
+ const { mutations, applyWritesCalls } = setup({ listItems });
+
+ await mutations.deleteStarterPack(existingPack);
+
+ assert.equal(applyWritesCalls.length, 2);
+ assert.equal(applyWritesCalls[0].length, 50);
+ const last = applyWritesCalls[1];
+ assert.equal(
+ last[last.length - 1].collection,
+ "app.bsky.graph.starterpack",
+ );
+ });
+ });
+});
+
describe("deleteList", () => {
const listUri = "at://did:plc:test123/app.bsky.graph.list/mylist";
const otherListUri = "at://did:plc:test123/app.bsky.graph.list/other";
diff --git a/tests/unit/specs/dialogHelpers.test.js b/tests/unit/specs/dialogHelpers.test.js
index e99e494d..362a45b1 100644
--- a/tests/unit/specs/dialogHelpers.test.js
+++ b/tests/unit/specs/dialogHelpers.test.js
@@ -138,6 +138,23 @@ describe("resetScrollOnBlur", () => {
dialog.remove();
});
+ it("re-evaluates a scroll area getter on each blur", () => {
+ const otherDialog = document.createElement("dialog");
+ let area = document.createElement("div");
+ otherDialog.appendChild(area);
+ document.body.appendChild(otherDialog);
+ resetScrollOnBlur(otherDialog, () => area);
+ const replacement = document.createElement("div");
+ otherDialog.replaceChild(replacement, area);
+ area = replacement;
+ replacement.scrollTop = 42;
+ const input = document.createElement("input");
+ replacement.appendChild(input);
+ blurFrom(input);
+ assert.deepEqual(replacement.scrollTop, 0);
+ otherDialog.remove();
+ });
+
it("resets the scroll area when a textarea blurs", () => {
const textarea = document.createElement("textarea");
scrollArea.appendChild(textarea);
diff --git a/tests/unit/specs/dragHelpers.test.js b/tests/unit/specs/dragHelpers.test.js
index ceeb1bf5..0e03f58f 100644
--- a/tests/unit/specs/dragHelpers.test.js
+++ b/tests/unit/specs/dragHelpers.test.js
@@ -187,6 +187,24 @@ describe("enableDragToDismiss", () => {
await wait(0);
assert.equal(closeCount, 0);
});
+ it("re-evaluates a scrollContainer getter on each touch", async () => {
+ let scrollContainer = document.createElement("div");
+ el.appendChild(scrollContainer);
+ handle = enableDragToDismiss(el, {
+ direction: "down",
+ onDismiss: () => closeCount++,
+ scrollContainer: () => scrollContainer,
+ });
+ const replacement = document.createElement("div");
+ replacement.scrollTop = 100;
+ el.replaceChild(replacement, scrollContainer);
+ scrollContainer = replacement;
+ replacement.dispatchEvent(touchEvent("touchstart", { clientY: 100 }));
+ el.dispatchEvent(touchEvent("touchmove", { clientY: 250 }));
+ el.dispatchEvent(touchEvent("touchend"));
+ await wait(0);
+ assert.equal(closeCount, 0);
+ });
it("allows dismissing from outside a scrolled container", async () => {
const scrollContainer = document.createElement("div");
scrollContainer.scrollTop = 100;
diff --git a/tests/unit/specs/listInteractionHandler.test.js b/tests/unit/specs/starterPackInteractionHandler.test.js
similarity index 82%
rename from tests/unit/specs/listInteractionHandler.test.js
rename to tests/unit/specs/starterPackInteractionHandler.test.js
index 33a1780b..538c292a 100644
--- a/tests/unit/specs/listInteractionHandler.test.js
+++ b/tests/unit/specs/starterPackInteractionHandler.test.js
@@ -1,13 +1,13 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
-import { ListInteractionHandler } from "/js/listInteractionHandler.js";
+import { StarterPackInteractionHandler } from "/js/starterPackInteractionHandler.js";
import {
makeTestDataLayer,
respondToConfirm,
waitFor,
} from "../testHelpers.js";
-describe("ListInteractionHandler reference list opt-out", () => {
+describe("StarterPackInteractionHandler opt-out", () => {
const list = {
uri: "at://did:plc:creator/app.bsky.graph.list/pack",
viewer: {},
@@ -32,13 +32,13 @@ describe("ListInteractionHandler reference list opt-out", () => {
calls.push(arg);
return mutationImpl();
};
- return { handler: new ListInteractionHandler(dataLayer), calls };
+ return { handler: new StarterPackInteractionHandler(dataLayer), calls };
}
it("does not mutate when the confirm is cancelled", async () => {
const { handler, calls } = makeHandler(async () => {});
- const promise = handler.handleOptOutOfReferenceList(list);
+ const promise = handler.handleOptOut(list);
await respondToConfirm(false);
assert.deepEqual(await promise, false);
@@ -48,7 +48,7 @@ describe("ListInteractionHandler reference list opt-out", () => {
it("opts out on confirm and toasts", async () => {
const { handler, calls } = makeHandler(async () => {});
- const promise = handler.handleOptOutOfReferenceList(list);
+ const promise = handler.handleOptOut(list);
await respondToConfirm(true);
assert.deepEqual(await promise, true);
@@ -60,7 +60,7 @@ describe("ListInteractionHandler reference list opt-out", () => {
it("undoes on confirm and toasts", async () => {
const { handler, calls } = makeHandler(async () => {});
- const promise = handler.handleUndoReferenceListOptOut(list);
+ const promise = handler.handleUndoOptOut(list);
await respondToConfirm(true);
assert.deepEqual(await promise, true);
@@ -73,7 +73,7 @@ describe("ListInteractionHandler reference list opt-out", () => {
throw new Error("nope");
});
- const promise = handler.handleOptOutOfReferenceList(list);
+ const promise = handler.handleOptOut(list);
await respondToConfirm(true);
await waitFor(() => document.querySelector('[data-testid="toast"].error'));
diff --git a/tests/unit/specs/templates/feedsFeed.template.test.js b/tests/unit/specs/templates/feedGeneratorList.template.test.js
similarity index 81%
rename from tests/unit/specs/templates/feedsFeed.template.test.js
rename to tests/unit/specs/templates/feedGeneratorList.template.test.js
index fa9e1f6a..6901f70c 100644
--- a/tests/unit/specs/templates/feedsFeed.template.test.js
+++ b/tests/unit/specs/templates/feedGeneratorList.template.test.js
@@ -1,15 +1,15 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
-import { feedsFeedTemplate } from "/js/templates/feedsFeed.template.js";
+import { feedGeneratorListTemplate } from "/js/templates/feedGeneratorList.template.js";
import { render } from "/js/lib/lit-html.js";
function renderLoading() {
const container = document.createElement("div");
- render(feedsFeedTemplate({ items: null, renderItem: () => "" }), container);
+ render(feedGeneratorListTemplate({ feedGenerators: null }), container);
return container;
}
-describe("feedsFeedTemplate loading skeleton", () => {
+describe("feedGeneratorListTemplate loading skeleton", () => {
it("renders skeleton items", () => {
const container = renderLoading();
assert(
diff --git a/tests/unit/specs/templates/listFeed.template.test.js b/tests/unit/specs/templates/listList.template.test.js
similarity index 94%
rename from tests/unit/specs/templates/listFeed.template.test.js
rename to tests/unit/specs/templates/listList.template.test.js
index 9b02f03e..cfa94b9d 100644
--- a/tests/unit/specs/templates/listFeed.template.test.js
+++ b/tests/unit/specs/templates/listList.template.test.js
@@ -1,11 +1,11 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
-import { listFeedTemplate } from "/js/templates/listFeed.template.js";
+import { listListTemplate } from "/js/templates/listList.template.js";
import { render } from "/js/lib/lit-html.js";
function renderList(props) {
const container = document.createElement("div");
- render(listFeedTemplate(props), container);
+ render(listListTemplate(props), container);
return container;
}
@@ -20,7 +20,7 @@ function makeList(overrides = {}) {
};
}
-describe("listFeedTemplate", () => {
+describe("listListTemplate", () => {
it("renders skeletons while loading", () => {
const container = renderList({ lists: null });
assert(
diff --git a/tests/unit/specs/templates/paginatedList.template.test.js b/tests/unit/specs/templates/paginatedList.template.test.js
new file mode 100644
index 00000000..b15132c2
--- /dev/null
+++ b/tests/unit/specs/templates/paginatedList.template.test.js
@@ -0,0 +1,83 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { html, render } from "/js/lib/lit-html.js";
+import { paginatedListTemplate } from "/js/templates/paginatedList.template.js";
+
+function renderList(props) {
+ const container = document.createElement("div");
+ render(
+ paginatedListTemplate({
+ renderItem: (item) => html`
${item}
`,
+ ...props,
+ }),
+ container,
+ );
+ return container;
+}
+
+describe("paginatedListTemplate", () => {
+ it("renders skeleton rows while items are null", () => {
+ const container = renderList({ items: null });
+ assert(
+ container.querySelectorAll("[data-testid='feeds-list-item-skeleton']")
+ .length > 0,
+ );
+ assert.equal(container.querySelector(".row"), null);
+ });
+
+ it("uses a caller-supplied skeleton row when given", () => {
+ const container = renderList({
+ items: null,
+ renderSkeletonItem: () => html`
`,
+ });
+ assert.equal(container.querySelectorAll(".custom-skeleton").length, 10);
+ assert.equal(
+ container.querySelector("[data-testid='feeds-list-item-skeleton']"),
+ null,
+ );
+ });
+
+ it("renders the empty template only when there are no items", () => {
+ const emptyTemplate = html`
Nothing
`;
+ const empty = renderList({ items: [], emptyTemplate });
+ assert(empty.querySelector(".empty"));
+ const filled = renderList({ items: ["a", "b"], emptyTemplate });
+ assert.equal(filled.querySelector(".empty"), null);
+ assert.equal(filled.querySelectorAll(".row").length, 2);
+ });
+
+ it("renders a plain empty message inside the standard wrapper", () => {
+ const container = renderList({ items: [], emptyMessage: "Nothing" });
+ const empty = container.querySelector("[data-testid='empty-state']");
+ assert(empty.textContent.includes("Nothing"));
+ const overridden = renderList({
+ items: [],
+ emptyMessage: "Nothing",
+ emptyTemplate: html`
Custom
`,
+ });
+ assert(overridden.querySelector(".empty"));
+ assert.equal(overridden.querySelector("[data-testid='empty-state']"), null);
+ });
+
+ it("renders nothing for an empty list without an empty template", () => {
+ const container = renderList({ items: [] });
+ assert.equal(container.querySelector(".feeds-list").children.length, 0);
+ });
+
+ it("shows a spinner instead of the footer while more pages remain", () => {
+ const footerTemplate = html``;
+ const more = renderList({ items: ["a"], hasMore: true, footerTemplate });
+ assert(more.querySelector(".loading-spinner"));
+ assert.equal(more.querySelector(".footer"), null);
+ const done = renderList({ items: ["a"], hasMore: false, footerTemplate });
+ assert.equal(done.querySelector(".loading-spinner"), null);
+ assert(done.querySelector(".footer"));
+ });
+
+ it("wraps the list in an infinite-scroll container only when onLoadMore is given", () => {
+ const plain = renderList({ items: ["a"] });
+ assert.equal(plain.querySelector("infinite-scroll-container"), null);
+ const scrolling = renderList({ items: ["a"], onLoadMore: async () => {} });
+ assert(scrolling.querySelector("infinite-scroll-container .feeds-list"));
+ });
+});
diff --git a/tests/unit/specs/templates/postFeed.template.test.js b/tests/unit/specs/templates/postFeed.template.test.js
index 30141037..1d4430ea 100644
--- a/tests/unit/specs/templates/postFeed.template.test.js
+++ b/tests/unit/specs/templates/postFeed.template.test.js
@@ -52,9 +52,7 @@ describe("postFeedTemplate - empty state", () => {
});
const container = document.createElement("div");
render(result, container);
- assert(
- container.querySelector("[data-testid='feed-end-message']") !== null,
- );
+ assert(container.querySelector("[data-testid='empty-state']") !== null);
});
it("should show custom empty message when provided", () => {
@@ -65,7 +63,7 @@ describe("postFeedTemplate - empty state", () => {
});
const container = document.createElement("div");
render(result, container);
- const message = container.querySelector("[data-testid='feed-end-message']");
+ const message = container.querySelector("[data-testid='empty-state']");
assert(message.textContent.includes("No posts yet!"));
});
});
@@ -88,6 +86,7 @@ describe("postFeedTemplate - feed with posts", () => {
feed: { feed: feed.slice(0, 2), cursor: null },
currentUser: mockUser,
postInteractionHandler,
+ onLoadMore: async () => {},
});
const container = document.createElement("div");
render(result, container);
diff --git a/tests/unit/specs/templates/profileFeed.template.test.js b/tests/unit/specs/templates/profileList.template.test.js
similarity index 94%
rename from tests/unit/specs/templates/profileFeed.template.test.js
rename to tests/unit/specs/templates/profileList.template.test.js
index 4ce5b54a..3a233b15 100644
--- a/tests/unit/specs/templates/profileFeed.template.test.js
+++ b/tests/unit/specs/templates/profileList.template.test.js
@@ -3,8 +3,8 @@ import assert from "node:assert/strict";
import {
profileListItemTemplate,
profileListItemSkeletonTemplate,
- profileFeedTemplate,
-} from "/js/templates/profileFeed.template.js";
+ profileListTemplate,
+} from "/js/templates/profileList.template.js";
import { html, render } from "/js/lib/lit-html.js";
const mockActor = {
@@ -362,7 +362,7 @@ describe("profileListItemSkeletonTemplate", () => {
});
});
-describe("profileFeedTemplate", () => {
+describe("profileListTemplate", () => {
let container;
beforeEach(() => {
@@ -376,25 +376,25 @@ describe("profileFeedTemplate", () => {
});
it("should render skeleton when profiles is null", () => {
- const result = profileFeedTemplate({ profiles: null, hasMore: false });
+ const result = profileListTemplate({ profiles: null, hasMore: false });
render(result, container);
assert(container.querySelector("[data-testid='skeleton-avatar']") !== null);
});
it("should render empty message when profiles is empty", () => {
- const result = profileFeedTemplate({
+ const result = profileListTemplate({
profiles: [],
hasMore: false,
emptyMessage: "Nothing here.",
});
render(result, container);
- const msg = container.querySelector("[data-testid='feed-end-message']");
+ const msg = container.querySelector("[data-testid='empty-state']");
assert(msg !== null);
assert(msg.textContent.includes("Nothing here."));
});
it("should render profile list items when profiles is non-empty", () => {
- const result = profileFeedTemplate({
+ const result = profileListTemplate({
profiles: [mockActor],
hasMore: false,
});
@@ -414,7 +414,7 @@ describe("profileFeedTemplate", () => {
labels: [{ val: "!no-unauthenticated", src: "did:plc:hidden" }],
};
render(
- profileFeedTemplate({
+ profileListTemplate({
profiles: [mockActor, hiddenActor],
hasMore: false,
isAuthenticated: false,
@@ -435,7 +435,7 @@ describe("profileFeedTemplate", () => {
labels: [{ val: "!no-unauthenticated", src: "did:plc:hidden" }],
};
render(
- profileFeedTemplate({
+ profileListTemplate({
profiles: [mockActor, hiddenActor],
hasMore: false,
isAuthenticated: true,
@@ -449,7 +449,7 @@ describe("profileFeedTemplate", () => {
});
it("should render 10 skeletons by default when loading", () => {
- const result = profileFeedTemplate({ profiles: null, hasMore: false });
+ const result = profileListTemplate({ profiles: null, hasMore: false });
render(result, container);
assert.deepEqual(
container.querySelectorAll("[data-testid='skeleton-avatar']").length,
@@ -458,7 +458,7 @@ describe("profileFeedTemplate", () => {
});
it("should honor skeletonCount when loading", () => {
- const result = profileFeedTemplate({
+ const result = profileListTemplate({
profiles: null,
hasMore: false,
skeletonCount: 3,
@@ -471,7 +471,7 @@ describe("profileFeedTemplate", () => {
});
it("should not render end-of-feed message by default when not hasMore", () => {
- const result = profileFeedTemplate({
+ const result = profileListTemplate({
profiles: [mockActor],
hasMore: false,
});
@@ -483,7 +483,7 @@ describe("profileFeedTemplate", () => {
});
it("should render end-of-feed message when showEndMessage is true and not hasMore", () => {
- const result = profileFeedTemplate({
+ const result = profileListTemplate({
profiles: [mockActor],
hasMore: false,
showEndMessage: true,
@@ -495,7 +495,7 @@ describe("profileFeedTemplate", () => {
});
it("should pass rightItemTemplate through to list items", () => {
- const result = profileFeedTemplate({
+ const result = profileListTemplate({
profiles: [mockActor],
hasMore: false,
rightItemTemplate: () =>
@@ -506,15 +506,12 @@ describe("profileFeedTemplate", () => {
});
it("should render loading indicator when hasMore is true", () => {
- const result = profileFeedTemplate({
+ const result = profileListTemplate({
profiles: [mockActor],
hasMore: true,
});
render(result, container);
- assert(
- container.querySelector("[data-testid='feed-loading-indicator']") !==
- null,
- );
+ assert(container.querySelector(".feeds-list .loading-spinner") !== null);
assert.deepEqual(
container.querySelector("[data-testid='feed-end-message']"),
null,
@@ -522,7 +519,7 @@ describe("profileFeedTemplate", () => {
});
});
-describe("profileFeedTemplate - missing pluginService warning", () => {
+describe("profileListTemplate - missing pluginService warning", () => {
let container;
let warn;
@@ -537,7 +534,7 @@ describe("profileFeedTemplate - missing pluginService warning", () => {
it("should warn when a non-compact feed has no pluginService", () => {
render(
- profileFeedTemplate({ profiles: [mockActor], hasMore: false }),
+ profileListTemplate({ profiles: [mockActor], hasMore: false }),
container,
);
assert.deepEqual(warn.mock.callCount(), 1);
@@ -546,7 +543,7 @@ describe("profileFeedTemplate - missing pluginService warning", () => {
it("should not warn when pluginService is provided", () => {
render(
- profileFeedTemplate({
+ profileListTemplate({
profiles: [mockActor],
hasMore: false,
pluginService: {},
@@ -558,7 +555,7 @@ describe("profileFeedTemplate - missing pluginService warning", () => {
it("should not warn for compact feeds", () => {
render(
- profileFeedTemplate({
+ profileListTemplate({
profiles: [mockActor],
hasMore: false,
compact: true,
diff --git a/tests/unit/specs/templates/starterPackList.template.test.js b/tests/unit/specs/templates/starterPackList.template.test.js
new file mode 100644
index 00000000..81863a98
--- /dev/null
+++ b/tests/unit/specs/templates/starterPackList.template.test.js
@@ -0,0 +1,94 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { html, render } from "/js/lib/lit-html.js";
+import { starterPackListTemplate } from "/js/templates/starterPackList.template.js";
+
+function renderList(props) {
+ const container = document.createElement("div");
+ render(
+ starterPackListTemplate({
+ cursor: null,
+ currentUser: { did: "did:plc:me" },
+ onLoadMore: null,
+ ...props,
+ }),
+ container,
+ );
+ return container;
+}
+
+function makeStarterPack(rkey) {
+ return {
+ uri: `at://did:plc:me/app.bsky.graph.starterpack/${rkey}`,
+ record: { name: `Pack ${rkey}`, description: "" },
+ creator: { did: "did:plc:me", handle: "me.test" },
+ listItemsSample: [{ subject: { did: "did:plc:a", handle: "a.test" } }],
+ listItemCount: 1,
+ };
+}
+
+describe("starterPackListTemplate", () => {
+ it("renders skeleton rows that mirror the member avatar strip", () => {
+ const container = renderList({ starterPacks: null });
+ const skeletons = container.querySelectorAll(
+ "[data-testid='feeds-list-item-skeleton']",
+ );
+ assert.equal(skeletons.length, 10);
+ assert(skeletons[0].classList.contains("starter-pack-list-item"));
+ assert(skeletons[0].querySelector(".starter-pack-list-item-members"));
+ });
+
+ it("renders rows with the member strip and no create affordances by default", () => {
+ const container = renderList({
+ starterPacks: [makeStarterPack("a"), makeStarterPack("b")],
+ });
+ assert.equal(
+ container.querySelectorAll("[data-testid='starter-pack-list-item']")
+ .length,
+ 2,
+ );
+ assert(container.querySelector("[data-testid='starter-pack-members']"));
+ assert.equal(
+ container.querySelector("[data-testid='starter-pack-create-another']"),
+ null,
+ );
+ });
+
+ it("renders the caller's empty state and footer templates", () => {
+ const emptyTemplate = html`
`;
+ const footerTemplate = html`
`;
+ const empty = renderList({
+ starterPacks: [],
+ emptyTemplate,
+ footerTemplate,
+ });
+ assert(empty.querySelector("[data-testid='starter-pack-create-button']"));
+ assert.equal(
+ empty.querySelector("[data-testid='starter-pack-create-another']"),
+ null,
+ );
+ const filled = renderList({
+ starterPacks: [makeStarterPack("a")],
+ emptyTemplate,
+ footerTemplate,
+ });
+ assert(filled.querySelector("[data-testid='starter-pack-create-another']"));
+ assert.equal(
+ filled.querySelector("[data-testid='starter-pack-create-button']"),
+ null,
+ );
+ });
+
+ it("shows the plain empty message without an empty template", () => {
+ const container = renderList({ starterPacks: [] });
+ const empty = container.querySelector("[data-testid='empty-state']");
+ assert(empty.textContent.includes("No starter packs yet."));
+ assert.equal(empty.querySelector("button"), null);
+ });
+});
diff --git a/tests/unit/specs/utils.test.js b/tests/unit/specs/utils.test.js
index 945a1c52..a8d719d5 100644
--- a/tests/unit/specs/utils.test.js
+++ b/tests/unit/specs/utils.test.js
@@ -35,6 +35,8 @@ import {
throttleByKey,
WindowedCounter,
Poller,
+ graphemeCount,
+ truncateGraphemes,
} from "/js/utils.js";
import { flushMicrotasks, installFakeIndexedDB } from "../testHelpers.js";
@@ -397,6 +399,11 @@ describe("classnames", () => {
assert.deepEqual(result, "");
});
+ it("should skip null and undefined entries", () => {
+ const result = classnames("base", null, undefined, "extra");
+ assert.deepEqual(result, "base extra");
+ });
+
it("should throw error for invalid input", () => {
let errorThrown = false;
try {
@@ -2086,3 +2093,34 @@ describe("Poller", () => {
assert.deepEqual(counter.calls, 2);
});
});
+
+describe("truncateGraphemes", () => {
+ it("returns the string unchanged when within the limit", () => {
+ assert.equal(truncateGraphemes("hello", 5), "hello");
+ assert.equal(truncateGraphemes("", 3), "");
+ });
+
+ it("cuts plain text to the grapheme limit", () => {
+ assert.equal(truncateGraphemes("hello world", 5), "hello");
+ });
+
+ it("keeps multi-code-point grapheme clusters intact", () => {
+ const family = "π¨βπ©βπ§βπ¦";
+ const flag = "πΊπΈ";
+ const truncated = truncateGraphemes(`ab${family}${flag}cd`, 4);
+ assert.equal(truncated, `ab${family}${flag}`);
+ assert.equal(graphemeCount(truncated), 4);
+ });
+
+ it("does not split a combining mark from its base", () => {
+ assert.equal(truncateGraphemes("e\u0301x", 1), "e\u0301");
+ });
+
+ it("appends the suffix only when truncating", () => {
+ assert.equal(
+ truncateGraphemes("hello world", 5, { suffix: "β¦" }),
+ "helloβ¦",
+ );
+ assert.equal(truncateGraphemes("hello", 5, { suffix: "β¦" }), "hello");
+ });
+});