~ 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 pages' writes are checked end to end in a browser

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:harness/e2e
.knotless/workflows/deploy.yml+3−3
@@ -126,10 +126,10 @@ steps:
- name: checks done
run: |
[ ! -e ~/.moved ] || exit 0
# 25 minutes over all six, and never a failure: the next step judges.
# 25 minutes over all seven, and never a failure: the next step judges.
unless-moved on-host '
end=$(($(date +%s) + 1500))
for wf in check test web lexicons shots docs; do
for wf in check test web lexicons shots docs e2e; do
left=$((end - $(date +%s)))
[ "$left" -gt 0 ] || break
knotless checks "$REPO" --ref "$REF" --commit "$SHA" --workflow "$wf" --wait "$left" || true
@@ -140,7 +140,7 @@ steps:
[ ! -e ~/.moved ] || exit 0
if ! on-host '
end=$(($(date +%s) + 1500))
for wf in check test web lexicons shots docs; do
for wf in check test web lexicons shots docs e2e; do
left=$((end - $(date +%s)))
[ "$left" -gt 0 ] || left=1
out=$(knotless checks "$REPO" --ref "$REF" --commit "$SHA" --workflow "$wf" --wait "$left")
.knotless/workflows/e2e.ymlnew+77−0
@@ -0,0 +1,77 @@
# The pages used as people use them, as `just e2e` runs them: in Chromium,
# logging in through the PDS's own approval page, then starring,
# following, opening an issue, commenting, reacting, closing and
# labelling, 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.
#
# 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
# 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
# heads merged with their base, that change the pages, the server and
# client they log in to and write through, the world and harness they run
# over, or this file. A pull changing none passes at once, noted as
# skipped.
on:
push: {refs: [refs/heads/main]}
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]
env:
PLAYWRIGHT_BROWSERS_PATH: /opt/browsers
steps:
- name: node
run: |
[ -x /opt/node/bin/node ] || curl -fsSL https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-x64.tar.xz | tar -xJ -C /opt/node --strip-components 1
/opt/node/bin/node --version
- name: packages
run: |
export PATH=/opt/node/bin:$PATH
for dir in web harness; do
lock=$(sha256sum $dir/package-lock.json)
[ "$(cat $dir/node_modules/.lock 2>/dev/null)" = "$lock" ] && continue
(cd $dir && npm ci --no-audit --no-fund)
echo "$lock" > $dir/node_modules/.lock
done
- name: chromium
run: |
export PATH=/opt/node/bin:$PATH
cd harness
# The browser, into /opt/browsers once for each Playwright version.
npx playwright install chromium
# The libraries it needs, which `install-deps` would fetch with apt on
# every run: their .debs are kept, fetched again only when this
# Playwright or this Debian asks for others, and installed offline.
pkgs=$(npx playwright install-deps --dry-run chromium | sed -n "s/.*--no-install-recommends \([^\"']*\).*/\1/p")
[ -n "$pkgs" ] || { echo "no packages in: $(npx playwright install-deps --dry-run chromium)"; exit 1; }
debs=/opt/chromium-debs
want="$(. /etc/os-release && echo "$VERSION_ID") $pkgs"
if [ "$(cat $debs/.want 2>/dev/null)" != "$want" ]; then
rm -rf "${debs:?}"/* && mkdir -p $debs/partial
apt-get update -qq
apt-get install -y -qq --no-install-recommends --download-only -o Dir::Cache::archives=$debs $pkgs
echo "$want" > $debs/.want
fi
dpkg -i $debs/*.deb > /dev/null
- name: build
run: |
export PATH=/opt/node/bin:$PATH
cargo build --bins -p knotless -p knotless-serve
cd web && npm run build
- name: flows
run: |
export PATH=/opt/node/bin:$PATH
status=0
(cd harness && 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
[ -f "$shot" ] && cp "$shot" "$KNOTLESS_RUN_OUTPUTS/$(basename "$(dirname "$shot")").png"
done
fi
exit $status
.knotless/workflows/publish-lexicons.yml+1−1
@@ -39,7 +39,7 @@ steps:
[ "$KNOTLESS_RUN_REF" = refs/heads/main ] || exit 0
curl -fsSL https://knotless.dev/install.sh | sh
end=$(($(date +%s) + 1500))
for wf in check test web lexicons shots docs; do
for wf in check test web lexicons shots docs e2e; do
left=$((end - $(date +%s)))
[ "$left" -gt 0 ] || left=1
out=$(~/.local/bin/knotless checks "$REPO" --ref "$KNOTLESS_RUN_REF" --commit "$KNOTLESS_RUN_SHA" --workflow "$wf" --wait "$left")
.knotless/workflows/release.yml+1−1
@@ -41,7 +41,7 @@ steps:
run: |
[ ! -e ~/.done ] && [ "$KNOTLESS_RUN_REF" = refs/heads/main ] || exit 0
end=$(($(date +%s) + 1500))
for wf in check test web lexicons shots docs; do
for wf in check test web lexicons shots docs e2e; do
left=$((end - $(date +%s)))
[ "$left" -gt 0 ] || left=1
out=$(knotless checks "$REPO" --ref "$KNOTLESS_RUN_REF" --commit "$KNOTLESS_RUN_SHA" --workflow "$wf" --wait "$left")
docs/design/packages.md+1−1
@@ -319,7 +319,7 @@ no one using knotless sees.
Publishing follows from the version. The `release` workflow runs on each
push to main that changes `Cargo.toml`. If that version has no release
yet, it waits for the commit's six checks to pass, builds the client for
yet, it waits for the commit's seven checks to pass, builds the client for
macOS and Linux, each on aarch64 and x86_64, and publishes the four
archives as the owner, with the version's section of the changelog as the
notes and the commit it was built from. It then tags that commit
docs/development.md+34−3
@@ -15,7 +15,7 @@ to CI.
| [`web`](../web) | the SvelteKit pages |
| [`docs-site`](../docs-site) | these docs as the site the pages serve at `/docs` |
| [`lexicons`](../lexicons/dev/knotless) | the `dev.knotless.*` schemas |
| [`harness`](../harness) | the test PDS, coverage, screenshots and scenarios |
| [`harness`](../harness) | the test PDS, coverage, screenshots, end-to-end flows and scenarios |
| [`deploy`](../deploy) | how knotless.dev is deployed |
| [`.knotless/workflows`](../.knotless/workflows) | this repository's CI |
@@ -205,6 +205,37 @@ A world has no session to sign them in, so `shots-live` skips them.
`just shots-linux` runs the check as CI does, in Playwright's Linux image
under Docker.
## End to end
The shots show what the pages draw. The end-to-end flows
([`harness/e2e`](../harness/e2e)) show that what they do lands. In
Chromium, as bob and then alice, they:
- log in through the PDS's own approval page
- star, follow, open an issue, comment, react, close and reopen
- label as the repository's owner, logged in to a second account
- switch back, and log out of each
Each write is checked three ways: on the page at once, as a record in the
PDS, and on the page read anew from the index.
```sh
just e2e # a world of its own, then every flow, in order
just e2e -g 'logging in through|starring' # or only some, the login first
```
The run seeds a world as the shots' is, on ports of its own, so it can run
beside `shots-up`. It writes to that world as it goes, so the world is
never one the shots read. knotless-serve then serves the world again as a
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 new control that writes from the pages gets a flow in
[`flows.spec.mjs`](../harness/e2e/flows.spec.mjs).
## Scenarios
Scenarios ([`harness/scenarios`](../harness/scenarios)) put agents to a
@@ -254,8 +285,8 @@ knotless pull knotless://chadtmiller.com/knotless main \
The title and body follow `.knotless/pull.md`; an issue,
`.knotless/issue.md`.
Six checks run on it: `check` (fmt and clippy), `test` (100% coverage),
`web`, `lexicons`, `shots` and `docs`. To see how they went:
Seven checks run on it: `check` (fmt and clippy), `test` (100% coverage),
`web`, `lexicons`, `shots`, `docs` and `e2e`. To see how they went:
```sh
knotless checks knotless://chadtmiller.com/knotless --pull <at-uri> --wait 600 --log
harness/e2e/flows.spec.mjsnew+179−0
@@ -0,0 +1,179 @@
// 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 { 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
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 `check` passes on `page` read anew: the index follows the PDS a
/// moment behind.
const anew = (page, check) =>
expect(async () => {
await page.reload()
await check()
}).toPass({ timeout: 15_000 })
/// 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 page.waitForURL((u) => u.origin === new URL(world.pds).origin)
await page.getByRole('textbox', { name: 'Password' }).fill(who[name].password)
await page.getByRole('button', { name: 'Sign in' }).click()
await page.getByRole('button', { name: /^(accept|authorize|allow)$/i }).click()
await page.waitForURL(back)
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 }) => {
page = await browser.newPage()
})
test.afterAll(async () => {
await page.close()
})
test('logging in through the PDS', async () => {
await page.goto(url('/alice.test/tally'))
await logIn(page, 'bob')
await expect.poll(() => loggedInAs(page)).toBe('bob.test')
})
test('starring and unstarring', async () => {
await page.goto(url('/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(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(page.getByRole('button', { name: 'Star', exact: true })).toBeVisible())
})
test('following and unfollowing', async () => {
await page.goto(url('/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(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(page.getByRole('button', { name: 'Follow', exact: true })).toBeVisible())
})
let opened
test('opening an issue', async () => {
await page.goto(url('/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 page.goto(url('/alice.test/tally/issues'))
await anew(page, () => expect(page.getByRole('link', { name: 'Counts a trailing newline as a line' })).toBeVisible())
})
test('commenting', async () => {
await page.goto(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(page.getByText('Ran it against wc -l on a few files: they agree.')).toBeVisible())
})
test('reacting', async () => {
await page.goto(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(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 page.goto(opened)
await page.getByRole('button', { name: 'Close issue' }).click()
await expect(page.getByRole('button', { name: 'Reopen issue' })).toBeVisible()
await anew(page, () => 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(page.getByRole('button', { name: 'Close issue' })).toBeVisible())
})
test('logging in to a second account and labelling as the owner', async () => {
await page.goto(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')
await page.locator('.label-menu summary').click()
await page.locator('.label-menu').getByRole('button', { name: 'kind/bug' }).click()
await expect(page.locator('.label-menu').getByRole('button', { name: 'kind/bug' })).toHaveAttribute('aria-pressed', 'true')
await anew(page, () => expect(page.locator('.labels').getByText('kind/bug')).toBeVisible())
})
test('switching back and logging out', async () => {
await page.goto(opened)
await page.getByLabel('Your account').click()
await page.locator('.account .menu-item.switch', { hasText: 'bob.test' }).click()
await expect.poll(() => loggedInAs(page)).toBe('bob.test')
// Logging bob out goes on as alice, the other account the browser holds;
// logging her out leaves no one.
await page.getByLabel('Your account').click()
await page.getByRole('button', { name: 'Log out' }).click()
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 page.goto(url('/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()
})
harness/e2e/playwright.config.mjsnew+41−0
@@ -0,0 +1,41 @@
// The pages used as people use them: logged in through the PDS's own
// approval page, writing from the pages, and reading back what they wrote,
// on the page and in the PDS. The shots show what the pages draw; this
// shows that what they do lands.
//
// just e2e a world of its own, seeded as the shots' is, and the flows
//
// The world is written to as it runs, so it is never the one `shots-up`
// keeps: setup.mjs seeds one for each run, on ports of its own (E2E=1).
// The flows go in order in one worker, as each writes to the world the
// next reads.
import { defineConfig } from '@playwright/test'
import { join } from 'node:path'
import { root } from '../shots/world.mjs'
// Without it, the world would take the shots' ports, and `shots-up`'s.
if (!process.env.E2E) throw new Error('run the flows with E2E=1, as `just e2e` does')
export default defineConfig({
testDir: '.',
testMatch: 'flows.spec.mjs',
globalSetup: './setup.mjs',
outputDir: join(root, 'target/e2e/results'),
workers: 1,
// Seeding takes a while; a flow, waiting on the index, much less.
timeout: 60_000,
expect: { timeout: 15_000 },
reporter: [['list']],
use: {
browserName: 'chromium',
viewport: { width: 1280, height: 800 },
locale: 'en-US',
// The pages are reached as a PDS reaches a gateway: over https, at the
// harness's gateway hostname, with the harness's own certificate.
ignoreHTTPSErrors: true,
launchOptions: { args: ['--host-resolver-rules=MAP gateway.knotless.internal 127.0.0.1'] },
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
})
harness/e2e/setup.mjsnew+34−0
@@ -0,0 +1,34 @@
// Before the flows: a world seeded as the shots' is, then knotless-serve
// over it again as a gateway, behind the https front the PDS sends logins
// back to (harness/tls-proxy.mjs). Where the pages are and who is in the
// world go to the flows in E2E_WORLD. Returns the teardown.
import { spawn } from 'node:child_process'
import { createInterface } from 'node:readline'
import { join } from 'node:path'
import { create, port, root, serve } from '../shots/world.mjs'
export default async function setup() {
const world = await create()
world.server.kill()
const { cert, key, host } = world.net.gateway
const proxy = spawn('node', [join(root, 'harness/tls-proxy.mjs'), cert, key, String(port)], {
stdio: ['pipe', 'pipe', 'inherit'],
})
const [line] = await createInterface({ input: proxy.stdout })[Symbol.asyncIterator]().next().then((r) => [r.value])
const base = `https://${host}:${line.trim()}`
let server
try {
server = await serve(world, { gateway: base })
} catch (e) {
proxy.kill()
world.stop()
throw e
}
process.env.E2E_WORLD = JSON.stringify({ base, pds: world.net.pds, accounts: world.net.accounts, views: world.views })
return () => {
server.kill()
proxy.kill()
world.stop()
}
}
harness/shots/world.mjs+13−5
@@ -25,8 +25,9 @@ const fixture = join(here, 'fixture')
const bin = join(root, 'target/debug')
// Where the world answers, and the pages behind it on the next two ports:
// three of their own for each checkout, from its path, so the shots of two
// side by side do not meet; SHOTS_PORT moves them.
const own = 20000 + 3 * (createHash('sha256').update(root).digest().readUInt16BE(0) % 3000)
// side by side do not meet, and three more for the end-to-end run's world
// (E2E=1), so it goes beside `shots-up`'s; SHOTS_PORT moves them.
const own = (process.env.E2E ? 29000 : 20000) + 3 * (createHash('sha256').update(root).digest().readUInt16BE(0) % 3000)
export const port = Number(process.env.SHOTS_PORT ?? own)
export const base = `http://127.0.0.1:${port}`
export const saved = join(root, 'target/shots/world.json')
@@ -118,8 +119,10 @@ export const pages = (at, data, build = join(root, 'web/build')) =>
})
/// knotless-serve from this build, over the world's index and mirrors, on
/// `at`: in front of the pages, unless `alone`.
export const serve = async (world, { at = port, alone = false } = {}) => {
/// `at`: in front of the pages, unless `alone`. With `gateway`, the https
/// front a PDS sends logins back to, it is a gateway too, which the pages
/// log in to.
export const serve = async (world, { at = port, alone = false, gateway } = {}) => {
const drawn = alone ? null : pages(port + 1, base)
const server = spawn(
join(bin, 'knotless-serve'),
@@ -128,8 +131,13 @@ export const serve = async (world, { at = port, alone = false } = {}) => {
'--index', join(world.dir, 'index.db'),
'--firehose', world.net.pds.replace('http', 'ws'),
...(alone ? [] : ['--web', `http://127.0.0.1:${port + 1}`]),
// atproto takes a did:web with a port only for localhost.
...(gateway ? ['--public-url', `http://localhost:${at}`, '--oauth-url', gateway] : []),
],
{ env: environment(world.net, world.dir, 'server'), stdio: 'inherit' },
{
env: { ...environment(world.net, world.dir, 'server'), ...(gateway ? { KNOTLESS_DID_WEB_SCHEME: 'http' } : {}) },
stdio: 'inherit',
},
)
const kill = () => {
server.kill()
AGENTS.md+15−7
Justfile+10−0