fix tests, improve coverage, make git more resilient to weird environments
Msrc/services/git.ts
| @@ -3,7 +3,18 @@ import { $ as _$ } from "bun"; | |||
|---|---|---|---|
| 3 | 3 | ||
| 4 | 4 | import { REPOS_DIR } from "../constants.ts"; | |
| 5 | 5 | ||
| 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); | |
| 7 | 18 | ||
| 8 | 19 | // Per-repo mutex: prevents concurrent git write operations on the same repo | |
| 9 | 20 | // (e.g. two patches being merged simultaneously, which would corrupt the index). | |
| @@ -55,7 +66,6 @@ export async function archiveRepo( | |||
|---|---|---|---|
| 55 | 66 | ): Promise<void> { | |
| 56 | 67 | const p = repoPath(repoName); | |
| 57 | 68 | const base = `${slug}-${commitHash.slice(0, 8)}`; | |
| 58 | - | const env = { ...process.env, LC_ALL: "C", LANG: "C" }; | |
| 59 | 69 | ||
| 60 | 70 | const zip = Bun.spawn( | |
| 61 | 71 | [ | |
| @@ -67,7 +77,7 @@ export async function archiveRepo( | |||
|---|---|---|---|
| 67 | 77 | `--output=${path.join(outDir, `${base}.zip`)}`, | |
| 68 | 78 | commitHash, | |
| 69 | 79 | ], | |
| 70 | - | { signal, env }, | |
| 80 | + | { signal, env: gitEnv }, | |
| 71 | 81 | ); | |
| 72 | 82 | if ((await zip.exited) !== 0) throw new Error("git archive (zip) failed"); | |
| 73 | 83 | ||
| @@ -81,7 +91,7 @@ export async function archiveRepo( | |||
|---|---|---|---|
| 81 | 91 | `--output=${path.join(outDir, `${base}.tar.gz`)}`, | |
| 82 | 92 | commitHash, | |
| 83 | 93 | ], | |
| 84 | - | { signal, env }, | |
| 94 | + | { signal, env: gitEnv }, | |
| 85 | 95 | ); | |
| 86 | 96 | if ((await tgz.exited) !== 0) | |
| 87 | 97 | throw new Error("git archive (tar.gz) failed"); | |
| @@ -89,11 +99,11 @@ export async function archiveRepo( | |||
|---|---|---|---|
| 89 | 99 | try { | |
| 90 | 100 | const tar = Bun.spawn( | |
| 91 | 101 | ["git", "-C", p, "archive", "--format=tar", commitHash], | |
| 92 | - | { signal, env, stdout: "pipe" }, | |
| 102 | + | { signal, env: gitEnv, stdout: "pipe" }, | |
| 93 | 103 | ); | |
| 94 | 104 | const zst = Bun.spawn( | |
| 95 | 105 | ["zstd", "-o", path.join(outDir, `${base}.tar.zst`)], | |
| 96 | - | { signal, env, stdin: tar.stdout }, | |
| 106 | + | { signal, env: gitEnv, stdin: tar.stdout }, | |
| 97 | 107 | ); | |
| 98 | 108 | await Promise.all([tar.exited, zst.exited]); | |
| 99 | 109 | } catch { | |
Msrc/views/Settings.tsx
| @@ -283,8 +283,7 @@ export function Settings({ | |||
|---|---|---|---|
| 283 | 283 | {passkeys.map((pk) => ( | |
| 284 | 284 | <div class="passkey-item"> | |
| 285 | 285 | <span class="passkey-date"> | |
| 286 | - | Added{" "} | |
| 287 | - | {formatDate(pk.created_at)} | |
| 286 | + | Added {formatDate(pk.created_at)} | |
| 288 | 287 | </span> | |
| 289 | 288 | <form | |
| 290 | 289 | method="POST" | |
| @@ -350,8 +349,7 @@ export function Settings({ | |||
|---|---|---|---|
| 350 | 349 | {key.fingerprint} | |
| 351 | 350 | </span> | |
| 352 | 351 | <span class="passkey-date"> | |
| 353 | - | Added{" "} | |
| 354 | - | {formatDate(key.created_at)} | |
| 352 | + | Added {formatDate(key.created_at)} | |
| 355 | 353 | </span> | |
| 356 | 354 | </div> | |
| 357 | 355 | <form | |
Mtests/e2e.test.ts
| @@ -454,6 +454,15 @@ describe('repos', () => { | |||
|---|---|---|---|
| 454 | 454 | expect(await alicePage.locator('.repo-tab[href$="/settings"]').count()).toBe(0); | |
| 455 | 455 | } finally { await alicePage.close(); } | |
| 456 | 456 | }); | |
| 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 | + | }); | |
| 457 | 466 | }); | |
| 458 | 467 | ||
| 459 | 468 | // ─── Issues ─────────────────────────────────────────────────────────────────── | |
| @@ -461,6 +470,7 @@ describe('repos', () => { | |||
|---|---|---|---|
| 461 | 470 | describe('issues', () => { | |
| 462 | 471 | let adminCtx: BrowserContext; | |
| 463 | 472 | let issueUrl: string; | |
| 473 | + | let completedIssueUrl: string; | |
| 464 | 474 | ||
| 465 | 475 | beforeAll(async () => { | |
| 466 | 476 | adminCtx = await loggedInContext(); | |
| @@ -534,7 +544,7 @@ describe('issues', () => { | |||
|---|---|---|---|
| 534 | 544 | const page = await adminCtx.newPage(); | |
| 535 | 545 | try { | |
| 536 | 546 | await page.goto(issueUrl); | |
| 537 | - | await page.click('.issue-detail-meta-actions button'); | |
| 547 | + | await page.click('form[action*="/close"] button'); | |
| 538 | 548 | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 539 | 549 | expect(await page.locator('.issue-badge').textContent()).toBe('closed'); | |
| 540 | 550 | } finally { await page.close(); } | |
| @@ -558,6 +568,67 @@ describe('issues', () => { | |||
|---|---|---|---|
| 558 | 568 | expect(await page.locator('.issue-badge').textContent()).toBe('open'); | |
| 559 | 569 | } finally { await page.close(); } | |
| 560 | 570 | }); | |
| 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 | + | }); | |
| 561 | 632 | }); | |
| 562 | 633 | ||
| 563 | 634 | // ─── Patches ────────────────────────────────────────────────────────────────── | |
| @@ -607,6 +678,12 @@ describe('patches', () => { | |||
|---|---|---|---|
| 607 | 678 | ||
| 608 | 679 | beforeAll(async () => { | |
| 609 | 680 | 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 | + | }); | |
| 610 | 687 | }); | |
| 611 | 688 | ||
| 612 | 689 | afterAll(async () => { await adminCtx.close(); }); | |
| @@ -778,12 +855,17 @@ describe('patches', () => { | |||
|---|---|---|---|
| 778 | 855 | const page = await adminCtx.newPage(); | |
| 779 | 856 | try { | |
| 780 | 857 | 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 | |
| 781 | 865 | await page.click('.timeline-author .inline-edit-details summary'); | |
| 782 | - | await page.fill('.inline-edit-form-area [name=title]', 'Edited Conflict Patch'); | |
| 783 | 866 | await page.fill('.inline-edit-form-area [name=edit_description]', 'Updated desc'); | |
| 784 | 867 | await page.click('.inline-edit-form-area [type=submit]'); | |
| 785 | 868 | await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, ''))); | |
| 786 | - | expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch'); | |
| 787 | 869 | } finally { await page.close(); } | |
| 788 | 870 | }); | |
| 789 | 871 | ||
| @@ -817,6 +899,21 @@ describe('patches', () => { | |||
|---|---|---|---|
| 817 | 899 | expect(await page.locator('.reaction-btn').first().textContent()).toMatch(/\d/); | |
| 818 | 900 | } finally { await page.close(); } | |
| 819 | 901 | }); | |
| 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 | + | }); | |
| 820 | 917 | }); | |
| 821 | 918 | ||
| 822 | 919 | // ─── Pagination ─────────────────────────────────────────────────────────────── | |
| @@ -826,6 +923,12 @@ describe('pagination', () => { | |||
|---|---|---|---|
| 826 | 923 | ||
| 827 | 924 | beforeAll(async () => { | |
| 828 | 925 | 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 | + | }); | |
| 829 | 932 | ||
| 830 | 933 | // Create a repo dedicated to pagination testing | |
| 831 | 934 | const page = await adminCtx.newPage(); | |
| @@ -1792,6 +1895,33 @@ describe('settings', () => { | |||
|---|---|---|---|
| 1792 | 1895 | expect(resp.status()).toBe(302); | |
| 1793 | 1896 | expect(resp.headers()['location']).toContain('error'); | |
| 1794 | 1897 | }); | |
| 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 | + | }); | |
| 1795 | 1925 | }); | |
| 1796 | 1926 | ||
| 1797 | 1927 | // ─── Repository deletion ────────────────────────────────────────────────────── | |
| @@ -1892,6 +2022,22 @@ describe('404 handling', () => { | |||
|---|---|---|---|
| 1892 | 2022 | expect(resp.status()).toBe(404); | |
| 1893 | 2023 | } finally { await page.close(); } | |
| 1894 | 2024 | }); | |
| 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 | + | }); | |
| 1895 | 2041 | }); | |
| 1896 | 2042 | ||
| 1897 | 2043 | // ─── Issue editing and deletion ─────────────────────────────────────────────── | |
| @@ -1926,12 +2072,25 @@ describe('issue editing', () => { | |||
|---|---|---|---|
| 1926 | 2072 | const page = await adminCtx.newPage(); | |
| 1927 | 2073 | try { | |
| 1928 | 2074 | 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'); | |
| 1931 | 2083 | await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.'); | |
| 1932 | 2084 | await page.click('.inline-edit-form-area [type=submit]'); | |
| 1933 | 2085 | 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); | |
| 1935 | 2094 | } finally { await page.close(); } | |
| 1936 | 2095 | }); | |
| 1937 | 2096 | ||