chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The pages' writes are checked end to end in a browser
.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() | ||