Skip to content

Commit 06bf5ce

Browse files
committed
test: serve a reproducible subscription group preview
1 parent 9cc1cf7 commit 06bf5ce

4 files changed

Lines changed: 207 additions & 0 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Local subscription-group testing fixture
2+
3+
Run the real frontend against an in-memory API with **150 channels, 18 groups and 300 videos**. The seed includes overlapping memberships, ungrouped channels, an empty group, a channel in ten groups, long names, non-Latin names, and missing avatars. All images are generated locally. Video cards are illustrative; playback is unavailable.
4+
5+
From the repository root, start the API:
6+
7+
```sh
8+
bun run dev:groups-fixture
9+
```
10+
11+
In another terminal, start the frontend:
12+
13+
```sh
14+
VITE_DEV_PROXY_TARGET=http://127.0.0.1:9876 VITE_API_URL=/api bun run dev --host 127.0.0.1 --strictPort
15+
```
16+
17+
Open [Manage groups](http://127.0.0.1:5173/subscriptions/groups), [Channels](http://127.0.0.1:5173/subscriptions/channels?group=all), or [Videos](http://127.0.0.1:5173/subscriptions?group=all). If prompted to sign in, any nonempty identifier and password work with this local fixture; use dummy values. The profile menu identifies the local fixture.
18+
19+
The fixture binds only to `127.0.0.1:9876` and never forwards requests. CRUD and membership changes exist only in memory. Restarting the API restores the seed. To reset without restarting, then reload the browser:
20+
21+
```sh
22+
curl -X POST http://127.0.0.1:9876/__qa/reset
23+
```
24+
25+
## Pagination failure and retry
26+
27+
Open Videos and wait for the first page to load. Before scrolling to the bottom, inject four failures to exhaust the client's automatic retries on the next page:
28+
29+
```sh
30+
curl http://127.0.0.1:9876/__qa/fail -X POST \
31+
-H 'Content-Type: application/json' \
32+
--data '{"path":"/subscriptions/feed","query":"cursor=","method":"GET","count":4}'
33+
```
34+
35+
Scroll to the bottom. Existing cards should remain visible, with an error and Retry button below them. Retry should append the next page without removing or duplicating earlier cards. The same endpoint accepts a group-membership path with `method: "PUT"` and `count: 1` to exercise partial-save recovery.
36+
37+
## Suggested manager checks
38+
39+
- Show more from 50 to 100 to 150 channels; Select results selects all matching channels, including unloaded rows.
40+
- Select channels across group filters; compare In group / Not in group and Show selected.
41+
- Search for `Atlas` to inspect a long channel name and ten memberships in the combobox.
42+
- Test the Ungrouped and empty To explore filters, and create or rename a group.
43+
- Inspect current memberships, group counts, mutation logs and dataset totals at `http://127.0.0.1:9876/__qa/state`.

‎docs/subscription-groups-ux.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ The [manager stylesheet](../apps/web/src/styles/subscription-groups.css) consoli
4444

4545
## Verification scope
4646

47+
- Feed error regression: 346 tests passed after adding coverage for initial-load, pagination, and background-refresh failures. Chromium retained all 30 loaded cards after an injected next-page failure; keyboard Retry appended 30 more distinct cards and cleared the error. `check`, `knip`, `sherif`, the production build and the whitespace check passed.
48+
- A [reproducible local fixture](subscription-groups-fixture.md) now provides 150 channels, 18 groups and 300 videos, including 109 channels in multiple groups and 22 ungrouped channels. The 50-to-100-row manager expansion was checked in Chromium; fixture CRUD and filtering were checked in memory.
4749
- Final automated checks after defaulting the bulk target from the sidebar: 343 tests passed; `check`, `knip`, `sherif`, the production build and `git diff --check` passed.
4850
- Bulk-target browser checks confirmed Tech and Science sidebar selections populate the target, a manual Music override survives inversion and search, and All channels/Ungrouped reset the target. Existing channel selections were preserved throughout.
4951
- Row-selection checks covered background clicks, checkbox keyboard activation, highlighted rows in both themes, one/two/21 selections, hidden selections, retained drafts across filtering and multiple selection, Cancel, successful Save, and failed-save retry. Exactly one globally selected channel exposed an editor when visible. The local fixture's original memberships were restored after testing.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"localize": "bun run --cwd apps/web localize",
1818
"localization:report": "node scripts/localization-report.mjs",
1919
"dev": "bun run --cwd apps/web dev",
20+
"dev:groups-fixture": "bun scripts/fixtures/subscription-groups.ts",
2021
"build": "bun run --cwd apps/web build",
2122
"check": "bun run localize && node scripts/check-localization.mjs && biome check .",
2223
"format": "biome format --write .",
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { fixtureImage } from "./subscription-groups-data";
2+
import {
3+
createFixture,
4+
filteredChannels,
5+
groupCounts,
6+
writeGroup,
7+
} from "./subscription-groups-state";
8+
9+
let state = createFixture();
10+
const me = {
11+
id: "fixture-user",
12+
role: "user",
13+
publicUsername: "Local fixture · 150 channels",
14+
bio: null,
15+
avatarUrl: null,
16+
avatarType: null,
17+
avatarCode: null,
18+
};
19+
const empty = (): Response => new Response(null, { status: 204 });
20+
21+
Bun.serve({
22+
hostname: "127.0.0.1",
23+
port: 9876,
24+
async fetch(request): Promise<Response> {
25+
const url = new URL(request.url);
26+
const path = url.pathname;
27+
const method = request.method;
28+
if (path === "/__qa/state") {
29+
return Response.json({
30+
...state,
31+
groups: groupCounts(state),
32+
summary: {
33+
channels: state.channels.length,
34+
groups: state.groups.length,
35+
videos: state.videos.length,
36+
ungrouped: state.channels.filter((channel) => channel.groupIds.length === 0).length,
37+
multipleGroups: state.channels.filter((channel) => channel.groupIds.length > 1).length,
38+
},
39+
});
40+
}
41+
if (path === "/__qa/reset" && method === "POST") {
42+
state = createFixture();
43+
return empty();
44+
}
45+
if (path === "/__qa/fail" && method === "POST") {
46+
const rule: unknown = await request.json();
47+
if (
48+
!rule ||
49+
typeof rule !== "object" ||
50+
!("path" in rule) ||
51+
typeof rule.path !== "string" ||
52+
!("method" in rule) ||
53+
typeof rule.method !== "string" ||
54+
!("count" in rule) ||
55+
!Number.isInteger(rule.count) ||
56+
Number(rule.count) < 0
57+
) {
58+
return Response.json(
59+
{ error: "Expected path, method and nonnegative count" },
60+
{ status: 400 },
61+
);
62+
}
63+
state.failure = {
64+
path: rule.path,
65+
method: rule.method,
66+
count: Number(rule.count),
67+
query: "query" in rule && typeof rule.query === "string" ? rule.query : undefined,
68+
};
69+
return empty();
70+
}
71+
const failure = state.failure;
72+
if (
73+
failure &&
74+
failure.count > 0 &&
75+
method === failure.method &&
76+
path.includes(failure.path) &&
77+
(!failure.query || url.search.includes(failure.query))
78+
) {
79+
failure.count--;
80+
return Response.json({ error: "Simulated fixture failure" }, { status: 503 });
81+
}
82+
const image = path.match(/^\/__qa\/(avatar|thumbnail)\/(\d+)\.svg$/);
83+
if (image) return fixtureImage(image[1], Number(image[2]));
84+
if (path === "/instance")
85+
return Response.json({
86+
guestAllowed: true,
87+
youtubeRemoteLoginEnabled: false,
88+
parentalControlsEnabled: false,
89+
});
90+
if (path === "/auth/register/status")
91+
return Response.json({ allowRegistration: false, bootstrapAvailable: false });
92+
if (path === "/auth/oidc/status")
93+
return Response.json({
94+
enabled: false,
95+
providerName: null,
96+
localLoginEnabled: true,
97+
autoRedirect: false,
98+
});
99+
if (path === "/auth/login" || path === "/auth/refresh")
100+
return Response.json({ accessToken: "local-fixture-only" });
101+
if (path === "/auth/me") return Response.json(me);
102+
if (path === "/profiles")
103+
return Response.json({
104+
profiles: [
105+
{ ...me, name: me.publicUsername, isActive: true, isDefault: true, lastUsedAt: 0 },
106+
],
107+
activeProfileId: me.id,
108+
defaultProfileId: me.id,
109+
});
110+
if (path === "/subscriptions/group-memberships") return Response.json(state.channels);
111+
if (path === "/subscriptions/groups" && method === "GET")
112+
return Response.json(groupCounts(state));
113+
if (path.startsWith("/subscriptions/groups") && method !== "GET")
114+
return writeGroup(state, request, path);
115+
const groupId = url.searchParams.get("groupId");
116+
if (
117+
path.startsWith("/subscriptions") &&
118+
groupId &&
119+
!state.groups.some((group) => group.id === groupId)
120+
) {
121+
return Response.json(
122+
{ error: "Group not found", code: "subscription_group_not_found" },
123+
{ status: 404 },
124+
);
125+
}
126+
if (path === "/subscriptions") return Response.json(filteredChannels(state, url));
127+
if (path === "/subscriptions/feed") {
128+
const channels = new Set(filteredChannels(state, url).map((channel) => channel.channelUrl));
129+
const videos = state.videos.filter((video) => channels.has(video.uploaderUrl));
130+
const offset = Math.max(0, Number(url.searchParams.get("cursor")) || 0);
131+
const limit = Math.max(1, Math.min(30, Number(url.searchParams.get("limit")) || 30));
132+
return Response.json({
133+
videos: videos.slice(offset, offset + limit),
134+
nextpage: offset + limit < videos.length ? String(offset + limit) : null,
135+
generation: 1,
136+
generatedAt: Date.now(),
137+
refreshing: false,
138+
});
139+
}
140+
if (path === "/settings")
141+
return Response.json({
142+
captionStyles: {},
143+
sponsorBlockCategoryActions: {},
144+
defaultService: 0,
145+
defaultLandingPage: "/",
146+
volume: 1,
147+
hideSubscriptionLiveStreams: false,
148+
deArrowEnabled: false,
149+
});
150+
if (path.startsWith("/streams"))
151+
return Response.json({ error: "Fixture videos cannot be played" }, { status: 404 });
152+
if (path.includes("notifications"))
153+
return Response.json({ items: [], unreadCount: 0, nextCursor: null });
154+
if (path === "/progress/batch") return Response.json([]);
155+
if (method === "POST" || method === "PUT") return empty();
156+
return Response.json([]);
157+
},
158+
});
159+
console.log(
160+
"Local subscription fixture: http://127.0.0.1:9876 (150 channels, 18 groups, 300 videos)",
161+
);

0 commit comments

Comments
 (0)