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

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

The e2e check uses a private repository, over the spaces alpha

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:harness/e2e-private
.knotless/workflows/e2e.yml+41−8
@@ -2,12 +2,14 @@
# logging in through the PDS's own approval page, then every write the
# pages make, each read back on the page and from the PDS
# (harness/e2e/flows.spec.mjs), over a world seeded for the run as the
# shots' is. The shots show what the pages draw; this, that what they do
# lands. Each flow that fails leaves the page as it was then among the
# check's outputs, as a picture.
# shots' is. Then a private repository's, over another on the spaces
# alpha, each read back on the page and from its space, and as not there
# to anyone else (private.spec.mjs). The shots show what the pages draw;
# this, that what they do lands. Each flow that fails leaves the page as
# it was then among the check's outputs, as a picture.
#
# Node, the packages, Chromium, the libraries it needs and the build are
# kept between runs, as main's runs leave them: nothing is fetched again
# Node, the packages, Chromium, the libraries it needs, the alpha and the
# build are kept between runs, as main's runs leave them: nothing is fetched again
# until what it is made from changes.
image: rust:1.98
# Every push to main, and pulls into it or a feature/ branch, at their
@@ -20,9 +22,13 @@ on:
pull:
base: [refs/heads/main, refs/heads/feature/*]
paths: [web/**, lexicons/**, crates/knotless/**, crates/knotless-serve/**, crates/knotless-mcp/**, Cargo.toml, Cargo.lock, harness/e2e/**, harness/shots/world.mjs, harness/shots/fixture/**, harness/pds.mjs, harness/tls-proxy.mjs, harness/package.json, harness/package-lock.json, .knotless/workflows/e2e.yml]
cache: [target, /usr/local/cargo/registry, /usr/local/rustup, /opt/node, /opt/browsers, /opt/chromium-debs, web/node_modules, harness/node_modules]
cache: [target, /usr/local/cargo/registry, /usr/local/rustup, /opt/node, /opt/browsers, /opt/chromium-debs, /opt/atproto, web/node_modules, harness/node_modules]
env:
PLAYWRIGHT_BROWSERS_PATH: /opt/browsers
# The spaces alpha the private flows run over, as test.yml pins it: the
# two move together.
ATPROTO_COMMIT: 11c5d98cd50261c5e1201fedc5c6c05a7b8d1ccf
ALPHA_BUILD: "2"
steps:
- name: node
run: |
@@ -57,6 +63,31 @@ steps:
echo "$want" > $debs/.want
fi
dpkg -i $debs/*.deb > /dev/null
- name: spaces alpha
run: |
[ "$(cat /opt/atproto/.built 2>/dev/null)" = "$ATPROTO_COMMIT $ALPHA_BUILD" ] && exit 0
export PATH=/opt/node/bin:$PATH COREPACK_ENABLE_DOWNLOAD_PROMPT=0 COREPACK_HOME=/opt/atproto/.corepack
find /opt/atproto -mindepth 1 -delete
cd /opt/atproto
git init -q . && git fetch -q --depth 1 https://github.com/bluesky-social/atproto.git "$ATPROTO_COMMIT" && git checkout -q FETCH_HEAD
mkdir .bin && corepack enable --install-directory /opt/atproto/.bin
export PATH=/opt/atproto/.bin:$PATH
pnpm install --frozen-lockfile --ignore-scripts
pnpm run build:tooling --force
pnpm --filter '@atproto/dev-env^...' --filter @atproto/dev-env run --if-present prebuild
pnpm exec tsc --build packages/dev-env/tsconfig.build.json
# The PDS serves the OAuth sign-in pages from this bundle.
pnpm --filter @atproto/oauth-provider-ui run build:ui
echo "$ATPROTO_COMMIT $ALPHA_BUILD" > /opt/atproto/.built
- name: sqlite
run: |
# The one install script the PDS needs, which the install above
# skips: SQLite's native module, as better-sqlite3's own script builds
# it (a prebuilt one where there is one).
export PATH=/opt/node/bin:$PATH
cd "$(dirname "$(node -p "require.resolve('better-sqlite3/package.json', { paths: ['/opt/atproto/packages/pds'] })")")"
[ -f build/Release/better_sqlite3.node ] && exit 0
npm run install
- name: build
run: |
export PATH=/opt/node/bin:$PATH
@@ -67,9 +98,11 @@ steps:
export PATH=/opt/node/bin:$PATH
status=0
(cd harness && E2E=1 npx playwright test -c e2e/playwright.config.mjs) || status=$?
# Then the private ones, over a world of their own on the alpha.
(cd harness && KNOTLESS_DEV_ENV=/opt/atproto/packages/dev-env E2E=1 npx playwright test -c e2e/playwright.config.mjs) || status=$?
# The page each failed flow ended on, named for the flow.
if [ -n "${KNOTLESS_RUN_OUTPUTS:-}" ] && [ -d target/e2e/results ]; then
for shot in target/e2e/results/*/test-failed-*.png; do
if [ -n "${KNOTLESS_RUN_OUTPUTS:-}" ] && [ -d target/e2e ]; then
for shot in target/e2e/*/*/test-failed-*.png; do
[ -f "$shot" ] && cp "$shot" "$KNOTLESS_RUN_OUTPUTS/$(basename "$(dirname "$shot")").png"
done
fi
docs/development.md+34−1
@@ -242,11 +242,44 @@ gateway, behind the harness's https front
([`tls-proxy.mjs`](../harness/tls-proxy.mjs)), as a PDS sends a login back
only over https. Chromium reaches that front at the harness's gateway
hostname and takes its certificate. A failed flow leaves a screenshot and
a trace under `target/e2e/results`.
a trace under `target/e2e/results` (`target/e2e/private` for the
private flows).
A new control that writes from the pages gets a flow in
[`flows.spec.mjs`](../harness/e2e/flows.spec.mjs).
### Private repositories
A private repository is built differently all the way down. Its records
are in a space rather than anyone's public repo, and its pages read them
from this server's own index of that space rather than the appview's
([Access](design/access.md#private-repositories)). So the private flows
([`private.spec.mjs`](../harness/e2e/private.spec.mjs)) run over a world on
the spaces alpha, with a private repository of alice's pushed from the
command line. As alice and then bob, a reader she adds, they:
- create a private repository from the pages, rename it and delete it
- define a label, open a pull from a branch, label, close and reopen,
hide a comment, and merge
- add a reader and take them out again, who then reads it no more
- open an issue, comment, react and edit as the reader, and comment on
the pull
- hear of the reader's issue as the owner
- fork it, privately, as the reader
Each write is checked on the page at once, from the space with the
command line, as missing from the writer's public repo, on the page read
anew, and as not there at all to a browser logged in to no one.
```sh
KNOTLESS_DEV_ENV=<alpha>/packages/dev-env just e2e-private
```
`KNOTLESS_DEV_ENV` names a build of the alpha at `test.yml`'s
`ATPROTO_COMMIT`, made with that workflow's steps. A control that writes
to a private repository differently from a public one gets a flow there
too.
## Scenarios
Scenarios ([`harness/scenarios`](../harness/scenarios)) put agents to a
harness/e2e/flows.spec.mjs+1−101
@@ -3,110 +3,10 @@
// read anew, from the index.
import { expect, test } from '@playwright/test'
const world = JSON.parse(process.env.E2E_WORLD)
const who = Object.fromEntries(world.accounts.map((a) => [a.handle.split('.')[0], a]))
const url = (path) => world.base + path
import { world, who, url, records, go, anew, delegate, status, switchTo, comment, editor, choose, logIn, loggedInAs } from './pages.mjs'
test.describe.configure({ mode: 'serial' })
/// The records of `collection` in `name`'s repo, read from their PDS.
const records = async (name, collection) => {
const asked = `${world.pds}/xrpc/com.atproto.repo.listRecords?repo=${who[name].did}&collection=${collection}&limit=100`
return (await fetch(asked).then((r) => r.json())).records ?? []
}
/// Until the page's script has taken it over, as the root says once
/// mounted: a click before that reaches no handler.
const ready = (page) => page.locator('html[data-ready]').waitFor({ state: 'attached' })
/// `page` at `path` of the pages, ready.
const go = async (page, path) => {
await page.goto(path.startsWith('http') ? path : url(path))
await ready(page)
}
/// Until `check` passes on `page` read anew: the index follows the PDS a
/// moment behind. `check` is handed an `expect` that gives up soon, so a
/// page drawn before the index caught up is read again rather than waited
/// on.
const anew = (page, check) =>
expect(async () => {
await page.reload()
await ready(page)
await check(expect.configure({ timeout: 2_000 }))
}).toPass({ timeout: 30_000 })
/// Signs in as `name` on the PDS's own page `at` has come to, and approves
/// what was asked of it.
const approve = async (at, name) => {
await at.waitForURL((u) => u.origin === new URL(world.pds).origin)
await at.getByRole('textbox', { name: 'Password' }).fill(who[name].password)
await at.getByRole('button', { name: 'Sign in' }).click()
await at.getByRole('button', { name: /^(accept|authorize|allow)$/i }).click()
}
/// Delegates `name`'s writes to this server from the button on `page`:
/// approved at their PDS, in the page of its own the button opens.
const delegate = async (page, name) => {
const opening = page.context().waitForEvent('page')
await page.getByRole('button', { name: 'Delegate writes to this server' }).click()
const pds = await opening
await approve(pds, name)
// Back at the gateway, which takes the grant there.
await pds.waitForURL((u) => u.origin === new URL(world.base).origin)
await pds.close()
await expect(page.getByRole('button', { name: /^(Delegate writes to this server|Waiting for approval…)$/ })).toHaveCount(0)
}
/// The status the pages answer `path` with, asked from the page, as the
/// browser reaches them.
const status = (page, path) => page.evaluate((p) => fetch(p).then((r) => r.status), path)
/// Switches the browser to `handle`, an account it is logged in to too.
const switchTo = async (page, handle) => {
await page.getByLabel('Your account').click()
await page.locator('.account .menu-item.switch', { hasText: handle }).click()
await expect.poll(() => loggedInAs(page)).toBe(handle)
await ready(page)
}
/// A comment, by what it says.
const comment = (page, text) => page.locator('.comment', { hasText: text })
/// The text of the form whose button says `label`, as editing opens one.
const editor = (page, label) => page.locator('form', { has: page.getByRole('button', { name: label }) }).locator('textarea')
/// Chooses `action` from the menu of the comment that says `text`.
const choose = async (page, text, action) => {
const menu = comment(page, text).locator('.action-menu')
await menu.locator('summary').click()
await menu.getByRole('button', { name: action, exact: true }).click()
}
/// Logs in as `name` from the dialog the page's header opens: the handle,
/// then the PDS's own page to sign in and approve on, which sends the
/// browser back to the page it started from.
const logIn = async (page, name, open = () => page.getByRole('button', { name: 'Log in' }).click()) => {
await open()
const dialog = page.getByRole('dialog', { name: 'Log in with your Atmosphere account' })
await dialog.getByLabel('Handle').fill(who[name].handle)
const back = page.url()
await dialog.getByRole('button', { name: 'Log in' }).click()
await approve(page, name)
await page.waitForURL(back)
await ready(page)
await expect(page.getByLabel('Your account')).toBeVisible()
}
/// Who the page is logged in as, by the account menu's head, which is
/// there with the menu closed: nothing while the page reloads.
const loggedInAs = (page) =>
page
.locator('.account .menu-head')
.textContent({ timeout: 1000 })
.then((t) => t.trim(), () => '')
let page
test.beforeAll(async ({ browser }) => {
harness/e2e/pages.mjsnew+118−0
@@ -0,0 +1,118 @@
// What the flows share: the world they run over, and how a page is read,
// logged in to and written from, as a person does it.
import { expect } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { join } from 'node:path'
import { environment, root } from '../shots/world.mjs'
export const world = JSON.parse(process.env.E2E_WORLD)
export const who = Object.fromEntries(world.accounts.map((a) => [a.handle.split('.')[0], a]))
export const url = (path) => world.base + path
/// The records of `collection` in `name`'s repo, read from their PDS.
export const records = async (name, collection) => {
const asked = `${world.pds}/xrpc/com.atproto.repo.listRecords?repo=${who[name].did}&collection=${collection}&limit=100`
return (await fetch(asked).then((r) => r.json())).records ?? []
}
/// What the command line says, run as `name`: for a private repository,
/// read from its space with their own session, past the pages and index.
export const knotless = (name, ...args) =>
execFileSync(join(root, 'target/debug/knotless'), args, { env: environment(world, world.dir, name) })
.toString()
.trim()
/// The status `page`, logged in to no one, gets for `path`.
export const seen = async (page, path) => (await page.goto(url(path))).status()
/// Until the page's script has taken it over, as the root says once
/// mounted: a click before that reaches no handler.
export const ready = (page) => page.locator('html[data-ready]').waitFor({ state: 'attached' })
/// `page` at `path` of the pages, ready.
export const go = async (page, path) => {
await page.goto(path.startsWith('http') ? path : url(path))
await ready(page)
}
/// Until `check` passes on `page` read anew: the index follows the PDS a
/// moment behind. `check` is handed an `expect` that gives up soon, so a
/// page drawn before the index caught up is read again rather than waited
/// on. `timeout`, for a change seen later than the index's.
export const anew = (page, check, timeout = 30_000) =>
expect(async () => {
await page.reload()
await ready(page)
await check(expect.configure({ timeout: 2_000 }))
}).toPass({ timeout })
/// Signs in as `name` on the PDS's own page `at` has come to, and approves
/// what was asked of it.
export const approve = async (at, name) => {
await at.waitForURL((u) => u.origin === new URL(world.pds).origin)
await at.getByRole('textbox', { name: 'Password' }).fill(who[name].password)
await at.getByRole('button', { name: 'Sign in' }).click()
await at.getByRole('button', { name: /^(accept|authorize|allow)$/i }).click()
}
/// Delegates `name`'s writes to this server from the button on `page`:
/// approved at their PDS, in the page of its own the button opens.
export const delegate = async (page, name) => {
const opening = page.context().waitForEvent('page')
await page.getByRole('button', { name: 'Delegate writes to this server' }).click()
const pds = await opening
await approve(pds, name)
// Back at the gateway, which takes the grant there.
await pds.waitForURL((u) => u.origin === new URL(world.base).origin)
await pds.close()
await expect(page.getByRole('button', { name: /^(Delegate writes to this server|Waiting for approval…)$/ })).toHaveCount(0)
}
/// The status the pages answer `path` with, asked from the page, as the
/// browser reaches them.
export const status = (page, path) => page.evaluate((p) => fetch(p).then((r) => r.status), path)
/// Switches the browser to `handle`, an account it is logged in to too.
export const switchTo = async (page, handle) => {
await page.getByLabel('Your account').click()
await page.locator('.account .menu-item.switch', { hasText: handle }).click()
await expect.poll(() => loggedInAs(page)).toBe(handle)
await ready(page)
}
/// A comment, by what it says.
export const comment = (page, text) => page.locator('.comment', { hasText: text })
/// The text of the form whose button says `label`, as editing opens one.
export const editor = (page, label) => page.locator('form', { has: page.getByRole('button', { name: label }) }).locator('textarea')
/// Chooses `action` from the menu of the comment that says `text`.
export const choose = async (page, text, action) => {
const menu = comment(page, text).locator('.action-menu')
await menu.locator('summary').click()
await menu.getByRole('button', { name: action, exact: true }).click()
}
/// Logs in as `name` from the dialog the page's header opens: the handle,
/// then the PDS's own page to sign in and approve on, which sends the
/// browser back to the page it started from.
export const logIn = async (page, name, open = () => page.getByRole('button', { name: 'Log in' }).click()) => {
await open()
const dialog = page.getByRole('dialog', { name: 'Log in with your Atmosphere account' })
await dialog.getByLabel('Handle').fill(who[name].handle)
const back = page.url()
await dialog.getByRole('button', { name: 'Log in' }).click()
await approve(page, name)
await page.waitForURL(back)
await ready(page)
await expect(page.getByLabel('Your account')).toBeVisible()
}
/// Who the page is logged in as, by the account menu's head, which is
/// there with the menu closed: nothing while the page reloads.
export const loggedInAs = (page) =>
page
.locator('.account .menu-head')
.textContent({ timeout: 1000 })
.then((t) => t.trim(), () => '')
harness/e2e/playwright.config.mjs+6−2
@@ -19,9 +19,13 @@ if (!process.env.E2E) throw new Error('run the flows with E2E=1, as `just e2e` d
export default defineConfig({
testDir: '.',
testMatch: 'flows.spec.mjs',
// The public flows over the stock PDS, as most accounts' are; the
// private ones over the spaces alpha (KNOTLESS_DEV_ENV), as only it
// holds a private repository.
testMatch: process.env.KNOTLESS_DEV_ENV ? 'private.spec.mjs' : 'flows.spec.mjs',
globalSetup: './setup.mjs',
outputDir: join(root, 'target/e2e/results'),
// Each run's own, as a run clears its outputs first.
outputDir: join(root, 'target/e2e', process.env.KNOTLESS_DEV_ENV ? 'private' : 'results'),
workers: 1,
// Seeding takes a while; a flow, waiting on the index, much less.
timeout: 60_000,
harness/e2e/private.spec.mjsnew+249−0
@@ -0,0 +1,249 @@
// A private repository used on the pages, over the spaces alpha: alice's
// `vault`, made private from the command line by setup.mjs, and one she
// makes from the pages. Its records live in its space, not in anyone's
// public repo, and its pages read them from this server's index of that
// space, apart from the appview's. So each write here is checked on the
// page at once, from the space with the command line, as not in the
// writer's public repo, on the page read anew, and as not there at all to
// a browser logged in to no one.
import { expect, test } from '@playwright/test'
import { who, url, records, go, anew, knotless, seen, switchTo, comment, editor, choose, logIn, loggedInAs } from './pages.mjs'
test.describe.configure({ mode: 'serial' })
const vault = 'knotless://alice.test/vault'
/// The record a pull's or issue's page, at `href`, shows.
const uriOf = (href) => {
const [kind, did, rkey] = new URL(href).pathname.split('/').slice(-3)
return `at://${did}/dev.knotless.${kind}/${rkey}`
}
/// The page as the members see it, and one for a browser logged in to no
/// one, which finds nothing of it.
let page
let stranger
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
stranger = await browser.newPage()
})
test.afterAll(async () => {
await page.close()
await stranger.close()
})
test('logging in as its owner', async () => {
// To anyone, it is not there.
await go(page, '/alice.test/vault')
await expect(page.getByText('Not Found')).toBeVisible()
await logIn(page, 'alice')
await expect.poll(() => loggedInAs(page)).toBe('alice.test')
await anew(page, async (expect) => {
await expect(page.getByText('Plans not yet for anyone else.')).toBeVisible()
await expect(page.locator('.tag.private').first()).toBeVisible()
})
})
test('creating a private repository', async () => {
await go(page, '/new')
await page.getByLabel('Repository name').fill('diary')
await page.getByRole('radio', { name: /^Private/ }).click()
await page.getByRole('button', { name: 'Create repository' }).click()
await page.waitForURL(url('/alice.test/diary'))
await expect(page.locator('.tag.private').first()).toBeVisible()
expect(knotless('alice', 'ls', 'alice.test')).toMatch(/^diary\t.*private$/m)
expect((await records('alice', 'dev.knotless.repo')).map((r) => r.uri.split('/').pop())).not.toContain('diary')
expect(await seen(stranger, '/alice.test/diary')).toBe(404)
await go(page, '/alice.test?tab=repos')
await anew(page, (expect) => expect(page.getByRole('link', { name: 'diary' }).first()).toBeVisible())
})
test('defining a label', async () => {
await go(page, '/alice.test/vault/settings/labels')
await page.getByLabel('New label').fill('kind/secret')
await page.getByRole('button', { name: 'Create label' }).click()
await expect(page.locator('li', { hasText: 'kind/secret' })).toBeVisible()
expect(knotless('alice', 'labels', vault)).toContain('kind/secret')
await anew(page, (expect) => expect(page.locator('li', { hasText: 'kind/secret' })).toBeVisible())
})
let pulled
test('opening a pull from a branch', async () => {
await go(page, '/alice.test/vault/pulls/new')
await page.getByLabel('From').selectOption({ label: 'alice.test/vault (yours)' })
await page.getByLabel('Branch').selectOption('plans')
await expect(page.locator('.compared')).toContainText('Write down the plans')
await page.getByRole('textbox', { name: 'Title' }).fill('Write down the plans')
await page.getByRole('button', { name: 'Open pull' }).click()
await page.waitForURL(/\/alice\.test\/vault\/pull\//)
pulled = page.url()
await expect(page.getByRole('heading', { name: 'Write down the plans' })).toBeVisible()
expect(knotless('alice', 'list', vault, '--pulls')).toContain('Write down the plans')
expect((await records('alice', 'dev.knotless.pull')).map((r) => r.value.title)).not.toContain('Write down the plans')
expect(await seen(stranger, new URL(pulled).pathname)).toBe(404)
await go(page, '/alice.test/vault/pulls')
await anew(page, (expect) => expect(page.getByRole('link', { name: 'Write down the plans' })).toBeVisible())
})
const readers = (page) => page.locator('section', { hasText: 'Readers' }).locator('li', { hasText: 'bob.test' })
test('adding a reader', async () => {
await go(page, '/alice.test/vault/settings/access')
await page.getByLabel('Add a reader').fill('bob.test')
await page.locator('form', { has: page.getByLabel('Add a reader') }).getByRole('button', { name: 'Add' }).click()
await expect(readers(page)).toBeVisible()
expect(knotless('alice', 'members', vault)).toContain(`${who.bob.did}\tread`)
await anew(page, (expect) => expect(readers(page)).toBeVisible())
})
test('logging in as a reader', async () => {
await go(page, '/alice.test/vault')
await logIn(page, 'bob', async () => {
await page.getByLabel('Your account').click()
await page.getByRole('button', { name: 'Add account' }).click()
})
await expect.poll(() => loggedInAs(page)).toBe('bob.test')
await anew(page, (expect) => expect(page.getByText('Plans not yet for anyone else.')).toBeVisible())
// Not the owner: no settings.
await expect(page.getByRole('link', { name: 'Settings', exact: true })).toHaveCount(0)
})
let asked
test('opening an issue as a reader', async () => {
await go(page, '/alice.test/vault/issues/new')
await page.getByRole('textbox', { name: 'Title' }).fill('The plans need dates')
await page.getByRole('textbox', { name: 'Description' }).fill('Without them, nothing ships.')
await page.getByRole('button', { name: 'Open issue' }).click()
await page.waitForURL(/\/alice\.test\/vault\/issue\//)
asked = page.url()
await expect(page.getByRole('heading', { name: 'The plans need dates' })).toBeVisible()
// In the space, where alice reads it, and not in bob's public repo.
expect(knotless('alice', 'list', vault, '--issues')).toContain('The plans need dates')
expect((await records('bob', 'dev.knotless.issue')).map((r) => r.value.title)).not.toContain('The plans need dates')
expect(await seen(stranger, new URL(asked).pathname)).toBe(404)
await go(page, '/alice.test/vault/issues')
await anew(page, (expect) => expect(page.getByRole('link', { name: 'The plans need dates' })).toBeVisible())
})
test('commenting, reacting and editing as a reader', async () => {
await go(page, asked)
await page.getByPlaceholder('Leave a comment').fill('By the end of the month?')
await page.getByRole('button', { name: 'Comment', exact: true }).click()
await expect(comment(page, 'By the end of the month?')).toBeVisible()
expect(knotless('alice', 'show', vault, uriOf(asked))).toContain('By the end of the month?')
expect((await records('bob', 'dev.knotless.comment')).map((r) => r.value.body)).not.toContain('By the end of the month?')
await anew(page, (expect) => expect(comment(page, 'By the end of the month?')).toBeVisible())
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')).toEqual([])
await anew(page, (expect) => expect(page.locator('.reactions').first().getByRole('button', { name: 'Eyes: 1' })).toBeVisible())
await choose(page, 'By the end of the month?', 'Edit')
await editor(page, 'Update comment').fill('By the end of next month?')
await page.getByRole('button', { name: 'Update comment' }).click()
await expect(comment(page, 'By the end of next month?')).toContainText(', edited')
await anew(page, (expect) => expect(comment(page, 'By the end of next month?')).toContainText(', edited'))
})
test('commenting on a pull as a reader', async () => {
await go(page, pulled)
await page.getByPlaceholder('Leave a comment').fill('Looks right to me.')
await page.getByRole('button', { name: 'Comment', exact: true }).click()
await expect(comment(page, 'Looks right to me.')).toBeVisible()
await anew(page, (expect) => expect(comment(page, 'Looks right to me.')).toBeVisible())
})
test('hearing of it as the owner', async () => {
await switchTo(page, 'alice.test')
await go(page, '/notifications')
await anew(page, (expect) => expect(page.getByText('The plans need dates').first()).toBeVisible())
})
test('labelling, closing and hiding as the owner', async () => {
await go(page, asked)
await page.locator('.label-menu summary').click()
await page.locator('.label-menu').getByRole('button', { name: 'kind/secret' }).click()
await expect(page.locator('.label-menu').getByRole('button', { name: 'kind/secret' })).toHaveAttribute('aria-pressed', 'true')
await anew(page, (expect) => expect(page.locator('.labels').getByText('kind/secret')).toBeVisible())
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())
expect(knotless('alice', 'list', vault, '--issues')).toMatch(/^issue\tclosed\t.*\tThe plans need dates$/m)
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())
await choose(page, 'By the end of next month?', 'Hide this comment')
await expect(comment(page, 'By the end of next month?')).toHaveCount(0)
await anew(page, (expect) => expect(comment(page, 'By the end of next month?')).toHaveCount(0))
})
test('merging a private pull', async () => {
await go(page, pulled)
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 anew(page, (expect) => expect(page.locator('.event', { hasText: 'Merged as' })).toBeVisible())
expect(knotless('alice', 'refs', vault)).toMatch(/refs\/heads\/main/)
await go(page, '/alice.test/vault/commits/main')
await anew(page, (expect) => expect(page.getByText('Write down the plans').first()).toBeVisible())
})
test('forking a private repository', async () => {
await switchTo(page, 'bob.test')
await go(page, '/alice.test/vault')
await page.locator('.fork-menu summary').click()
await page.getByRole('button', { name: 'Create fork' }).click()
await page.waitForURL(url('/bob.test/vault'))
await expect(page.getByText('Forked from')).toBeVisible()
await expect(page.locator('.tag.private').first()).toBeVisible()
// Private as what it forked: in no public repo, and to no one else.
expect((await records('bob', 'dev.knotless.repo')).map((r) => r.uri.split('/').pop())).not.toContain('vault')
expect(await seen(stranger, '/bob.test/vault')).toBe(404)
await anew(page, (expect) => expect(page.getByText('Plans not yet for anyone else.')).toBeVisible())
})
test('removing a reader', async () => {
await switchTo(page, 'alice.test')
await go(page, '/alice.test/vault/settings/access')
await readers(page).getByRole('button', { name: 'Remove' }).click()
await expect(readers(page)).toHaveCount(0)
expect(knotless('alice', 'members', vault)).not.toContain(who.bob.did)
await anew(page, (expect) => expect(readers(page)).toHaveCount(0))
// bob reads it no more, once the server asks the space again who may,
// which it does now and then rather than on every look.
await switchTo(page, 'bob.test')
await go(page, '/alice.test/vault')
await anew(page, (expect) => expect(page.getByText('Not Found')).toBeVisible(), 60_000)
})
test('renaming and deleting a private repository', async () => {
await switchTo(page, 'alice.test')
await go(page, '/alice.test/diary/settings')
await page.getByLabel('Name', { exact: true }).fill('journal')
await page.getByRole('button', { name: 'Rename' }).click()
await page.waitForURL(/\/alice\.test\/journal/)
expect(knotless('alice', 'ls', 'alice.test')).toMatch(/^journal\t.*private$/m)
await anew(page, async (expect) => {
await go(page, '/alice.test/diary')
expect(new URL(page.url()).pathname).toMatch(/^\/alice\.test\/journal/)
})
await go(page, '/alice.test/journal/settings')
await page.getByRole('button', { name: 'Delete', exact: true }).click()
const dialog = page.locator('#delete-repo')
await dialog.getByRole('textbox').fill('alice.test/journal')
await dialog.getByRole('button', { name: 'Delete this repository' }).click()
await page.waitForURL((u) => !u.pathname.startsWith('/alice.test/journal'))
expect(knotless('alice', 'ls', 'alice.test')).not.toMatch(/^journal\t/m)
await go(page, '/alice.test/journal')
await anew(page, (expect) => expect(page.getByText('Not Found')).toBeVisible())
})
harness/e2e/setup.mjs+21−1
@@ -4,6 +4,7 @@
// world go to the flows in E2E_WORLD. Returns the teardown.
import { execFileSync, spawn } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import { createInterface } from 'node:readline'
import { join } from 'node:path'
import { create, environment, port, root, serve } from '../shots/world.mjs'
@@ -15,6 +16,25 @@ export default async function setup() {
const bob = { cwd: join(world.dir, 'bob/tally'), env: environment(world.net, world.dir, 'bob') }
execFileSync('git', ['tag', 'v0.1.0', 'main'], bob)
execFileSync('git', ['push', '-q', 'origin', 'v0.1.0'], bob)
// On the spaces alpha, a private repository of alice's for the private
// flows: a main and a branch off it to pull, pushed from the command
// line as no page pushes.
if (world.net.spaces) {
const vault = join(world.dir, 'alice/vault')
const alice = { cwd: vault, env: environment(world.net, world.dir, 'alice') }
mkdirSync(vault, { recursive: true })
execFileSync(join(root, 'target/debug/knotless'), ['create', 'knotless://alice.test/vault', '--private', '--description', 'What we keep to ourselves'], alice)
execFileSync('git', ['init', '-q', '-b', 'main'], alice)
writeFileSync(join(vault, 'README.md'), '# Vault\n\nPlans not yet for anyone else.\n')
execFileSync('git', ['add', '-A'], alice)
execFileSync('git', ['commit', '-q', '-m', 'Start the vault'], alice)
execFileSync('git', ['push', '-q', 'knotless://alice.test/vault', 'main'], alice)
execFileSync('git', ['switch', '-q', '-c', 'plans'], alice)
writeFileSync(join(vault, 'PLANS.md'), '# Plans\n\nShip tally 1.0.\n')
execFileSync('git', ['add', '-A'], alice)
execFileSync('git', ['commit', '-q', '-m', 'Write down the plans'], alice)
execFileSync('git', ['push', '-q', 'knotless://alice.test/vault', 'plans'], alice)
}
world.server.kill()
const { cert, key, host } = world.net.gateway
const proxy = spawn('node', [join(root, 'harness/tls-proxy.mjs'), cert, key, String(port)], {
@@ -30,7 +50,7 @@ export default async function setup() {
world.stop()
throw e
}
process.env.E2E_WORLD = JSON.stringify({ base, pds: world.net.pds, accounts: world.net.accounts, views: world.views })
process.env.E2E_WORLD = JSON.stringify({ base, pds: world.net.pds, plc: world.net.plc, dir: world.dir, accounts: world.net.accounts, views: world.views })
return () => {
server.kill()
proxy.kill()
AGENTS.md+6−3
@@ -224,9 +224,12 @@ on every pull. `main` deploys only once they all pass:
write the pages make, from starring and commenting to merging,
settings, organizations, transfers and deleting an account's data,
as two accounts, each read back on the page and from the PDS
(`harness/e2e/flows.spec.mjs`). A flow that fails leaves its page
among the check's outputs. A new control that writes gets a flow
there.
(`harness/e2e/flows.spec.mjs`); then a private repository's, over
the spaces alpha, each read back from its space and as not there to
anyone else (`private.spec.mjs`, `just e2e-private`). A flow that
fails leaves its page among the check's outputs. A new control that
writes gets a flow there, and in the private flows too when a private
repository reads it back differently.
A push to `main` at the same tree as a pull's run that passed, as
`knotless merge` leaves it, does not run these again: the runner files
Justfile+7−0
@@ -203,6 +203,13 @@ e2e *args: web
{{cargo}} build --bins
cd harness && E2E=1 npx playwright test -c e2e/playwright.config.mjs {{args}}
# The private flows (harness/e2e/private.spec.mjs): a private repository
# used on the pages, over a world on the spaces alpha `spaces` names, a
# build of test.yml's ATPROTO_COMMIT.
e2e-private *args: web
{{cargo}} build --bins
cd harness && KNOTLESS_DEV_ENV={{spaces}} E2E=1 npx playwright test -c e2e/playwright.config.mjs {{args}}
# Agents doing a day's git work through the MCP server, one fresh session
# per step: `harness/scenarios/tracks`, or only the tracks named. Reports
# what tripped them up under target/scenarios/. Each run costs model usage.