Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions apps/web/e2e/github.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { Locator } from '@playwright/test';

import type { Item } from '../src/github/github';
import { chipLooks } from './chips';
import { home, pulls, reviewRequests } from './demo';
import { expect, test } from './fixtures';
import { answerAllGitHub, answerGitHub, githubIssues, githubMine, githubReviews } from './github';
import { group } from './groups';
import { answerPulls, answerReviews, mine } from './pulls';

/** How a row and its label, title, first detail line and chip look; null for a part it lacks. */
function rowLooks(row: Locator) {
return row.evaluate((element) => {
const parts = ['.list-label', '.list-title', '.list-detail', '.chip'].map((part) => element.querySelector(part));
const looks = [
'display',
'grid-template-columns',
'padding-top',
'padding-left',
'column-gap',
'color',
'font-family',
'font-size',
'font-weight',
'line-height',
'letter-spacing',
'text-transform',
];
return [element, ...parts].map(
(part) => part && looks.map((name) => getComputedStyle(part).getPropertyValue(name))
);
});
}

test.beforeEach(async ({ page }) => {
await page.route('/api/home', (route) => route.fulfill({ json: home }));
});

test('the GitHub page lists my PRs, the review requests and my issues on GitHub', async ({ page }, testInfo) => {
await answerAllGitHub(page);
await page.goto('/github');

await expect(page).toHaveTitle('GitHub · Bakhyard');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('GitHub');
// The tab bar, the phone layout, has no tab for GitHub.
const [name, current] = testInfo.project.name === 'phone' ? ['More', 'true'] : ['GitHub', 'page'];
await expect(page.getByRole('link', { name, exact: true })).toHaveAttribute('aria-current', current);
await expect(page.getByRole('main').getByRole('heading', { level: 2 })).toHaveText([
'Your PRs',
'Review requests',
'Assigned issues',
]);
const lists = [
{ head: 'Your PRs', count: '2 PRs', snapshot: githubMine },
{ head: 'Review requests', count: '1 PR', snapshot: githubReviews },
{ head: 'Assigned issues', count: '2 issues', snapshot: githubIssues },
];
for (const { head, count, snapshot } of lists) {
const items = snapshot.items ?? [];
await expect(group(page, head).getByText(count)).toBeVisible();
const rows = group(page, head).getByRole('link');
await expect(rows).toHaveText(
items.map((item) => `#${item.id} ${item.title} ${item.repo}${item.draft ? ' Draft' : ''}`)
);
for (const [index, item] of items.entries()) {
await expect(rows.nth(index)).toHaveAttribute('href', item.url ?? '');
}
}
// A draft's chip keeps only its outline, as on the PRs page.
expect(await chipLooks(page.getByRole('main').locator('.chip'))).toEqual(['outlined']);
});

