~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
1088 lines · 47.2 KB Raw History
// 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()
})