fix tests, improve coverage, make git more resilient to weird environments

AuthorKonata <konata@posteo.jp>
Date
Commitbcd74c13abb1b95dfb874c4a29943a645d492887
Parenta0b441f
3 files changed, 183 insertions(+), 16 deletions(-)
Msrc/services/git.ts
@@ -3,7 +3,18 @@ import { $ as _$ } from "bun";
33
44 import { REPOS_DIR } from "../constants.ts";
55
6-const $ = _$.env({ ...process.env, LC_ALL: "C", LANG: "C" });
6+const gitEnv = {
7+ ...process.env,
8+ LC_ALL: "C",
9+ LANG: "C",
10+ GIT_CONFIG_GLOBAL: "/dev/null",
11+ GIT_CONFIG_SYSTEM: "/dev/null",
12+ GIT_CONFIG_COUNT: "0",
13+ GIT_ASKPASS: "echo",
14+ GIT_TERMINAL_PROMPT: "0",
15+};
16+
17+const $ = _$.env(gitEnv);
718
819 // Per-repo mutex: prevents concurrent git write operations on the same repo
920 // (e.g. two patches being merged simultaneously, which would corrupt the index).
@@ -55,7 +66,6 @@ export async function archiveRepo(
5566 ): Promise<void> {
5667 const p = repoPath(repoName);
5768 const base = `${slug}-${commitHash.slice(0, 8)}`;
58- const env = { ...process.env, LC_ALL: "C", LANG: "C" };
5969
6070 const zip = Bun.spawn(
6171 [
@@ -67,7 +77,7 @@ export async function archiveRepo(
6777 `--output=${path.join(outDir, `${base}.zip`)}`,
6878 commitHash,
6979 ],
70- { signal, env },
80+ { signal, env: gitEnv },
7181 );
7282 if ((await zip.exited) !== 0) throw new Error("git archive (zip) failed");
7383
@@ -81,7 +91,7 @@ export async function archiveRepo(
8191 `--output=${path.join(outDir, `${base}.tar.gz`)}`,
8292 commitHash,
8393 ],
84- { signal, env },
94+ { signal, env: gitEnv },
8595 );
8696 if ((await tgz.exited) !== 0)
8797 throw new Error("git archive (tar.gz) failed");
@@ -89,11 +99,11 @@ export async function archiveRepo(
8999 try {
90100 const tar = Bun.spawn(
91101 ["git", "-C", p, "archive", "--format=tar", commitHash],
92- { signal, env, stdout: "pipe" },
102+ { signal, env: gitEnv, stdout: "pipe" },
93103 );
94104 const zst = Bun.spawn(
95105 ["zstd", "-o", path.join(outDir, `${base}.tar.zst`)],
96- { signal, env, stdin: tar.stdout },
106+ { signal, env: gitEnv, stdin: tar.stdout },
97107 );
98108 await Promise.all([tar.exited, zst.exited]);
99109 } catch {
Msrc/views/Settings.tsx
@@ -283,8 +283,7 @@ export function Settings({
283283 {passkeys.map((pk) => (
284284 <div class="passkey-item">
285285 <span class="passkey-date">
286- Added{" "}
287- {formatDate(pk.created_at)}
286+ Added {formatDate(pk.created_at)}
288287 </span>
289288 <form
290289 method="POST"
@@ -350,8 +349,7 @@ export function Settings({
350349 {key.fingerprint}
351350 </span>
352351 <span class="passkey-date">
353- Added{" "}
354- {formatDate(key.created_at)}
352+ Added {formatDate(key.created_at)}
355353 </span>
356354 </div>
357355 <form
Mtests/e2e.test.ts
@@ -454,6 +454,15 @@ describe('repos', () => {
454454 expect(await alicePage.locator('.repo-tab[href$="/settings"]').count()).toBe(0);
455455 } finally { await alicePage.close(); }
456456 });
457+
458+ test('create repository with invalid name shows error', async () => {
459+ const resp = await adminCtx.request.post(`${BASE}/new`, {
460+ form: { name: 'has spaces!', description: '', default_branch: 'main' },
461+ maxRedirects: 0,
462+ });
463+ expect(resp.status()).toBe(200);
464+ expect(await resp.text()).toContain('Invalid repository name');
465+ });
457466 });
458467
459468 // ─── Issues ───────────────────────────────────────────────────────────────────
@@ -461,6 +470,7 @@ describe('repos', () => {
461470 describe('issues', () => {
462471 let adminCtx: BrowserContext;
463472 let issueUrl: string;
473+ let completedIssueUrl: string;
464474
465475 beforeAll(async () => {
466476 adminCtx = await loggedInContext();
@@ -534,7 +544,7 @@ describe('issues', () => {
534544 const page = await adminCtx.newPage();
535545 try {
536546 await page.goto(issueUrl);
537- await page.click('.issue-detail-meta-actions button');
547+ await page.click('form[action*="/close"] button');
538548 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
539549 expect(await page.locator('.issue-badge').textContent()).toBe('closed');
540550 } finally { await page.close(); }
@@ -558,6 +568,67 @@ describe('issues', () => {
558568 expect(await page.locator('.issue-badge').textContent()).toBe('open');
559569 } finally { await page.close(); }
560570 });
571+
572+ test('completed button marks issue as completed', async () => {
573+ const page = await adminCtx.newPage();
574+ try {
575+ await page.goto(`${BASE}/my-repo/issues/new`);
576+ await page.fill('[name=title]', 'To be completed');
577+ await page.click('form[action$="/issues"] button[type=submit]');
578+ await page.waitForURL(/\/my-repo\/issues\/\d+/);
579+ completedIssueUrl = page.url();
580+ await page.click('form[action*="/complete"] button');
581+ await page.waitForURL(new RegExp(completedIssueUrl.replace(BASE, '')));
582+ expect(await page.locator('.issue-badge').textContent()).toBe('completed');
583+ } finally { await page.close(); }
584+ });
585+
586+ test('completed issue appears in completed list', async () => {
587+ const page = await adminCtx.newPage();
588+ try {
589+ await page.goto(`${BASE}/my-repo/issues?status=completed`);
590+ const titles = await page.locator('.issue-title').allTextContents();
591+ expect(titles.some(t => t.includes('To be completed'))).toBe(true);
592+ } finally { await page.close(); }
593+ });
594+
595+ test('non-admin cannot complete or close issue', async () => {
596+ const issueNum = issueUrl.split('/issues/')[1];
597+ const ctx = await browser.newContext();
598+ try {
599+ const completeResp = await ctx.request.post(
600+ `${BASE}/my-repo/issues/${issueNum}/complete`,
601+ { maxRedirects: 0 },
602+ );
603+ expect(completeResp.status()).toBe(302);
604+ expect(completeResp.headers()['location']).toContain('/login');
605+ } finally { await ctx.close(); }
606+ });
607+
608+ test('reacting with same emoji toggles it off', async () => {
609+ const page = await adminCtx.newPage();
610+ try {
611+ await page.goto(issueUrl);
612+ // Reaction was added by the earlier 'react to issue' test
613+ expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0);
614+ await page.locator('.reaction-btn').first().click();
615+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
616+ expect(await page.locator('.reaction-btn').count()).toBe(0);
617+ } finally { await page.close(); }
618+ });
619+
620+ test('react to issue comment', async () => {
621+ const page = await adminCtx.newPage();
622+ try {
623+ await page.goto(issueUrl);
624+ const commentItem = page.locator('.timeline-item:not(.timeline-item-new)')
625+ .filter({ hasText: 'A follow-up comment.' });
626+ await commentItem.locator('.reaction-add-btn').click();
627+ await commentItem.locator('.reaction-picker-btn').first().click();
628+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
629+ expect(await commentItem.locator('.reaction-btn').count()).toBeGreaterThan(0);
630+ } finally { await page.close(); }
631+ });
561632 });
562633
563634 // ─── Patches ──────────────────────────────────────────────────────────────────
@@ -607,6 +678,12 @@ describe('patches', () => {
607678
608679 beforeAll(async () => {
609680 adminCtx = await loggedInContext();
681+ // Git identity is required to submit patches
682+ await adminCtx.request.fetch(`${BASE}/settings/git-identity`, {
683+ method: 'POST',
684+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
685+ data: 'git_name=Admin+User&git_email=admin%40test.com',
686+ });
610687 });
611688
612689 afterAll(async () => { await adminCtx.close(); });
@@ -778,12 +855,17 @@ describe('patches', () => {
778855 const page = await adminCtx.newPage();
779856 try {
780857 await page.goto(conflictPatchUrl);
858+ // Edit title via title form
859+ await page.click('details.title-edit-details summary');
860+ await page.fill('.title-edit-form-area [name=title]', 'Edited Conflict Patch');
861+ await page.click('.title-edit-form-area [type=submit]');
862+ await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
863+ expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch');
864+ // Edit description via inline form
781865 await page.click('.timeline-author .inline-edit-details summary');
782- await page.fill('.inline-edit-form-area [name=title]', 'Edited Conflict Patch');
783866 await page.fill('.inline-edit-form-area [name=edit_description]', 'Updated desc');
784867 await page.click('.inline-edit-form-area [type=submit]');
785868 await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
786- expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch');
787869 } finally { await page.close(); }
788870 });
789871
@@ -817,6 +899,21 @@ describe('patches', () => {
817899 expect(await page.locator('.reaction-btn').first().textContent()).toMatch(/\d/);
818900 } finally { await page.close(); }
819901 });
902+
903+ test('admin can delete a patch', async () => {
904+ const page = await adminCtx.newPage();
905+ try {
906+ const patchNum = conflictPatchUrl.split('/patches/')[1];
907+ const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/delete`, {
908+ maxRedirects: 0,
909+ });
910+ expect(resp.status()).toBe(302);
911+ expect(resp.headers()['location']).toContain('/patches');
912+ // Patch should no longer be accessible
913+ const checkResp = await page.request.get(conflictPatchUrl);
914+ expect(checkResp.status()).toBe(404);
915+ } finally { await page.close(); }
916+ });
820917 });
821918
822919 // ─── Pagination ───────────────────────────────────────────────────────────────
@@ -826,6 +923,12 @@ describe('pagination', () => {
826923
827924 beforeAll(async () => {
828925 adminCtx = await loggedInContext();
926+ // Git identity is required to submit patches
927+ await adminCtx.request.fetch(`${BASE}/settings/git-identity`, {
928+ method: 'POST',
929+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
930+ data: 'git_name=Admin+User&git_email=admin%40test.com',
931+ });
829932
830933 // Create a repo dedicated to pagination testing
831934 const page = await adminCtx.newPage();
@@ -1792,6 +1895,33 @@ describe('settings', () => {
17921895 expect(resp.status()).toBe(302);
17931896 expect(resp.headers()['location']).toContain('error');
17941897 });
1898+
1899+ test('git identity: empty name shows error', async () => {
1900+ const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, {
1901+ form: { git_name: ' ', git_email: 'test@example.com' },
1902+ maxRedirects: 0,
1903+ });
1904+ expect(resp.status()).toBe(302);
1905+ expect(resp.headers()['location']).toContain('error=Git+name+is+required');
1906+ });
1907+
1908+ test('git identity: empty email shows error', async () => {
1909+ const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, {
1910+ form: { git_name: 'Test User', git_email: ' ' },
1911+ maxRedirects: 0,
1912+ });
1913+ expect(resp.status()).toBe(302);
1914+ expect(resp.headers()['location']).toContain('error=Git+email+is+required');
1915+ });
1916+
1917+ test('git identity: valid values redirect with success', async () => {
1918+ const resp = await aliceCtx.request.post(`${BASE}/settings/git-identity`, {
1919+ form: { git_name: 'Alice Smith', git_email: 'alice@example.com' },
1920+ maxRedirects: 0,
1921+ });
1922+ expect(resp.status()).toBe(302);
1923+ expect(resp.headers()['location']).toContain('success=git_identity');
1924+ });
17951925 });
17961926
17971927 // ─── Repository deletion ──────────────────────────────────────────────────────
@@ -1892,6 +2022,22 @@ describe('404 handling', () => {
18922022 expect(resp.status()).toBe(404);
18932023 } finally { await page.close(); }
18942024 });
2025+
2026+ test('non-existent patch returns 404', async () => {
2027+ const page = await adminCtx.newPage();
2028+ try {
2029+ const resp = await page.request.get(`${BASE}/my-repo/patches/99999`);
2030+ expect(resp.status()).toBe(404);
2031+ } finally { await page.close(); }
2032+ });
2033+
2034+ test('non-existent release returns 404', async () => {
2035+ const page = await adminCtx.newPage();
2036+ try {
2037+ const resp = await page.request.get(`${BASE}/my-repo/releases/99999`);
2038+ expect(resp.status()).toBe(404);
2039+ } finally { await page.close(); }
2040+ });
18952041 });
18962042
18972043 // ─── Issue editing and deletion ───────────────────────────────────────────────
@@ -1926,12 +2072,25 @@ describe('issue editing', () => {
19262072 const page = await adminCtx.newPage();
19272073 try {
19282074 await page.goto(issueUrl);
1929- await page.locator('.timeline-author .inline-edit-details').first().locator('summary').click();
1930- await page.fill('.inline-edit-form-area [name=title]', 'Edited issue title');
2075+ // Edit title via title form
2076+ await page.click('details.title-edit-details summary');
2077+ await page.fill('.title-edit-form-area [name=title]', 'Edited issue title');
2078+ await page.click('.title-edit-form-area [type=submit]');
2079+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
2080+ expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title');
2081+ // Edit body via inline form
2082+ await page.click('.timeline-author .inline-edit-details summary');
19312083 await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.');
19322084 await page.click('.inline-edit-form-area [type=submit]');
19332085 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
1934- expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title');
2086+ } finally { await page.close(); }
2087+ });
2088+
2089+ test('edited marker appears after editing', async () => {
2090+ const page = await adminCtx.newPage();
2091+ try {
2092+ await page.goto(issueUrl);
2093+ expect(await page.locator('time.edited-indicator').count()).toBeGreaterThan(0);
19352094 } finally { await page.close(); }
19362095 });
19372096