test('a row takes me to the issue on GitHub', async ({ page }) => {
await answerAllGitHub(page);
await page.route('https://github.com/**', (route) =>
route.fulfill({ contentType: 'text/html', body: '<title>acme/cli #88</title>' })
);
await page.goto('/github');
await group(page, 'Assigned issues').getByRole('link', { name: /^#88 / }).click();

await expect(page).toHaveURL('https://github.com/acme/cli/issues/88');
await expect(page).toHaveTitle('acme/cli #88');
});

test('the GitHub page loads its lists again each time it shows', async ({ page }) => {
let issues = githubIssues;
await answerGitHub(page, 'mine', githubMine);
await answerGitHub(page, 'reviews', githubReviews);
await page.route('/api/github/issues', (route) => route.fulfill({ json: issues }));
await page.goto('/github');
await expect(group(page, 'Assigned issues').getByText('2 issues')).toBeVisible();

await page.getByRole('link', { name: 'Home', exact: true }).click();
await expect(page.getByRole('heading', { level: 1, name: 'Good morning, Alex.' })).toBeVisible();
// The github service finds an issue closed while the page is away.
issues = { ...githubIssues, items: githubIssues.items?.slice(1) };
await page.goBack();

await expect(group(page, 'Assigned issues').getByText('1 issue')).toBeVisible();
await expect(group(page, 'Assigned issues').getByRole('link')).toHaveCount(1);
});

test("a draft PR's row looks as one on the PRs page", async ({ page }) => {
await answerPulls(page, pulls);
await answerReviews(page, reviewRequests);
await page.goto('/pulls');
const pull = await rowLooks(mine(page).getByRole('link', { name: /^#41 / }));
expect(pull).not.toContain(null);

await answerAllGitHub(page);
await page.goto('/github');
expect(await rowLooks(group(page, 'Your PRs').getByRole('link', { name: /^#12 / }))).toEqual(pull);
});

test('a long title, repo or failure wraps on a narrow screen', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 640 });
const issue: Item = {
id: 1234567,
title: `Read ${'x'.repeat(200)}`,
repo: `acme/${'y'.repeat(60)}`,
url: 'https://github.com/acme/web/issues/1234567',
draft: false,
};
const failure = `search issues: Get "https://api.github.com/search/issues?q=${'z'.repeat(200)}": EOF`;
await answerGitHub(page, 'mine', githubMine);
await answerGitHub(page, 'reviews', githubReviews);
await answerGitHub(page, 'issues', { items: [issue], searched: '2026-10-21T00:39:00Z', failure });
await page.goto('/github');

const issues = group(page, 'Assigned issues');
await expect(issues.getByRole('link')).toHaveText(`#${issue.id} ${issue.title} ${issue.repo}`);
await expect(issues.getByText(/^The last search failed/)).toHaveText(
`The last search failed: ${failure}. These issues are from 21 October at 09:39.`
);
const scroll = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(scroll).toBe(0);
});
76 changes: 76 additions & 0 deletions apps/web/e2e/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { Page } from '@playwright/test';

import type { List, Snapshot } from '../src/github/github';

/**
* A made-up user's open PRs on GitHub, the latest updated first, as the github service found them two minutes before
* the tests' time.
*/
export const githubMine: Snapshot = {
items: [
{
id: 214,
title: 'Add a dark theme to the docs site',
repo: 'acme/docs',
url: 'https://github.com/acme/docs/pull/214',
draft: false,
},
{
id: 12,
title: 'Share the shell config across machines',
repo: 'mira/dotfiles',
url: 'https://github.com/mira/dotfiles/pull/12',
draft: true,
},
],
searched: '2026-10-21T00:39:00Z',
};

/** The open PRs on GitHub that wait for a made-up user's review, as the github service found them with githubMine. */
export const githubReviews: Snapshot = {
items: [
{
id: 14,
title: 'Speed up the prompt in large repos',
repo: 'kai/dotfiles',
url: 'https://github.com/kai/dotfiles/pull/14',
draft: false,
},
],
searched: '2026-10-21T00:39:00Z',
};

/** The open issues on GitHub assigned to a made-up user, as the github service found them with githubMine. */
export const githubIssues: Snapshot = {
items: [
{
id: 88,
title: 'The CLI crashes on an empty config',
repo: 'acme/cli',
url: 'https://github.com/acme/cli/issues/88',
draft: false,
},
{
id: 5,
title: 'Document the release steps',
repo: 'acme/docs',
url: 'https://github.com/acme/docs/issues/5',
draft: false,
},
],
searched: '2026-10-21T00:39:00Z',
};

/** Answers the GitHub page's call for the list with the snapshot, or with the status. */
export async function answerGitHub(page: Page, list: List, snapshot: Snapshot | number) {
await page.route(`/api/github/${list}`, (route) =>
typeof snapshot === 'number' ? route.fulfill({ status: snapshot }) : route.fulfill({ json: snapshot })
);
}

/** Answers each of the GitHub page's calls with the made-up user's list. */
export async function answerAllGitHub(page: Page) {
await answerGitHub(page, 'mine', githubMine);
await answerGitHub(page, 'reviews', githubReviews);
await answerGitHub(page, 'issues', githubIssues);
}
11 changes: 11 additions & 0 deletions apps/web/e2e/page-status.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Page } from '@playwright/test';

import { home, tickets } from './demo';
import { expect, test } from './fixtures';
import { answerGitHub } from './github';
import { inkMuted } from './ink';
import { answerDismissals, answerTickets } from './jira';
import { answerPulls, answerReviews } from './pulls';
Expand All @@ -26,6 +27,16 @@ const lines: { name: string; path: string; says: string; answer: (page: Page) =>
await page.route('/api/time/report', (route) => route.fulfill({ status: 404 }));
},
},
{
name: 'GitHub',
path: '/github',
says: "GitHub isn't set up.",
answer: async (page) => {
for (const list of ['mine', 'reviews', 'issues'] as const) {
await answerGitHub(page, list, 404);
}
},
},
{
name: 'Games',
path: '/games',
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/App.pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ function answer(url: string): Response {
case '/api/pulls/mine':
case '/api/pulls/reviews':
return Response.json({ pulls: [], searched });
case '/api/github/mine':
case '/api/github/reviews':
case '/api/github/issues':
return Response.json({ items: [], searched });
case '/api/games/recent':
return Response.json({ games: [], searched });
case '/api/planner/plans':
Expand Down Expand Up @@ -42,12 +46,12 @@ afterEach(() => {
history.replaceState(null, '', '/');
});

test('shows the page at the path it opens on', () => {
test('shows the page at the path it opens on', async () => {
history.replaceState(null, '', '/github');
render(App);

expect(screen.getByRole('heading', { level: 1, name: 'GitHub' })).toBeInTheDocument();
expect(screen.getByText('Coming soon')).toBeInTheDocument();
expect(await screen.findByRole('heading', { level: 2, name: 'Your PRs' })).toBeInTheDocument();
expect(document.title).toBe('GitHub · Bakhyard');
expect(screen.getByRole('link', { name: 'GitHub', current: 'page' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'More', current: true })).toBeInTheDocument();
Expand All @@ -57,6 +61,7 @@ test.each([
['/todos', '1 todo on the list.', 'Todos · Bakhyard'],
['/pulls', 'No open pull requests.', 'Pull requests · Bakhyard'],
['/time', '7h 30m / 40h', 'Time · Bakhyard'],
['/github', 'No open issues.', 'GitHub · Bakhyard'],
['/games', 'No games played in the last 14 days.', 'Games · Bakhyard'],
['/planner', 'No plans yet.', 'Planner · Bakhyard'],
])('shows the page of the app at %s, with what it loads', async (path, loaded, title) => {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { tick } from 'svelte';

import GamesPage from './games/GamesPage.svelte';
import GitHubPage from './github/GitHubPage.svelte';
import HomePage from './home/HomePage.svelte';
import { type Feed, pollHome } from './home/poll';
import { unavailableSources } from './home/sources';
Expand Down Expand Up @@ -78,6 +79,8 @@ $effect(() =>
<PullsPage />
{:else if page.kind === 'time'}
<TimePage />
{:else if page.kind === 'github'}
<GitHubPage />
{:else if page.kind === 'games'}
<GamesPage {now} />
{:else if page.kind === 'planner'}
Expand Down
74 changes: 74 additions & 0 deletions apps/web/src/github/GitHubPage.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<script lang="ts">
import { onMount } from 'svelte';

import PageHead from '../shell/PageHead.svelte';
import PolledSection from '../shell/PolledSection.svelte';
import { type Item, type Items, type List, loadItems, type Snapshot } from './github';

const loading: Items = { kind: 'loading' };
let lists = $state.raw<Record<List, Items>>({ mine: loading, reviews: loading, issues: loading });

// The lists load at once, so none waits for another, as on the PRs page (decision 172).
onMount(() => {
for (const list of ['mine', 'reviews', 'issues'] as const) {
void loadItems(list).then((loaded) => {
lists = { ...lists, [list]: loaded };
});
}
});

const itemsOf = (snapshot: Snapshot) => snapshot.items;
</script>

{#snippet itemRow(item: Item)}
<!-- An item without a link to GitHub gets a row that isn't a link. -->
<svelte:element this={item.url ? 'a' : 'div'} class="list-row" href={item.url}>
<span class="list-label">#{item.id}</span>
<span class="list-text">
<span class="list-title">{item.title}</span>
<span class="list-detail">{item.repo}</span>
</span>
<span class="list-end">
{#if item.draft}
<span class="chip">Draft</span>
{/if}
</span>
</svelte:element>
{/snippet}

<PageHead>GitHub</PageHead>
<!-- Without GitHub, the github service answers 404 for every list. -->
{#if Object.values(lists).every((items) => items.kind === 'off')}
<p class="page-status">GitHub isn't set up.</p>
{:else}
<PolledSection
polled={lists.mine}
list={itemsOf}
title="Your"
accent="PRs"
noun="PR"
empty="No open pull requests."
failed="Couldn't load the pull requests."
row={itemRow}
/>
<PolledSection
polled={lists.reviews}
list={itemsOf}
title="Review"
accent="requests"
noun="PR"
empty="No pull requests to review."
failed="Couldn't load the review requests."
row={itemRow}
/>
<PolledSection
polled={lists.issues}
list={itemsOf}
title="Assigned"
accent="issues"
noun="issue"
empty="No open issues."
failed="Couldn't load the issues."
row={itemRow}
/>
{/if}
Loading
Loading