// What people do on the pages, in order, as bob and then alice: each // write shown at once, kept in the PDS, and shown again once the page is // read anew, from the index. import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, test } from '@playwright/test' import { world, who, url, records, go, anew, knotless, delegate, status, switchTo, comment, editor, choose, logIn, loggedInAs, } from './pages.mjs' test.describe.configure({ mode: 'serial' }) let page test.beforeAll(async ({ browser }) => { page = await browser.newPage() }) test.afterAll(async () => { await page.close() }) test('logging in through the PDS', async () => { await go(page, '/alice.test/tally') await logIn(page, 'bob') await expect.poll(() => loggedInAs(page)).toBe('bob.test') }) test('starring and unstarring', async () => { await go(page, '/alice.test/notes') const star = page.getByRole('button', { name: 'Star', exact: true }) await star.click() const starred = page.getByRole('button', { name: 'Starred' }) await expect(starred).toHaveAttribute('aria-pressed', 'true') await expect(page.locator('.star .count')).toHaveText('1') expect( (await records('bob', 'dev.knotless.star')).map((r) => r.value.subject?.uri ?? r.value.subject), ).toContain(`at://${who.alice.did}/dev.knotless.repo/notes`) await anew(page, (expect) => expect(page.getByRole('button', { name: 'Starred' })).toBeVisible()) await page.getByRole('button', { name: 'Starred' }).click() await expect(page.getByRole('button', { name: 'Star', exact: true })).toHaveAttribute( 'aria-pressed', 'false', ) await expect(page.locator('.star .count')).toHaveText('0') await anew(page, (expect) => expect(page.getByRole('button', { name: 'Star', exact: true })).toBeVisible(), ) }) test('following and unfollowing', async () => { await go(page, '/textkit.test') await page.getByRole('button', { name: 'Follow', exact: true }).click() await expect(page.getByRole('button', { name: 'Unfollow' })).toHaveAttribute( 'aria-pressed', 'true', ) const follows = await records('bob', 'dev.knotless.follow') expect(follows.map((r) => r.value.subject)).toContain(who.textkit.did) await anew(page, (expect) => expect(page.getByRole('button', { name: 'Unfollow' })).toBeVisible()) await page.getByRole('button', { name: 'Unfollow' }).click() await expect(page.getByRole('button', { name: 'Follow', exact: true })).toHaveAttribute( 'aria-pressed', 'false', ) await anew(page, (expect) => expect(page.getByRole('button', { name: 'Follow', exact: true })).toBeVisible(), ) }) let opened test('opening an issue', async () => { await go(page, '/alice.test/tally/issues/new') await page.getByRole('textbox', { name: 'Title' }).fill('Counts a trailing newline as a line') await page .getByRole('textbox', { name: 'Description' }) .fill('`printf "a\\n" | tally` says 1, as `wc` does. Is that meant?') await page.getByRole('button', { name: 'Open issue' }).click() await page.waitForURL(/\/alice\.test\/tally\/issue\//) opened = page.url() await expect( page.getByRole('heading', { name: 'Counts a trailing newline as a line' }), ).toBeVisible() const issues = await records('bob', 'dev.knotless.issue') expect(issues.map((r) => r.value.title)).toContain('Counts a trailing newline as a line') await go(page, '/alice.test/tally/issues') await anew(page, (expect) => expect(page.getByRole('link', { name: 'Counts a trailing newline as a line' })).toBeVisible(), ) }) test('commenting', async () => { await go(page, opened) await page .getByPlaceholder('Leave a comment') .fill('Ran it against `wc -l` on a few files: they agree.') await page.getByRole('button', { name: 'Comment', exact: true }).click() await expect(page.getByText('Ran it against wc -l on a few files: they agree.')).toBeVisible() await expect(page.getByPlaceholder('Leave a comment')).toHaveValue('') const comments = await records('bob', 'dev.knotless.comment') expect(comments.map((r) => r.value.body)).toContain( 'Ran it against `wc -l` on a few files: they agree.', ) await anew(page, (expect) => expect(page.getByText('Ran it against wc -l on a few files: they agree.')).toBeVisible(), ) }) test('watching a repository, and unsubscribing from an issue', async () => { await go(page, '/alice.test/notes') const watch = page.locator('.watch-menu > summary') await expect(watch).toHaveText('Watch') await watch.click() await page.getByRole('button', { name: /^All activity/ }).click() await expect(watch).toHaveText('Watching') await anew(page, (expect) => expect(page.locator('.watch-menu > summary')).toHaveText('Watching')) await page.locator('.watch-menu > summary').click() await page.getByRole('button', { name: /^Participating/ }).click() await expect(page.locator('.watch-menu > summary')).toHaveText('Watch') await anew(page, (expect) => expect(page.locator('.watch-menu > summary')).toHaveText('Watch')) // Its author hears of the issue, until it unsubscribes, which mutes it. await go(page, opened) await page.getByRole('button', { name: 'Unsubscribe' }).click() await expect(page.getByRole('button', { name: 'Subscribe', exact: true })).toHaveAttribute( 'aria-pressed', 'false', ) await anew(page, (expect) => expect(page.getByRole('button', { name: 'Subscribe', exact: true })).toHaveAttribute( 'title', 'You muted this issue.', ), ) await page.getByRole('button', { name: 'Subscribe', exact: true }).click() await expect(page.getByRole('button', { name: 'Unsubscribe' })).toHaveAttribute( 'aria-pressed', 'true', ) await anew(page, (expect) => expect(page.getByRole('button', { name: 'Unsubscribe' })).toHaveAttribute( 'title', 'You hear of this issue as its author.', ), ) }) test('reacting', async () => { await go(page, opened) const thread = page.locator('.reactions').first() await thread.locator('summary[aria-label="Add reaction"]').click() await thread.getByRole('button', { name: 'Eyes' }).click() await expect(thread.getByRole('button', { name: 'Eyes: 1' })).toHaveAttribute( 'aria-pressed', 'true', ) expect((await records('bob', 'dev.knotless.reaction')).map((r) => r.value.content)).toContain( 'eyes', ) await anew(page, (expect) => expect( page.locator('.reactions').first().getByRole('button', { name: 'Eyes: 1' }), ).toBeVisible(), ) await page.locator('.reactions').first().getByRole('button', { name: 'Eyes: 1' }).click() await expect( page.locator('.reactions').first().getByRole('button', { name: /^Eyes/ }), ).toHaveCount(0) expect(await records('bob', 'dev.knotless.reaction')).toEqual([]) }) test('closing and reopening', async () => { await go(page, opened) await page.getByRole('button', { name: 'Close issue' }).click() await expect(page.getByRole('button', { name: 'Reopen issue' })).toBeVisible() await anew(page, (expect) => expect(page.getByRole('button', { name: 'Reopen issue' })).toBeVisible(), ) await page.getByRole('button', { name: 'Reopen issue' }).click() await expect(page.getByRole('button', { name: 'Close issue' })).toBeVisible() await anew(page, (expect) => expect(page.getByRole('button', { name: 'Close issue' })).toBeVisible(), ) }) test('forking', async () => { await go(page, '/alice.test/notes') await page.locator('.fork-menu summary').click() await expect(page.getByLabel('Name')).toHaveValue('notes') await page.getByRole('button', { name: 'Create fork' }).click() await page.waitForURL(url('/bob.test/notes')) await expect(page.getByText('Forked from')).toBeVisible() expect((await records('bob', 'dev.knotless.repo')).map((r) => r.uri.split('/').pop())).toContain( 'notes', ) // Every branch came with it. await go(page, '/bob.test/notes/branches') await anew(page, (expect) => expect(page.getByRole('link', { name: 'reading', exact: true })).toBeVisible(), ) }) test('merging a pull', async () => { // The fork's pull its queue ejected: merged by hand, by bob, its owner, // with the queue turned off for main. The rule is back once it merged. const fork = 'knotless://bob.test/tally' knotless('bob', 'branch', 'protect', fork, 'main', '--undo') await go(page, world.views['pull-ejected']) await anew(page, (expect) => expect(page.locator('.merge-box').getByRole('button', { name: 'Merge pull' })).toBeVisible(), ) await page.locator('.merge-box').getByRole('button', { name: 'Merge pull' }).click() // The page asks its answers again once merged, and the thread says so, // with a way to revert it, in place of the box: the box's own word for // it shows only until then, which can be no time at all. A merge is // several writes to the PDS, as compacting is, so it gets as long. const said = page.locator('.event', { hasText: 'Merged as' }) await expect(said.getByText(/^[0-9a-f]{7}$/)).toBeVisible({ timeout: 30_000 }) const sha = await said.getByText(/^[0-9a-f]{7}$/).innerText() await expect(page.getByRole('button', { name: 'Revert' })).toBeVisible() await anew(page, (expect) => expect(page.locator('.event', { hasText: 'Merged as' }).getByText(sha)).toBeVisible(), ) await go(page, '/bob.test/tally/commits/main') await anew(page, (expect) => expect(page.getByText('Print a total for many files').first()).toBeVisible(), ) knotless('bob', 'branch', 'protect', fork, 'main', '--merges', '--queue') }) test('leaving the merge queue and joining it again', async () => { await go(page, world.views['pull-queued']) const box = page.locator('.merge-box') await box.getByRole('button', { name: 'Leave queue' }).click() await expect(box.getByRole('button', { name: 'Add to merge queue' })).toBeVisible() await anew(page, (expect) => expect( page.locator('.merge-box').getByRole('button', { name: 'Add to merge queue' }), ).toBeVisible(), ) await page.locator('.merge-box').getByRole('button', { name: 'Add to merge queue' }).click() await expect( page.locator('.merge-box').getByRole('button', { name: 'Leave queue' }), ).toBeVisible() await anew(page, (expect) => expect(page.locator('.merge-box').getByRole('button', { name: 'Leave queue' })).toBeVisible(), ) }) test('adding a stack to the merge queue', async () => { const fork = 'knotless://bob.test/tally' const layer = (branch, on) => { knotless('bob', 'branch', 'create', fork, branch, on) knotless( 'bob', 'repo', 'commit', fork, '-b', branch, '-m', `Add ${branch}`, '--put', `${branch}.txt`, '/dev/null', ) return knotless('bob', 'pull', 'create', fork, on, fork, branch, `Layer ${branch}`) } const low = layer('stack-low', 'main') const high = layer('stack-high', 'stack-low') const page_of = (uri) => `/bob.test/tally/pull/${who.bob.did}/${uri.split('/').pop()}` const box = () => page.locator('.merge-box') const queued = async () => (await records('bob', 'dev.knotless.queue')) .filter((r) => r.value.state === 'queued') .map((r) => r.value.subject?.uri ?? r.value.subject) await go(page, page_of(high)) await anew(page, (expect) => expect(box().getByRole('button', { name: 'Add all 2 to merge queue' })).toBeEnabled(), ) await box().getByRole('button', { name: 'Add all 2 to merge queue' }).click() await expect(box().getByRole('button', { name: 'Leave queue' })).toBeVisible() expect(await queued()).toEqual(expect.arrayContaining([low, high])) await anew(page, (expect) => expect( page.locator('.merge-box').getByText('Waits for the pull below it to land'), ).toBeVisible(), ) expect(knotless('bob', 'queue', 'list', fork)).toContain(`stacked\t${high}`) // The lowest leaves, and the one waiting on it goes too. await go(page, page_of(low)) await anew(page, (expect) => expect(box().getByRole('button', { name: 'Leave queue' })).toBeVisible(), ) await box().getByRole('button', { name: 'Leave queue' }).click() await expect(box().getByRole('button', { name: 'Add to merge queue' })).toBeVisible() await expect.poll(() => knotless('bob', 'queue', 'list', fork)).not.toContain(high) knotless('bob', 'pull', 'close', fork, high) knotless('bob', 'pull', 'close', fork, low) }) test('reverting a merged pull', async () => { await go(page, world.views['pull-ejected']) await page.getByRole('button', { name: 'Revert' }).click() // On to the pull that brings the revert in. await page.waitForURL( (u) => u.href !== url(world.views['pull-ejected']) && /\/pull\//.test(u.pathname), ) await expect(page.getByRole('heading', { name: /^Revert/ })).toBeVisible() await anew(page, (expect) => expect(page.getByRole('heading', { name: /^Revert/ })).toBeVisible()) }) let proposed test('opening a pull from a fork', async () => { // bob's fork's main has what alice's lacks: its merges and its workflow. await go(page, '/alice.test/tally/pulls/new') await page.getByLabel('From').selectOption({ label: 'bob.test/tally (yours)' }) await page.getByLabel('Branch').selectOption('main') await expect(page.locator('.compared')).toContainText('Release from tags when asked') // The form unfolds once asked for. await expect(page.getByRole('textbox', { name: 'Title' })).toHaveCount(0) await page.locator('.compare-ready').getByRole('button', { name: 'Open pull' }).click() await page .getByRole('textbox', { name: 'Title' }) .fill('Bring in the fork: usage, totals and releases') // Asking the owner to review it as it opens, from Add beside the buttons. await page.locator('.open-form .add-menu summary').click() await page.locator('.open-form .add-menu').getByRole('button', { name: 'alice.test' }).click() await expect(page.locator('.open-form .reviewers-fact')).toContainText('1 reviewer') // Opened as a draft. await page.getByRole('button', { name: 'Open as draft' }).click() await page.waitForURL(/\/alice\.test\/tally\/pull\//) proposed = page.url() await expect( page.getByRole('heading', { name: 'Bring in the fork: usage, totals and releases' }), ).toBeVisible() await expect(page.locator('.item-meta .pill')).toHaveText('Draft') const made = await fork() expect(made?.value.reviewers?.map((r) => r.did)).toContain(who.alice.did) expect(made?.value.draft).toBe(true) // Asked no longer, then again, once it is open, from under its title. await page.locator('.reviewers-fact summary').click() await page.getByRole('button', { name: 'Stop asking alice.test' }).click() await expect(page.locator('.reviewers-fact')).toHaveCount(0) await expect.poll(async () => (await fork())?.value.reviewers ?? []).toHaveLength(0) await page.locator('.add-menu summary').click() await page.locator('.add-menu').getByRole('button', { name: 'alice.test' }).click() await expect(page.locator('.reviewers-fact')).toContainText('1 reviewer') await expect .poll(async () => (await fork())?.value.reviewers?.map((r) => r.did)) .toContain(who.alice.did) }) /** bob's pull from his fork, as his PDS holds it. */ const fork = async () => (await records('bob', 'dev.knotless.pull')).find( (r) => r.value.title === 'Bring in the fork: usage, totals and releases', ) test('retargeting a pull', async () => { await go(page, proposed) await page.locator('.merge-box .more-menu summary').click() await page.getByRole('button', { name: 'Retarget…' }).click() const dialog = page.getByRole('dialog', { name: 'Retarget this pull' }) await dialog.getByLabel('Head branch').fill('words') await dialog.getByRole('button', { name: 'Retarget' }).click() await expect(dialog).toBeHidden() await expect(page.getByRole('alert')).toHaveCount(0) await anew(page, (expect) => expect(page.getByText(/wants to merge into main from bob\.test\/tally:words$/)).toBeVisible(), ) }) test('marking a draft ready, and making it a draft again', async () => { await go(page, proposed) const box = page.locator('.merge-box') await box.getByRole('button', { name: 'Ready for review' }).click() await expect(page.locator('.item-meta .pill')).toHaveText('Open') expect((await fork())?.value.draft).toBeFalsy() await box.locator('.more-menu summary').click() await box.getByRole('button', { name: 'Convert to draft' }).click() await expect(page.locator('.item-meta .pill')).toHaveText('Draft') await expect(box.getByText('This pull is still a draft')).toBeVisible() expect((await fork())?.value.draft).toBe(true) // Ready once more, for what follows. await box.getByRole('button', { name: 'Ready for review' }).click() await anew(page, (expect) => expect(page.locator('.item-meta .pill')).toHaveText('Open')) }) test('rerunning a check', async () => { // bob runs his fork's checks: the failed test, asked for again. await go(page, world.views.check) await page.locator('.rerun-menu summary').click() await page.getByRole('button', { name: 'Re-run test' }).click() await expect(page.locator('.rerun-menu summary')).toHaveText(/Asked for test again/) expect((await records('bob', 'dev.knotless.rerun')).map((r) => r.value.workflow)).toContain( 'test', ) }) test('running a workflow when asked', async () => { await go(page, '/bob.test/tally/checks') await page.getByRole('button', { name: 'Run workflow' }).click() const dialog = page.getByRole('dialog', { name: 'Run a workflow' }) await dialog.getByLabel('Branch or tag').fill('v0.1.0') await dialog.getByLabel('Branch or tag').press('Tab') await expect(dialog.getByLabel('Workflow')).toHaveValue('release') await dialog.getByRole('button', { name: 'Add a variable' }).click() await dialog.getByRole('textbox', { name: 'Name' }).fill('CHANNEL') await dialog.getByRole('textbox', { name: 'Value' }).fill('beta') await dialog.getByRole('button', { name: 'Run workflow' }).click() await expect(dialog.getByRole('status')).toHaveText(/^Asked for release/) const asked = (await records('bob', 'dev.knotless.rerun')).find( (r) => r.value.workflow === 'release', ) expect(asked?.value).toMatchObject({ ref: 'refs/tags/v0.1.0' }) }) test('editing what was written', async () => { await go(page, opened) // The issue's own text. await choose(page, 'Is that meant?', 'Edit') await editor(page, 'Update issue').fill( '`printf "a\\n" | tally` says 1, as `wc -l` does. Is that meant, for a file with no newline at its end?', ) await page.getByRole('button', { name: 'Update issue' }).click() await expect(comment(page, 'for a file with no newline at its end?')).toBeVisible() // And a comment, which then says it was edited. await choose(page, 'they agree.', 'Edit') await editor(page, 'Update comment').fill( 'Ran it against `wc -l` on a few files: they agree, empty ones too.', ) await page.getByRole('button', { name: 'Update comment' }).click() await expect(comment(page, 'empty ones too.')).toContainText(', edited') await anew(page, async (expect) => { await expect(comment(page, 'for a file with no newline at its end?')).toBeVisible() await expect(comment(page, 'empty ones too.')).toContainText(', edited') }) }) test('ticking a task in what was written', async () => { await go(page, opened) await choose(page, 'for a file with no newline at its end?', 'Edit') await editor(page, 'Update issue').fill( '`printf "a\\n" | tally` says 1, as `wc -l` does. Is that meant, for a file with no newline at its end?\n\n- [ ] Try an empty file\n- [ ] Try a file with no newline', ) await page.getByRole('button', { name: 'Update issue' }).click() const box = comment(page, 'for a file with no newline at its end?').getByRole('checkbox').nth(1) await expect(box).toBeEnabled() await box.check() await expect(box).toBeChecked() // Its text ticks it too, at once, while the first is still saving. await comment(page, 'for a file with no newline at its end?') .getByText('Try an empty file') .click() await expect( comment(page, 'for a file with no newline at its end?').getByRole('checkbox').nth(0), ).toBeChecked() await expect .poll(async () => (await records('bob', 'dev.knotless.issue')).map((r) => r.value.body).join('\n'), ) .toContain('- [x] Try an empty file\n- [x] Try a file with no newline') await anew(page, (expect) => expect( comment(page, 'for a file with no newline at its end?').getByRole('checkbox').nth(0), ).toBeChecked(), ) }) test('deleting a comment', async () => { await go(page, opened) await page.getByPlaceholder('Leave a comment').fill('Said twice, by mistake.') await page.getByRole('button', { name: 'Comment', exact: true }).click() await expect(comment(page, 'Said twice, by mistake.')).toBeVisible() page.once('dialog', (d) => d.accept()) await choose(page, 'Said twice, by mistake.', 'Delete') await expect(comment(page, 'Said twice, by mistake.')).toHaveCount(0) expect((await records('bob', 'dev.knotless.comment')).map((r) => r.value.body)).not.toContain( 'Said twice, by mistake.', ) await anew(page, (expect) => expect(comment(page, 'Said twice, by mistake.')).toHaveCount(0)) }) test('reporting a comment', async () => { await go(page, world.views.issue) await choose(page, 'Confirmed.', 'Report this comment') const dialog = page.getByRole('dialog', { name: /^Report/ }) await dialog.getByLabel('More, for the moderators').fill('Testing the report flow.') await dialog.getByRole('button', { name: 'Send report' }).click() await expect(dialog.getByRole('button', { name: 'Send report' })).toHaveCount(0) }) test('creating a repository', async () => { await go(page, '/new') await page.getByLabel('Repository name').fill('wordlist') await page.getByLabel(/^Description/).fill('Words to count with') await page.getByRole('button', { name: 'Create repository' }).click() await page.waitForURL(url('/bob.test/wordlist')) await expect(page.getByText('Words to count with')).toBeVisible() expect((await records('bob', 'dev.knotless.repo')).map((r) => r.uri.split('/').pop())).toContain( 'wordlist', ) await go(page, '/bob.test?tab=repos') await anew(page, (expect) => expect(page.getByRole('link', { name: 'wordlist' }).first()).toBeVisible(), ) }) test('updating a profile', async () => { await go(page, '/settings/profile') await page.getByLabel('Name').fill('Bob T.') await page.getByLabel('Bio').fill('Fixes the edges of tally.') await page.getByRole('button', { name: 'Update profile' }).click() // Saved, on to the profile it changed. await page.waitForURL(url('/bob.test')) await anew(page, async (expect) => { await expect(page.getByText('Bob T.').first()).toBeVisible() await expect(page.getByText('Fixes the edges of tally.')).toBeVisible() }) }) test('choosing notifications and a theme', async () => { await go(page, '/settings/notifications') // Each choice is saved as it is made, and shown as saved. await page.getByLabel('Only accounts I follow').click() await expect(page.getByLabel('Only accounts I follow')).toBeChecked() await anew(page, (expect) => expect(page.getByLabel('Only accounts I follow')).toBeChecked()) await go(page, '/settings/appearance') // One theme always, rather than one by day and one at night as the system says. await page.getByLabel('One theme').click() await page.getByLabel('Dark', { exact: true }).click() await anew(page, (expect) => expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')) await page.getByLabel('Sync with system').click() await anew(page, (expect) => expect(page.locator('html')).not.toHaveAttribute('data-theme', 'dark'), ) }) test('starting an organization', async () => { // bob's own account becomes one: he delegates writes to this server, at // his PDS, in a page of its own, then invites alice, as an admin. await go(page, '/bob.test') await page.getByLabel('Your account').click() await page.getByRole('button', { name: 'Start an organization' }).click() await page .getByRole('dialog', { name: 'Start an organization' }) .getByRole('button', { name: 'Invite people' }) .click() await page.waitForURL(/\/bob\.test\?tab=members$/) await delegate(page, 'bob') const invite = page.locator('.org-invite') await invite.getByLabel('Handle').fill('alice.test') await invite.getByLabel('Role').selectOption('admin') await invite.getByRole('button', { name: 'Invite' }).click() await expect( page.getByText('bob.test is now an organization. Invited alice.test as an admin.'), ).toBeVisible() // Listed among the invitations, below the members, none of whom has joined. const invited = async (page) => { await expect(page.getByRole('heading', { name: 'Invitations' })).toBeVisible() await expect(page.getByText(/^Invited [A-Z][a-z]{2} \d+, \d{4}$/)).toBeVisible() } await invited(page) expect((await records('bob', 'dev.knotless.orgMember')).map((r) => r.value.member)).toContain( who.alice.did, ) await anew(page, (expect) => invited(page)) }) test('logging in to a second account and labelling as the owner', async () => { await go(page, opened) // bob, not the owner, has no labels to apply. await expect(page.locator('.label-menu')).toHaveCount(0) await logIn(page, 'alice', async () => { await page.getByLabel('Your account').click() await page.getByRole('button', { name: 'Add account' }).click() }) await expect.poll(() => loggedInAs(page)).toBe('alice.test') // With none yet, a label is put on from Add, then shows under the title. await page.locator('.add-menu summary').click() await page.locator('.add-menu').getByRole('button', { name: 'kind/bug' }).click() await expect(page.locator('.item-facts .labels')).toContainText('kind/bug') await page.locator('.item-facts .label-menu summary').click() await expect( page.locator('.label-menu').getByRole('button', { name: 'kind/bug' }), ).toHaveAttribute('aria-pressed', 'true') await anew(page, (expect) => expect(page.locator('.labels').getByText('kind/bug')).toBeVisible()) // Then assigned from Add to its author, kept in alice's assignment record. await page.locator('.add-menu summary').click() await page.locator('.add-menu').getByRole('button', { name: 'bob.test' }).click() await expect(page.locator('.assignees-fact')).toContainText('Assigned to bob.test') const assigned = async () => (await records('alice', 'dev.knotless.assignment')) .flatMap((r) => r.value.assignees ?? []) .map((a) => a.did) await expect.poll(assigned).toContain(who.bob.did) await anew(page, (expect) => expect(page.locator('.assignees-fact')).toContainText('Assigned to bob.test'), ) }) test('opening an issue with a label, as the owner', async () => { await go(page, '/alice.test/tally/issues/new') await page.getByRole('textbox', { name: 'Title' }).fill('Say what counts as a word in the README') await page.locator('.open-form .add-menu summary').click() await page.locator('.open-form .add-menu').getByRole('button', { name: 'kind/bug' }).click() await expect(page.locator('.open-form .labels')).toContainText('kind/bug') await page.getByRole('button', { name: 'Open issue' }).click() await page.waitForURL(/\/alice\.test\/tally\/issue\//) await expect( page.getByRole('heading', { name: 'Say what counts as a word in the README' }), ).toBeVisible() await anew(page, (expect) => expect(page.locator('.item-meta .labels').getByText('kind/bug')).toBeVisible(), ) }) test('seeing notifications', async () => { await go(page, '/alice.test/tally') await expect(page.getByRole('link', { name: /^Notifications, \d+ new$/ })).toBeVisible() await go(page, '/notifications') await anew(page, (expect) => expect(page.getByRole('link', { name: 'Notifications', exact: true })).toBeVisible(), ) // A pull or an issue muted from its row, and that undone. const rows = page.locator('.notifications li') const at = await rows.evaluateAll((ls) => ls.findIndex((l) => l.querySelector('.row-mute'))) expect(at).toBeGreaterThanOrEqual(0) const row = rows.nth(at) await row.hover() await row.locator('.row-mute').click() await expect(row.getByRole('status')).toContainText(/^You muted this (issue|pull)\./) // Read back as the page asks, through the gateway only the browser reaches. const chosen = () => page.evaluate(async () => (await (await fetch('/xrpc/dev.knotless.listSubscriptions')).json()).subscriptions.map( (s) => s.level, ), ) expect(await chosen()).toContain('muted') await row.getByRole('button', { name: 'Undo' }).click() await expect(row.locator('.row-mute')).toHaveCount(1) expect(await chosen()).not.toContain('muted') }) test('hiding a comment as the owner', async () => { await go(page, opened) await choose(page, 'empty ones too.', 'Hide this comment') await expect(comment(page, 'empty ones too.')).toHaveCount(0) expect((await records('alice', 'dev.knotless.hide')).length).toBeGreaterThan(0) await anew(page, (expect) => expect(comment(page, 'empty ones too.')).toHaveCount(0)) }) test('joining an organization', async () => { await go(page, '/bob.test?tab=members') await page.getByRole('button', { name: 'Join publicly' }).click() await expect(page.getByText("You're a member of bob.test, and anyone can see it.")).toBeVisible() // The gateway bob delegated to takes her acceptance in, as joinedAt on // the record of her he keeps, and her acceptance goes. await expect .poll( async () => (await records('bob', 'dev.knotless.orgMember')).find( (r) => r.value.member === who.alice.did, )?.value.joinedAt, ) .toBeTruthy() await go(page, '/alice.test') await anew(page, (expect) => expect(page.getByRole('link', { name: 'bob.test' }).first()).toBeVisible(), ) }) test("changing an organization's repository as its admin", async () => { // alice, an admin of bob.test now, changes its repository, written as // bob.test by the gateway he delegated to, but neither transfers nor // deletes it. await go(page, '/bob.test/wordlist') await page.getByRole('link', { name: 'Settings' }).click() await page.waitForURL(/\/bob\.test\/wordlist\/settings$/) await page.getByLabel('Description').fill('Words, one a line') await page.getByRole('button', { name: 'Save', exact: true }).click() await expect(page.getByRole('status')).toBeVisible() const wordlist = async () => (await records('bob', 'dev.knotless.repo')).find((r) => r.uri.endsWith('/wordlist'))?.value expect((await wordlist()).description).toBe('Words, one a line') await expect( page.getByText('Only bob.test itself can transfer or delete this repository.'), ).toBeVisible() await expect(page.getByRole('button', { name: 'Delete', exact: true })).toHaveCount(0) await anew(page, (expect) => expect(page.getByLabel('Description')).toHaveValue('Words, one a line'), ) }) test('changing what a repository says about itself', async () => { await go(page, '/alice.test/tally/settings') await page.getByLabel('Description').fill('Counts lines, words and bytes, as wc does') await page.getByLabel('Topics').fill('rust cli text') await page.getByRole('button', { name: 'Save', exact: true }).click() await expect(page.getByRole('status')).toBeVisible() await go(page, '/alice.test/tally') await anew(page, async (expect) => { await expect(page.getByText('Counts lines, words and bytes, as wc does')).toBeVisible() await expect(page.getByRole('link', { name: 'text', exact: true })).toBeVisible() }) }) test('defining, changing and deleting a label', async () => { await go(page, '/alice.test/tally/settings/labels') await page.getByRole('button', { name: 'New label' }).click() const dialog = page.getByRole('dialog', { name: 'New label' }) await dialog.getByLabel('Name').fill('kind/docs') await dialog.getByLabel(/^Description/).fill('Words, not code') await dialog.getByRole('button', { name: 'Create label' }).click() await expect(dialog).toBeHidden() const row = page.locator('li', { hasText: 'kind/docs' }) await expect(row).toContainText('Words, not code') await row.getByRole('button', { name: 'Edit' }).click() const editing = page.getByRole('dialog', { name: 'Edit label' }) await expect(editing.getByLabel('Name')).toHaveValue('kind/docs') await editing.getByLabel(/^Description/).fill('Words about the code') await editing.getByRole('button', { name: 'Save changes' }).click() await expect(editing).toBeHidden() await expect(row).toContainText('Words about the code') await anew(page, (expect) => expect(page.locator('li', { hasText: 'kind/docs' })).toContainText('Words about the code'), ) page.once('dialog', (d) => d.accept()) await page.locator('li', { hasText: 'kind/docs' }).getByRole('button', { name: 'Delete' }).click() await expect(page.locator('li', { hasText: 'kind/docs' })).toHaveCount(0) await anew(page, (expect) => expect(page.locator('li', { hasText: 'kind/docs' })).toHaveCount(0)) }) test('making, protecting, renaming and deleting a branch', async () => { await go(page, '/alice.test/tally/settings/branches') await page.getByLabel('New branch').fill('topic') await page.getByRole('button', { name: 'Create', exact: true }).click() const row = (name) => page.locator('li', { has: page.getByRole('link', { name, exact: true }) }) await expect(row('topic')).toBeVisible() await row('topic').getByRole('button', { name: 'Protect' }).click() await expect(row('topic')).toContainText('protected') await anew(page, (expect) => expect(row('topic')).toContainText('protected')) await row('topic').getByRole('button', { name: 'Unprotect' }).click() await expect(row('topic').getByRole('button', { name: 'Protect' })).toBeVisible() await row('topic').getByRole('button', { name: 'Rename' }).click() await page.getByLabel('New name').fill('topical') await row('topic').getByRole('button', { name: 'Rename' }).last().click() await expect(row('topical')).toBeVisible() await anew(page, (expect) => expect(row('topical')).toBeVisible()) page.once('dialog', (d) => d.accept()) await row('topical').getByRole('button', { name: 'Delete' }).click() await expect(row('topical')).toHaveCount(0) await anew(page, (expect) => expect(row('topical')).toHaveCount(0)) }) test('naming and removing a gate of a branch', async () => { await go(page, '/alice.test/tally/settings/branches') await page.getByLabel('New branch').fill('gated') await page.getByRole('button', { name: 'Create', exact: true }).click() const row = (page) => page.locator('li', { has: page.getByRole('link', { name: 'gated', exact: true }) }) await row(page).getByRole('button', { name: 'Protect' }).click() await expect(row(page)).toContainText('protected') await row(page).getByLabel('Required reviewers').fill('bob.test') await row(page) .locator('form', { has: page.getByLabel('Required reviewers') }) .getByRole('button', { name: 'Add' }) .click() const gate = (page) => row(page).locator('.gates li', { hasText: 'bob.test' }) await expect(gate(page)).toBeVisible() await anew(page, (expect) => expect(gate(page)).toBeVisible()) // The rule in alice's record names bob by DID. const rule = async () => { const repo = (await records('alice', 'dev.knotless.repo')).find((r) => r.uri.endsWith('/tally')) return repo.value.rules.find((r) => r.branch === 'gated') } expect((await rule()).reviews.gates).toEqual([{ did: who.bob.did }]) await gate(page).getByRole('button', { name: 'Remove' }).click() await expect(gate(page)).toHaveCount(0) await anew(page, (expect) => expect(gate(page)).toHaveCount(0)) expect((await rule()).reviews).toBeUndefined() await row(page).getByRole('button', { name: 'Unprotect' }).click() await expect(row(page).getByRole('button', { name: 'Protect' })).toBeVisible() page.once('dialog', (d) => d.accept()) await row(page).getByRole('button', { name: 'Delete' }).click() await expect(row(page)).toHaveCount(0) }) test('adding and removing a collaborator', async () => { await go(page, '/alice.test/tally/settings/access') const add = () => page .locator('form', { has: page.getByLabel('Add a collaborator') }) .getByRole('button', { name: 'Add' }) .click() await page.getByLabel('Add a collaborator').fill('bob.test') await add() // Collaborators push through a gateway: alice delegates to this one // first, as the page asks, then adds him again. await expect(page.getByRole('alert')).toContainText( 'Delegate your writes to one first with Delegate writes to this server', ) await delegate(page, 'alice') await add() const listed = page .locator('section', { hasText: 'Collaborators' }) .locator('li', { hasText: 'bob.test' }) await expect(listed).toBeVisible() await anew(page, (expect) => expect( page.locator('section', { hasText: 'Collaborators' }).locator('li', { hasText: 'bob.test' }), ).toBeVisible(), ) await page .locator('section', { hasText: 'Collaborators' }) .locator('li', { hasText: 'bob.test' }) .getByRole('button', { name: 'Remove' }) .click() await expect( page.locator('section', { hasText: 'Collaborators' }).locator('li', { hasText: 'bob.test' }), ).toHaveCount(0) await anew(page, (expect) => expect( page.locator('section', { hasText: 'Collaborators' }).locator('li', { hasText: 'bob.test' }), ).toHaveCount(0), ) }) test('naming and removing a triager', async () => { await go(page, '/alice.test/tally/settings/access') await page.getByLabel('Add a triager').fill('bob.test') await page.getByLabel('Scopes').fill('kind') await page .locator('form', { has: page.getByLabel('Scopes') }) .getByRole('button', { name: 'Add' }) .click() const named = (page) => page.locator('section', { hasText: 'Triagers' }).locator('li', { hasText: 'bob.test' }) await expect(named(page)).toBeVisible() await anew(page, (expect) => expect(named(page)).toBeVisible()) await named(page).getByRole('button', { name: 'Remove' }).click() await expect(named(page)).toHaveCount(0) await anew(page, (expect) => expect(named(page)).toHaveCount(0)) }) test('adding and removing a secret', async () => { await go(page, '/alice.test/tally/settings/actions') await page.getByLabel('New secret').fill('DEPLOY_TOKEN') await page.getByLabel('Value').fill('not-a-real-token') await page.getByRole('button', { name: 'Add secret' }).click() const listed = (page) => page.locator('li', { hasText: 'DEPLOY_TOKEN' }) await expect(listed(page)).toBeVisible() await anew(page, (expect) => expect(listed(page)).toBeVisible()) page.once('dialog', (d) => d.accept()) await listed(page).getByRole('button').click() await expect(listed(page)).toHaveCount(0) await anew(page, (expect) => expect(listed(page)).toHaveCount(0)) }) test('compacting a repository', async () => { await go(page, '/alice.test/tally/settings') await page.getByRole('button', { name: 'Compact' }).click() await expect(page.getByRole('button', { name: 'Compacting…' })).toHaveCount(0, { timeout: 30_000, }) await expect(page.getByRole('button', { name: 'Compact' })).toBeVisible() await expect(page.locator('section', { hasText: 'Storage' }).getByRole('alert')).toHaveCount(0) }) test('asking for changes, then dismissing the review, as the owner', async () => { await go(page, world.views['pull-open']) await page.getByPlaceholder('Leave a comment').fill('Name the field `chars` too.') await page.getByLabel('Request changes').check() await page.getByRole('button', { name: 'Request changes', exact: true }).click() const box = page.locator('.merge-box') await expect(box).toContainText('Changes requested') // From the merge box's row for the review, saying why. await box.locator('.reviewers .action-menu summary').first().click() await box.getByRole('button', { name: 'Dismiss review' }).click() const dialog = page.getByRole('dialog', { name: 'Dismiss review' }) await dialog.getByLabel('Why').fill('Settled in chat.') await dialog.getByRole('button', { name: 'Dismiss review' }).click() await expect(box).toContainText('review dismissed') await expect(page.locator('.comment', { hasText: 'Settled in chat.' })).toContainText( "dismissed alice.test's review", ) const dismissal = async () => (await records('alice', 'dev.knotless.comment')).find( (r) => r.value.body === 'Settled in chat.', )?.value.dismisses await expect.poll(dismissal).toMatch(/^at:\/\/.*\/dev\.knotless\.comment\//) await anew(page, (expect) => expect(page.locator('.comment', { hasText: 'Name the field' }).locator('.verdict')).toHaveText( 'Dismissed', ), ) }) test('setting a pull to merge when its checks pass, and cancelling it', async () => { // bob's pull from his fork has no checks, so merges now: he adds one // no runner runs here, and alice, who owns the repository, sets it to // merge once it passes. const workflow = join(tmpdir(), 'pull-test.yml') writeFileSync( workflow, 'on: {pull: {base: [refs/heads/main]}}\nsteps:\n - name: test\n run: cargo test\n', ) knotless( 'bob', 'repo', 'commit', 'knotless://bob.test/tally', '-b', 'words', '-m', 'Test each pull', '--put', '.knotless/workflows/test.yml', workflow, ) await go(page, proposed) const box = page.locator('.merge-box') await anew(page, (expect) => expect(box.getByRole('button', { name: 'Merge when checks pass' })).toBeVisible(), ) await box.getByRole('button', { name: 'Merge when checks pass' }).click() await expect(box).toContainText('Merges when checks pass') await expect(box).toContainText('Set by alice.test.') const set = async () => (await records('alice', 'dev.knotless.autoMerge')).map((r) => r.value.state) await expect.poll(set).toEqual(['set']) await anew(page, (expect) => expect(page.locator('.merge-box')).toContainText('Merges when checks pass'), ) // Cancelled, it merges only when someone merges it. await page.locator('.merge-box').getByRole('button', { name: 'Cancel merge' }).click() await expect( page.locator('.merge-box').getByRole('button', { name: 'Merge when checks pass' }), ).toBeVisible() await expect.poll(async () => (await set()).sort()).toEqual(['cancelled', 'set']) await anew(page, (expect) => expect(page.locator('.merge-box')).not.toContainText('Merges when checks pass'), ) }) test('merging into a protected branch', async () => { // alice's main is protected and linear: the pull is squashed into one commit. await go(page, world.views['pull-open']) await expect(page.locator('.merge-box')).toContainText('it is squashed into one commit') await page.locator('.merge-box').getByRole('button', { name: 'Merge pull' }).click() const said = page.locator('.event', { hasText: 'Merged as' }) await expect(said.getByText(/^[0-9a-f]{7}$/)).toBeVisible() await go(page, '/alice.test/tally/commits/main') await anew(page, (expect) => expect(page.getByText('Print counts as JSON with --json').first()).toBeVisible(), ) }) test('renaming a repository', async () => { await go(page, '/alice.test/scratch/settings') await page.getByLabel('Name', { exact: true }).fill('sketches') await page.getByRole('button', { name: 'Rename' }).click() await page.waitForURL(/\/alice\.test\/sketches/) // The old name leads to the new. await anew(page, async (expect) => { await go(page, '/alice.test/scratch') expect(new URL(page.url()).pathname).toMatch(/^\/alice\.test\/sketches/) }) }) test('offering a repository to another account', async () => { await go(page, '/alice.test/sketches/settings') await page.getByRole('combobox', { name: 'Account' }).fill('bob.test') await page.getByRole('button', { name: 'Offer it' }).click() await expect(page.getByRole('button', { name: 'Withdraw offer' })).toBeVisible() await anew(page, (expect) => expect(page.getByRole('button', { name: 'Withdraw offer' })).toBeVisible(), ) }) test('leaving an organization', async () => { await go(page, '/bob.test?tab=members') await page .locator('li, .member', { hasText: 'alice.test' }) .locator('details summary') .first() .click() await page.getByRole('button', { name: 'Leave bob.test' }).click() await page.getByRole('dialog').getByRole('button', { name: 'Leave', exact: true }).click() await expect(page.getByText("You've left bob.test.")).toBeVisible() await expect .poll(async () => (await records('bob', 'dev.knotless.orgMember')).some( (r) => r.value.member === who.alice.did, ), ) .toBe(false) }) test('taking up a transfer', async () => { await switchTo(page, 'bob.test') await go(page, '/alice.test/sketches') await page.getByRole('button', { name: 'Accept transfer' }).click() await page.waitForURL(/\/bob\.test\/sketches/) await anew(page, (expect) => expect(page.getByRole('heading').filter({ hasText: 'bob.test' }).first()).toBeVisible(), ) }) test('ending an organization', async () => { await go(page, '/settings/organization') await page.getByRole('button', { name: 'Delete', exact: true }).click() const dialog = page.getByRole('dialog', { name: 'Delete organization' }) await dialog.getByRole('textbox').fill('bob.test') await dialog.getByRole('button', { name: 'Delete organization' }).click() await expect.poll(async () => (await records('bob', 'dev.knotless.org')).length).toBe(0) await go(page, '/bob.test') await anew(page, (expect) => expect(page.getByRole('link', { name: /^Members/ })).toHaveCount(0)) }) test('archiving and deleting a repository', async () => { await go(page, '/bob.test/wordlist/settings') await page.getByRole('button', { name: 'Archive', exact: true }).click() await expect(page.getByRole('button', { name: 'Unarchive' })).toBeVisible() await anew(page, (expect) => expect(page.getByRole('button', { name: 'Unarchive' })).toBeVisible(), ) await page.getByRole('button', { name: 'Delete', exact: true }).click() const dialog = page.locator('#delete-repo') await dialog.getByRole('textbox').fill('bob.test/wordlist') await dialog.getByRole('button', { name: 'Delete this repository' }).click() await page.waitForURL((u) => !u.pathname.startsWith('/bob.test/wordlist')) expect( (await records('bob', 'dev.knotless.repo')).map((r) => r.uri.split('/').pop()), ).not.toContain('wordlist') await expect.poll(async () => status(page, '/bob.test/wordlist')).toBe(404) }) test("deleting one's knotless data", async () => { await go(page, '/settings/profile') await page.getByRole('button', { name: 'Delete data' }).click() const dialog = page.getByRole('dialog', { name: 'Delete your knotless data' }) await dialog.getByRole('textbox').fill('bob.test') await dialog.getByRole('button', { name: 'Delete all my knotless data' }).click() // The page deletes in parts, then goes home. await page.waitForURL((u) => u.pathname === '/', { timeout: 30_000 }) await expect .poll(async () => (await records('bob', 'dev.knotless.repo')).length, { timeout: 30_000 }) .toBe(0) await expect.poll(async () => status(page, '/bob.test/tally')).toBe(404) // It logs bob out too: the browser goes on as alice. await anew(page, async (expect) => expect(await loggedInAs(page)).toBe('alice.test')) }) test('logging out', async () => { await go(page, '/alice.test/notes') await expect.poll(() => loggedInAs(page)).toBe('alice.test') await page.getByLabel('Your account').click() await page.getByRole('button', { name: 'Log out' }).click() await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible() // Starring asks who is starring first. await go(page, '/alice.test/notes') await page.getByRole('button', { name: 'Star', exact: true }).click() await expect( page.getByRole('dialog', { name: 'Log in with your Atmosphere account' }), ).toBeVisible() })