chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The e2e check uses a private repository, over the spaces alpha
.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. | ||