0
2
ci: The pages and grain-serve are checked together, end to end
Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.
.knotless/workflows/e2e.ymlnew+164−0
| @@ -0,0 +1,164 @@ | ||
# The pages used as people use them, as `just e2e` runs them: grain-serve | ||
# and the web's build together, in Chromium, logging in through the PDS's | ||
# own approval page, then each write the pages make, each read back on the | ||
# page and from the PDS (harness/e2e/flows.spec.mjs). Then, over the | ||
# spaces alpha, what an account on a PDS with spaces finds: the Groups tab | ||
# and the groups page (spaces.spec.mjs). rust.yml and harness.yml check grain-serve, check.yml | ||
# and test.yml the web; this, that the two work together. Each flow that | ||
# fails leaves the page as it was then among the check's outputs, as a | ||
# picture, beside what the servers said. | ||
# | ||
# 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. Until this workflow runs on | ||
# main, a pull's run has none of it kept, and builds the alpha again. | ||
image: rust:1.98 | ||
runners: [runner.knotless.dev] | ||
# Every push to main, and pulls into it or a feature/ branch that change | ||
# the pages, the server they log in to and write through, the lexicons | ||
# between them, the 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/grain/**, | ||
crates/grain-serve/**, | ||
Cargo.toml, | ||
Cargo.lock, | ||
package.json, | ||
package-lock.json, | ||
svelte.config.js, | ||
vite.config.ts, | ||
hatk.generated.client.ts, | ||
harness/e2e/**, | ||
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, | ||
/opt/atproto, | ||
node_modules, | ||
harness/node_modules, | ||
] | ||
env: | ||
NODE_VERSION: 24.22.1 | ||
PLAYWRIGHT_BROWSERS_PATH: /opt/browsers | ||
# The spaces alpha the spaces flows run over, as harness.yml pins it: | ||
# the two move together. | ||
ATPROTO_COMMIT: 11c5d98cd50261c5e1201fedc5c6c05a7b8d1ccf | ||
ALPHA_BUILD: "1" | ||
steps: | ||
- name: node | ||
run: | | ||
[ "$(/opt/node/bin/node --version 2>/dev/null)" = "v$NODE_VERSION" ] && exit 0 | ||
mkdir -p /opt/node && find /opt/node -mindepth 1 -delete | ||
curl -fsSL "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" | tar -xJ -C /opt/node --strip-components 1 | ||
/opt/node/bin/node --version | ||
- name: web packages | ||
run: | | ||
lock=$(sha256sum package-lock.json) | ||
[ "$(cat node_modules/.lock 2>/dev/null)" = "$lock" ] && exit 0 | ||
PATH=/opt/node/bin:$PATH npm ci --no-audit --no-fund | ||
echo "$lock" > node_modules/.lock | ||
- name: harness | ||
run: | | ||
cd harness | ||
lock=$(sha256sum package-lock.json) | ||
[ "$(cat node_modules/.lock 2>/dev/null)" = "$lock" ] && exit 0 | ||
PATH=/opt/node/bin:$PATH npm ci --no-audit --no-fund | ||
echo "$lock" > node_modules/.lock | ||
- 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 | ||
mkdir -p $debs && 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: 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 | ||
mkdir -p /opt/atproto && 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 alpha's PDS needs, which the install | ||
# above skips: SQLite's native module, as better-sqlite3 builds it. | ||
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 | ||
cargo build --locked -p grain-serve --bin grain-serve | ||
npm run build | ||
- name: flows | ||
run: | | ||
export PATH=/opt/node/bin:$PATH | ||
rm -rf target/e2e | ||
status=0 | ||
(cd harness && npx playwright test -c e2e/playwright.config.mjs) || status=$? | ||
# Then the spaces ones, over a world of their own on the alpha. | ||
cp -r target/e2e/logs target/e2e/logs-public 2>/dev/null || true | ||
(cd harness && GRAIN_DEV_ENV=/opt/atproto/packages/dev-env npx playwright test -c e2e/playwright.config.mjs) || status=$? | ||
if [ $status -ne 0 ]; then | ||
# What the servers said, the last of it, for the flows that failed. | ||
# What each failed page held, and the browser said. | ||
for context in target/e2e/*/*/error-context.md; do | ||
[ -f "$context" ] && { echo "--- $context"; head -c 6000 "$context"; echo; } | ||
done | ||
for log in target/e2e/logs-public/*.log target/e2e/logs/*.log; do | ||
[ -f "$log" ] && { echo "--- $log"; grep -v '"url.path":"/\(_app\|atmo\)/' "$log" | tail -n 80 | cut -c1-600; } | ||
done | ||
fi | ||
# The page each failed flow ended on, named for the flow, and the logs. | ||
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 | ||
for log in target/e2e/logs-public/*.log; do | ||
[ -f "$log" ] && cp "$log" "$KNOTLESS_RUN_OUTPUTS/public-$(basename "$log")" | ||
done | ||
for log in target/e2e/logs/*.log; do | ||
[ -f "$log" ] && cp "$log" "$KNOTLESS_RUN_OUTPUTS/spaces-$(basename "$log")" | ||
done | ||
fi | ||
exit $status | ||
harness/e2e/flows.spec.mjsnew+256−0
| @@ -0,0 +1,256 @@ | ||
// What people do on the pages, in order, as alice and bob: each write shown | ||
// at once, kept in the writer's repo on their PDS, and shown again once the | ||
// page is read anew, from grain-serve's index, to the writer and to the | ||
// other account. | ||
| ||
import { expect, test } from "@playwright/test"; | ||
import { BLUESKY_PROFILE } from "./setup.mjs"; | ||
import { | ||
anew, | ||
browse, | ||
eventually, | ||
failed, | ||
go, | ||
logIn, | ||
nav, | ||
photo, | ||
record, | ||
said, | ||
records, | ||
who, | ||
} from "./pages.mjs"; | ||
| ||
test.describe.configure({ mode: "serial" }); | ||
| ||
/// alice's browser and bob's. | ||
let alice; | ||
let bob; | ||
| ||
test.beforeAll(async ({ browser }) => { | ||
alice = await browse(browser); | ||
bob = await browse(browser); | ||
}); | ||
| ||
test.afterAll(async () => { | ||
await alice.context().close(); | ||
await bob.context().close(); | ||
}); | ||
| ||
/// What the browsers said, shown for a flow that failed. | ||
test.afterEach(() => { | ||
const info = test.info(); | ||
if (info.status !== info.expectedStatus && said.length) console.log(said.slice(-40).join("\n")); | ||
said.length = 0; | ||
}); | ||
| ||
const TITLE = "Low tide at Ocean Beach"; | ||
const RETITLED = "Low tide at Ocean Beach, late October"; | ||
/// The gallery alice makes, by its record key. | ||
let rkey; | ||
const galleryPage = () => `/profile/alice.test/gallery/${rkey}`; | ||
/// The gallery page's own title. | ||
const heading = (page, title) => page.getByRole("heading", { level: 1, name: title }); | ||
const galleryLink = (page) => page.locator(`a[href$="/gallery/${rkey}"]`).first(); | ||
| ||
test("logging in through the PDS seeds a profile from Bluesky", async () => { | ||
await go(alice, "/"); | ||
await logIn(alice, "alice"); | ||
// In the background, after the login: from her Bluesky profile. | ||
await eventually( | ||
() => record("alice", "social.grain.actor.profile", "self"), | ||
(r) => expect(r?.value).toMatchObject(BLUESKY_PROFILE), | ||
); | ||
await go(alice, "/profile/alice.test"); | ||
await anew(alice, (expect) => | ||
expect(alice.locator(".profile-name")).toHaveText(BLUESKY_PROFILE.displayName), | ||
); | ||
}); | ||
| ||
test("logging in with no Bluesky profile seeds a bare one", async () => { | ||
await go(bob, "/"); | ||
await logIn(bob, "bob"); | ||
await eventually( | ||
() => record("bob", "social.grain.actor.profile", "self"), | ||
(r) => { | ||
expect(r?.value?.createdAt).toBeTruthy(); | ||
expect(r?.value?.displayName).toBeUndefined(); | ||
}, | ||
); | ||
// A PDS with no spaces: no Groups. | ||
await expect(nav(bob, "Profile")).toBeVisible(); | ||
await expect(nav(bob, "Groups")).toHaveCount(0); | ||
}); | ||
| ||
test("the home feed and the story tray load", async () => { | ||
await go(alice, "/"); | ||
await expect(alice.locator(".story-strip").getByText("Your story")).toBeVisible(); | ||
await expect(alice.getByText("No galleries found.")).toBeVisible(); | ||
// What the two are drawn from, as the browser asks for them. | ||
const asked = await alice.evaluate(async () => { | ||
const said = (r) => r.json().then((body) => ({ status: r.status, body })); | ||
const stories = await fetch("/xrpc/social.grain.story.getAuthors").then(said); | ||
const feeds = await fetch("/xrpc/social.grain.feed.describeFeeds").then(said); | ||
const recent = feeds.body?.feeds?.find((f) => f.name === "recent"); | ||
const q = new URLSearchParams({ feed: recent?.uri ?? "" }); | ||
const feed = await fetch(`/xrpc/social.grain.feed.getFeed?${q}`).then(said); | ||
return { stories, feeds, feed }; | ||
}); | ||
expect(asked.stories.status).toBe(200); | ||
expect(asked.feeds.status).toBe(200); | ||
expect(asked.feed.status, JSON.stringify(asked.feed.body)).toBe(200); | ||
expect(asked.feed.body.items).toEqual([]); | ||
}); | ||
| ||
test("creating a gallery with a photo", async () => { | ||
await go(alice, "/create"); | ||
await alice.locator('input[type="file"]').setInputFiles(await photo(alice, "tide.jpg")); | ||
await alice.getByPlaceholder("Add a title...").fill(TITLE); | ||
await alice.getByPlaceholder(/^Add a description/).fill("The pools the sea leaves behind."); | ||
await alice.getByRole("button", { name: "Next" }).click(); | ||
await alice | ||
.getByPlaceholder("Describe this image (optional)...") | ||
.fill("Rock pools under an orange sky"); | ||
await alice.getByRole("button", { name: "Post", exact: true }).click(); | ||
await alice.waitForURL((u) => u.pathname === "/"); | ||
// In her repo: the gallery, its photo with its blob, and the item joining them. | ||
const [gallery] = (await records("alice", "social.grain.gallery")).filter( | ||
(r) => r.value.title === TITLE, | ||
); | ||
expect(gallery?.value.description).toBe("The pools the sea leaves behind."); | ||
rkey = gallery.uri.split("/").pop(); | ||
const items = (await records("alice", "social.grain.gallery.item")).filter( | ||
(r) => r.value.gallery === gallery.uri, | ||
); | ||
expect(items).toHaveLength(1); | ||
const [shot] = (await records("alice", "social.grain.photo")).filter( | ||
(r) => r.uri === items[0].value.item, | ||
); | ||
expect(shot?.value.alt).toBe("Rock pools under an orange sky"); | ||
expect(shot?.value.photo?.ref?.$link).toBeTruthy(); | ||
// In the feed, and on her profile. | ||
await anew(alice, (expect) => expect(galleryLink(alice)).toBeVisible()); | ||
await go(alice, "/profile/alice.test"); | ||
await anew(alice, (expect) => expect(galleryLink(alice)).toBeVisible()); | ||
await go(alice, galleryPage()); | ||
await expect(heading(alice, TITLE)).toBeVisible(); | ||
}); | ||
| ||
test("favoriting a gallery", async () => { | ||
await go(bob, galleryPage()); | ||
await bob.getByRole("button", { name: "Favorite", exact: true }).first().click(); | ||
await expect(bob.getByRole("button", { name: "Unfavorite" }).first()).toBeVisible(); | ||
const uri = `at://${who.alice.did}/social.grain.gallery/${rkey}`; | ||
await eventually( | ||
() => records("bob", "social.grain.favorite"), | ||
(favs) => expect(favs.map((r) => r.value.subject)).toContain(uri), | ||
); | ||
await anew(bob, (expect) => | ||
expect(bob.getByRole("button", { name: "Unfavorite" }).first()).toBeVisible(), | ||
); | ||
}); | ||
| ||
const SAID = "The light on the left pool is something else."; | ||
| ||
test("commenting on a gallery", async () => { | ||
await go(bob, galleryPage()); | ||
const box = bob.getByPlaceholder("Add a comment...").first(); | ||
await box.fill(SAID); | ||
await box.press("Enter"); | ||
await expect(bob.getByText(SAID)).toBeVisible(); | ||
await expect(box).toHaveValue(""); | ||
const comments = await records("bob", "social.grain.comment"); | ||
expect(comments.map((r) => r.value.text)).toContain(SAID); | ||
await anew(bob, (expect) => expect(bob.getByText(SAID)).toBeVisible()); | ||
// And to alice. | ||
await go(alice, galleryPage()); | ||
await anew(alice, (expect) => expect(alice.getByText(SAID)).toBeVisible()); | ||
}); | ||
| ||
test("following", async () => { | ||
await go(bob, "/profile/alice.test"); | ||
await bob.getByRole("button", { name: "Follow", exact: true }).click(); | ||
await expect(bob.getByRole("button", { name: "Following", exact: true })).toBeVisible(); | ||
await eventually( | ||
() => records("bob", "social.grain.graph.follow"), | ||
(follows) => expect(follows.map((r) => r.value.subject)).toContain(who.alice.did), | ||
); | ||
await anew(bob, (expect) => | ||
expect(bob.getByRole("button", { name: "Following", exact: true })).toBeVisible(), | ||
); | ||
}); | ||
| ||
test("notifications show alice what bob did", async () => { | ||
await go(alice, "/notifications"); | ||
const from = (what) => alice.locator(".notif", { hasText: what }); | ||
await anew(alice, async (expect) => { | ||
await expect(from("followed you")).toContainText("bob.test"); | ||
await expect(from("favorited your gallery")).toContainText("bob.test"); | ||
await expect(from("commented on your gallery")).toContainText(SAID); | ||
}); | ||
}); | ||
| ||
// The gallery menu offers editing to one account alone, so the editor is | ||
// reached by its address. | ||
test("editing a gallery", async () => { | ||
await go(alice, `${galleryPage()}/edit`); | ||
const title = alice.getByPlaceholder("Add a title..."); | ||
await expect(title).toHaveValue(TITLE); | ||
await title.fill(RETITLED); | ||
await alice.getByRole("button", { name: "Save", exact: true }).click(); | ||
await alice.waitForURL((u) => u.pathname === galleryPage()); | ||
// In her repo, then in the index, then on the page she is sent back to. | ||
const kept = await record("alice", "social.grain.gallery", rkey); | ||
expect(kept?.value.title).toBe(RETITLED); | ||
const indexed = await alice.evaluate( | ||
(uri) => | ||
fetch(`/xrpc/social.grain.gallery.getGallery?gallery=${encodeURIComponent(uri)}`).then((r) => | ||
r.json(), | ||
), | ||
kept.uri, | ||
); | ||
expect(indexed.gallery?.title).toBe(RETITLED); | ||
await expect(heading(alice, RETITLED)).toBeVisible(); | ||
await go(bob, galleryPage()); | ||
await anew(bob, (expect) => expect(heading(bob, RETITLED)).toBeVisible()); | ||
}); | ||
| ||
test("editing a profile", async () => { | ||
await go(alice, "/settings/profile"); | ||
const name = alice.getByPlaceholder("Your name"); | ||
await expect(name).toHaveValue(BLUESKY_PROFILE.displayName); | ||
await name.fill("Alice in Tidepools"); | ||
await alice.getByPlaceholder("Tell us about yourself").fill("Low tides and long exposures."); | ||
await alice.getByRole("button", { name: "Save", exact: true }).click(); | ||
await expect(alice.getByText("Profile saved")).toBeVisible(); | ||
const kept = await record("alice", "social.grain.actor.profile", "self"); | ||
expect(kept?.value).toMatchObject({ | ||
displayName: "Alice in Tidepools", | ||
description: "Low tides and long exposures.", | ||
}); | ||
await go(bob, "/profile/alice.test"); | ||
await anew(bob, (expect) => | ||
expect(bob.locator(".profile-name")).toHaveText("Alice in Tidepools"), | ||
); | ||
}); | ||
| ||
test("deleting a gallery", async () => { | ||
await go(alice, galleryPage()); | ||
await alice.locator(".meta-top").getByRole("button", { name: "More options" }).click(); | ||
alice.once("dialog", (d) => d.accept()); | ||
await alice.getByRole("button", { name: "Delete gallery" }).click(); | ||
await alice.waitForURL( | ||
(u) => u.pathname.startsWith("/profile/alice.test") && !u.pathname.includes("/gallery/"), | ||
); | ||
expect(await record("alice", "social.grain.gallery", rkey)).toBeNull(); | ||
const items = await records("alice", "social.grain.gallery.item"); | ||
expect(items.filter((r) => r.value.gallery.endsWith(`/${rkey}`))).toEqual([]); | ||
await anew(alice, (expect) => expect(galleryLink(alice)).toHaveCount(0)); | ||
await go(bob, "/"); | ||
await anew(bob, (expect) => expect(galleryLink(bob)).toHaveCount(0)); | ||
}); | ||
| ||
// Last: what every page above asked of grain-serve was answered, rather | ||
// than failed or refused as a method it does not serve. | ||
test("every call the pages made was answered", async () => { | ||
expect([...failed].sort()).toEqual([]); | ||
}); | ||
harness/e2e/pages.mjsnew+142−0
| @@ -0,0 +1,142 @@ | ||
// What the flows share: the world they run over, and how a page is opened, | ||
// logged in to and written from, as a person does it. | ||
| ||
import { expect } from "@playwright/test"; | ||
| ||
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; | ||
| ||
/// What the pages called of grain-serve that it failed to answer, as | ||
/// `<status> <nsid>`, across every page the flows opened. | ||
export const failed = new Set(); | ||
| ||
/// What the browsers said that a page did not show: errors, and requests | ||
/// that failed, newest last. | ||
export const said = []; | ||
| ||
/// A browser of its own, logged in to no one, its page ready to open. It | ||
/// reaches the pages, the PDS and the PLC directory and nothing else: what | ||
/// the pages ask of plc.directory, as they do to find a PDS, the harness's | ||
/// directory answers, and anything else off the world is refused, so no | ||
/// flow reads or writes past it. | ||
export const browse = async (browser) => { | ||
const context = await browser.newContext(); | ||
const plc = new URL(world.plc); | ||
const local = new Set(["127.0.0.1", "localhost", world.host]); | ||
await context.route( | ||
(u) => !local.has(u.hostname), | ||
async (route) => { | ||
const asked = new URL(route.request().url()); | ||
if (asked.hostname !== "plc.directory") return route.abort("blockedbyclient"); | ||
const res = await fetch(new URL(asked.pathname + asked.search, plc)); | ||
await route.fulfill({ | ||
status: res.status, | ||
contentType: res.headers.get("content-type") ?? "application/json", | ||
headers: { "access-control-allow-origin": "*" }, | ||
body: Buffer.from(await res.arrayBuffer()), | ||
}); | ||
}, | ||
); | ||
const page = await context.newPage(); | ||
// What the browser said, for a flow that fails to show. | ||
page.on("console", (m) => m.type() === "error" && said.push(`console: ${m.text()}`)); | ||
page.on("pageerror", (e) => said.push(`page error: ${e.message}`)); | ||
page.on("requestfailed", (r) => said.push(`failed: ${r.url()} ${r.failure()?.errorText}`)); | ||
page.on("response", (res) => { | ||
const at = new URL(res.url()); | ||
if (at.origin === world.base && at.pathname.startsWith("/xrpc/") && res.status() >= 500) { | ||
failed.add(`${res.status()} ${at.pathname.slice("/xrpc/".length)}`); | ||
} | ||
}); | ||
return page; | ||
}; | ||
| ||
/// The records of `collection` in `name`'s public 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 ?? []; | ||
}; | ||
| ||
/// The record `rkey` of `collection` in `name`'s public repo: null for none. | ||
export const record = async (name, collection, rkey) => { | ||
const asked = `${world.pds}/xrpc/com.atproto.repo.getRecord?repo=${who[name].did}&collection=${collection}&rkey=${rkey}`; | ||
const res = await fetch(asked); | ||
return res.ok ? res.json() : null; | ||
}; | ||
| ||
/// 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: what one account wrote reaches | ||
/// another's page through the index, and a page drawn from a cached answer | ||
/// is read again rather than waited on. `check` is handed an `expect` that | ||
/// gives up soon. | ||
export const anew = (page, check, timeout = 30_000) => | ||
expect(async () => { | ||
await page.reload(); | ||
await ready(page); | ||
await check(expect.configure({ timeout: 3_000 })); | ||
}).toPass({ timeout }); | ||
| ||
/// Until `check` passes on what `read` answers, read anew each time. | ||
export const eventually = (read, check, timeout = 15_000) => | ||
expect(async () => check(await read())).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(); | ||
}; | ||
| ||
/// Logs in as `name` from the sidebar's button: the handle in the dialog it | ||
/// opens, 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) => { | ||
const back = page.url(); | ||
await page.locator(".sidebar-top").getByRole("button", { name: "Sign in" }).click(); | ||
const dialog = page.locator("dialog[open]"); | ||
await dialog.getByPlaceholder("e.g. user.bsky.social").fill(who[name].handle); | ||
await dialog.getByRole("button", { name: "Sign in", exact: true }).click(); | ||
await approve(page, name); | ||
await page.waitForURL(back); | ||
await ready(page); | ||
await expect(page.getByRole("button", { name: "Log out" })).toBeVisible(); | ||
}; | ||
| ||
/// The sidebar's link to `label`. | ||
export const nav = (page, label) => | ||
page.locator("nav.sidebar-left").getByRole("link", { name: label, exact: true }); | ||
| ||
/// A photo, as a camera would hand one over: a JPEG drawn by the page's own | ||
/// canvas, `width` by `height`, for a file input to take. | ||
export const photo = async (page, name, [width, height] = [1200, 800]) => { | ||
const data = await page.evaluate( | ||
([w, h]) => { | ||
const canvas = document.createElement("canvas"); | ||
canvas.width = w; | ||
canvas.height = h; | ||
const g = canvas.getContext("2d"); | ||
const sky = g.createLinearGradient(0, 0, 0, h); | ||
sky.addColorStop(0, "#f4b183"); | ||
sky.addColorStop(1, "#2e5c8a"); | ||
g.fillStyle = sky; | ||
g.fillRect(0, 0, w, h); | ||
g.fillStyle = "#1d2b36"; | ||
g.fillRect(0, h * 0.7, w, h * 0.3); | ||
return canvas.toDataURL("image/jpeg", 0.85).split(",")[1]; | ||
}, | ||
[width, height], | ||
); | ||
return { name, mimeType: "image/jpeg", buffer: Buffer.from(data, "base64") }; | ||
}; | ||
harness/e2e/playwright.config.mjsnew+39−0
| @@ -0,0 +1,39 @@ | ||
// 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, through grain-serve and the web's build | ||
// together, as next.grain.social runs them. | ||
// | ||
// just e2e the flows, over the stock PDS and then the spaces alpha | ||
// | ||
// Each run seeds a world of its own (setup.mjs) on free ports. 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 "./setup.mjs"; | ||
| ||
const spaces = !!process.env.GRAIN_DEV_ENV; | ||
| ||
export default defineConfig({ | ||
testDir: ".", | ||
// The flows over the stock PDS, as most accounts' are; the spaces ones | ||
// over the spaces alpha (GRAIN_DEV_ENV), as only it serves spaces. | ||
testMatch: spaces ? "spaces.spec.mjs" : "flows.spec.mjs", | ||
globalSetup: "./setup.mjs", | ||
outputDir: join(root, "target/e2e", spaces ? "spaces" : "results"), | ||
workers: 1, | ||
timeout: 90_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 grain-serve: over https, at | ||
// the harness's client hostname, with the harness's own certificate. | ||
ignoreHTTPSErrors: true, | ||
launchOptions: { args: ["--host-resolver-rules=MAP client.grain.internal 127.0.0.1"] }, | ||
trace: "retain-on-failure", | ||
screenshot: "only-on-failure", | ||
}, | ||
}); | ||
harness/e2e/setup.mjsnew+184−0
| @@ -0,0 +1,184 @@ | ||
// Before the flows: a world of their own. The reference PDS and a PLC | ||
// directory (harness/pds.mjs) with alice, bob and carol on it, alice with a | ||
// Bluesky profile and the others with none; grain-serve over an index of | ||
// its own, behind the https front the PDS sends logins back to | ||
// (harness/tls-proxy.mjs), handing the pages to the web's build, as | ||
// deploy/docker-compose.yml runs the two. Where the pages are and who is in | ||
// the world go to the flows in E2E_WORLD. Returns the teardown. | ||
// | ||
// Each server's output goes to target/e2e/logs, for the check to show when | ||
// a flow fails. | ||
| ||
import { spawn } from "node:child_process"; | ||
import { createWriteStream, mkdirSync, mkdtempSync, openSync } from "node:fs"; | ||
import { createServer } from "node:net"; | ||
import { tmpdir } from "node:os"; | ||
import { dirname, join, resolve } from "node:path"; | ||
import { createInterface } from "node:readline"; | ||
import { fileURLToPath } from "node:url"; | ||
| ||
/// The repository's root. | ||
export const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); | ||
/// Where the servers' output goes. | ||
export const logs = join(root, "target/e2e/logs"); | ||
| ||
/// The profile alice has on Bluesky, which her first login seeds her grain | ||
/// profile from. | ||
export const BLUESKY_PROFILE = { | ||
displayName: "Alice Liddell", | ||
description: "Photographs from down the rabbit hole.", | ||
}; | ||
| ||
/// A port free a moment ago. | ||
const free = () => | ||
new Promise((done, fail) => { | ||
const server = createServer(); | ||
server.on("error", fail); | ||
server.listen(0, "127.0.0.1", () => { | ||
const { port } = server.address(); | ||
server.close(() => done(port)); | ||
}); | ||
}); | ||
| ||
/// `command` with `args`, its output kept under `name` in the logs; its | ||
/// first line of output, when `first` is asked for. | ||
const start = (name, command, args, { env = {}, first = false } = {}) => { | ||
const fd = openSync(join(logs, `${name}.log`), "w"); | ||
const child = spawn(command, args, { | ||
cwd: root, | ||
env: { ...process.env, ...env }, | ||
stdio: ["pipe", first ? "pipe" : fd, fd], | ||
}); | ||
child.on("error", (e) => console.error(`${name}: ${e}`)); | ||
if (!first) return { child }; | ||
const lines = createInterface({ input: child.stdout }); | ||
const line = new Promise((done, fail) => { | ||
lines.once("line", done); | ||
child.once("exit", () => fail(new Error(`${name} exited before it was up: see ${name}.log`))); | ||
}).then((said) => { | ||
// The rest of what it says, after the line, to its log. | ||
lines.on("line", (more) => rest.write(`${more}\n`)); | ||
return said; | ||
}); | ||
const rest = createWriteStream(null, { fd }); | ||
return { child, line }; | ||
}; | ||
| ||
/// Until `url` answers ok, or `what` gives up after a minute. | ||
const until = async (what, url) => { | ||
const end = Date.now() + 60_000; | ||
while (Date.now() < end) { | ||
const ok = await fetch(url).then( | ||
(r) => r.ok, | ||
() => false, | ||
); | ||
if (ok) return; | ||
await new Promise((r) => setTimeout(r, 250)); | ||
} | ||
throw new Error(`${what} did not come up at ${url}: see target/e2e/logs`); | ||
}; | ||
| ||
/// `account`'s session at `pds`, logged in with its password. | ||
export const session = async (pds, account) => { | ||
const res = await fetch(`${pds}/xrpc/com.atproto.server.createSession`, { | ||
method: "POST", | ||
headers: { "content-type": "application/json" }, | ||
body: JSON.stringify({ identifier: account.handle, password: account.password }), | ||
}); | ||
if (!res.ok) throw new Error(`createSession ${account.handle}: ${await res.text()}`); | ||
return (await res.json()).accessJwt; | ||
}; | ||
| ||
export default async function setup() { | ||
mkdirSync(logs, { recursive: true }); | ||
const children = []; | ||
const stop = () => { | ||
for (const child of children.reverse()) child.kill(); | ||
}; | ||
try { | ||
// The PDS on the spaces alpha when GRAIN_DEV_ENV names it. | ||
const pds = start("pds", "node", ["harness/pds.mjs", "--accounts", "alice,bob,carol"], { | ||
first: true, | ||
}); | ||
children.push(pds.child); | ||
const net = JSON.parse(await pds.line); | ||
const [alice] = net.accounts; | ||
const token = await session(net.pds, alice); | ||
const put = await fetch(`${net.pds}/xrpc/com.atproto.repo.putRecord`, { | ||
method: "POST", | ||
headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, | ||
body: JSON.stringify({ | ||
repo: alice.did, | ||
collection: "app.bsky.actor.profile", | ||
rkey: "self", | ||
record: { $type: "app.bsky.actor.profile", ...BLUESKY_PROFILE }, | ||
}), | ||
}); | ||
if (!put.ok) throw new Error(`alice's Bluesky profile: ${await put.text()}`); | ||
| ||
const [serve, web] = [await free(), await free()]; | ||
const { cert, key, host } = net.client; | ||
const proxy = start("tls-proxy", "node", ["harness/tls-proxy.mjs", cert, key, String(serve)], { | ||
first: true, | ||
}); | ||
children.push(proxy.child); | ||
const base = `https://${host}:${(await proxy.line).trim()}`; | ||
| ||
const dir = mkdtempSync(join(tmpdir(), "grain-e2e-")); | ||
const server = start( | ||
"grain-serve", | ||
join(root, "target/debug/grain-serve"), | ||
[ | ||
["--listen", `127.0.0.1:${serve}`], | ||
["--index", join(dir, "index.db")], | ||
["--public-url", base], | ||
["--sign-up-pds", net.pds], | ||
// Backfill, as production runs it, reads each account a login | ||
// starts tracking and learns its handle; here from its PDS, with | ||
// no mirror, and the PDS for a directory, which lists no one. | ||
["--backfill", net.pds], | ||
["--backfill-mirror", ""], | ||
["--web", `http://127.0.0.1:${web}`], | ||
].flat(), | ||
{ | ||
env: { | ||
GRAIN_CONFIG_DIR: join(dir, "config"), | ||
GRAIN_CACHE_DIR: join(dir, "cache"), | ||
GRAIN_PLC_URL: net.plc, | ||
GRAIN_HANDLE_RESOLVER: net.pds, | ||
// The PDS is on loopback, over http. | ||
GRAIN_ALLOW_PRIVATE: "1", | ||
GRAIN_CA_CERTS: cert, | ||
RUST_LOG: "info", | ||
}, | ||
}, | ||
); | ||
children.push(server.child); | ||
// The pages, as `just pages` runs them, at the public URL: ORIGIN is | ||
// what a page's own address is taken to be, as the front says it. | ||
const pages = start("web", "node", ["build"], { | ||
env: { | ||
HOST: "127.0.0.1", | ||
PORT: String(web), | ||
ORIGIN: base, | ||
GRAIN_SERVE_URL: `http://127.0.0.1:${serve}`, | ||
}, | ||
}); | ||
children.push(pages.child); | ||
await until("grain-serve", `http://127.0.0.1:${serve}/healthz`); | ||
await until("the pages", `http://127.0.0.1:${web}/support/terms`); | ||
| ||
process.env.E2E_WORLD = JSON.stringify({ | ||
base, | ||
host, | ||
pds: net.pds, | ||
plc: net.plc, | ||
spaces: net.spaces, | ||
accounts: net.accounts, | ||
}); | ||
} catch (e) { | ||
stop(); | ||
throw e; | ||
} | ||
return stop; | ||
} | ||
harness/e2e/spaces.spec.mjsnew+51−0
| @@ -0,0 +1,51 @@ | ||
// Over the spaces alpha: what an account on a PDS that serves permissioned | ||
// spaces finds that one on the stock PDS does not. getSpaceSupport says the | ||
// PDS serves them, so the Groups tab shows, and the groups page opens to | ||
// the groups grain knows rather than to the notice that spaces are | ||
// needed. The world has no group host, so no group is started here. | ||
| ||
import { expect, test } from "@playwright/test"; | ||
import { browse, failed, go, logIn, nav, said, url, world } from "./pages.mjs"; | ||
| ||
test.describe.configure({ mode: "serial" }); | ||
| ||
let alice; | ||
| ||
test.beforeAll(async ({ browser }) => { | ||
if (!world.spaces) | ||
throw new Error("the spaces flows run over the spaces alpha: set GRAIN_DEV_ENV"); | ||
alice = await browse(browser); | ||
}); | ||
| ||
test.afterAll(async () => { | ||
await alice?.context().close(); | ||
}); | ||
| ||
/// What the browsers said, shown for a flow that failed. | ||
test.afterEach(() => { | ||
const info = test.info(); | ||
if (info.status !== info.expectedStatus && said.length) console.log(said.slice(-40).join("\n")); | ||
said.length = 0; | ||
}); | ||
| ||
test("logging in on a PDS with spaces shows Groups", async () => { | ||
await go(alice, "/"); | ||
await logIn(alice, "alice"); | ||
const asked = await alice.evaluate(() => | ||
fetch("/xrpc/social.grain.server.getSpaceSupport").then((r) => r.json()), | ||
); | ||
expect(asked.supported).toBe(true); | ||
await expect(nav(alice, "Groups")).toBeVisible(); | ||
}); | ||
| ||
test("the groups page opens", async () => { | ||
await nav(alice, "Groups").click(); | ||
await alice.waitForURL(url("/groups")); | ||
await expect(alice.getByText("No groups yet.")).toBeVisible(); | ||
// No group host, so nothing to start one on. | ||
await expect(alice.getByRole("link", { name: "Start a group" })).toHaveCount(0); | ||
}); | ||
| ||
test("every call the pages made was answered", async () => { | ||
expect([...failed].sort()).toEqual([]); | ||
}); | ||
harness/package-lock.json+66−0
| @@ -8,6 +8,9 @@ | ||
"dependencies": { | ||
"@atproto/dev-env": "0.6.26", | ||
"@atproto/lexicon": "0.7.17" | ||
}, | ||
"devDependencies": { | ||
"@playwright/test": "1.58.2" | ||
} | ||
}, | ||
"node_modules/@atproto-labs/did-resolver": { | ||
| @@ -4641,6 +4644,22 @@ | ||
"node": ">=14" | ||
} | ||
}, | ||
"node_modules/@playwright/test": { | ||
"version": "1.58.2", | ||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", | ||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", | ||
"dev": true, | ||
"license": "Apache-2.0", | ||
"dependencies": { | ||
"playwright": "1.58.2" | ||
}, | ||
"bin": { | ||
"playwright": "cli.js" | ||
}, | ||
"engines": { | ||
"node": ">=18" | ||
} | ||
}, | ||
"node_modules/@protobufjs/aspromise": { | ||
"version": "1.1.2", | ||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", | ||
| @@ -6722,6 +6741,21 @@ | ||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", | ||
"license": "MIT" | ||
}, | ||
"node_modules/fsevents": { | ||
"version": "2.3.2", | ||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", | ||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", | ||
"dev": true, | ||
"hasInstallScript": true, | ||
"license": "MIT", | ||
"optional": true, | ||
"os": [ | ||
"darwin" | ||
], | ||
"engines": { | ||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" | ||
} | ||
}, | ||
"node_modules/function-bind": { | ||
"version": "1.1.2", | ||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", | ||
| @@ -7948,6 +7982,38 @@ | ||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", | ||
"license": "MIT" | ||
}, | ||
"node_modules/playwright": { | ||
"version": "1.58.2", | ||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", | ||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", | ||
"dev": true, | ||
"license": "Apache-2.0", | ||
"dependencies": { | ||
"playwright-core": "1.58.2" | ||
}, | ||
"bin": { | ||
"playwright": "cli.js" | ||
}, | ||
"engines": { | ||
"node": ">=18" | ||
}, | ||
"optionalDependencies": { | ||
"fsevents": "2.3.2" | ||
} | ||
}, | ||
"node_modules/playwright-core": { | ||
"version": "1.58.2", | ||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", | ||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", | ||
"dev": true, | ||
"license": "Apache-2.0", | ||
"bin": { | ||
"playwright-core": "cli.js" | ||
}, | ||
"engines": { | ||
"node": ">=18" | ||
} | ||
}, | ||
"node_modules/postgres-array": { | ||
"version": "2.0.0", | ||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", | ||
harness/package.json+4−1
| @@ -1,10 +1,13 @@ | ||
{ | ||
"name": "grain-harness", | ||
"private": true, | ||
"description": "Runs the npm-published reference PDS and PLC for grain's tests, and holds grain-serve's answers to the lexicons", | ||
"description": "Runs the npm-published reference PDS and PLC for grain's tests, holds grain-serve's answers to the lexicons, and drives the pages end to end", | ||
"type": "module", | ||
"dependencies": { | ||
"@atproto/dev-env": "0.6.26", | ||
"@atproto/lexicon": "0.7.17" | ||
}, | ||
"devDependencies": { | ||
"@playwright/test": "1.58.2" | ||
} | ||
} | ||
web/src/routes/profile/[actor]/gallery/[rkey]/edit/+page.svelte+1−1
| @@ -432,7 +432,7 @@ import type { PhotoView } from '$lib/lexicons/social/grain/photo/defs.ts' | ||
| ||
await applyWrites(writes) | ||
| ||
queryClient.invalidateQueries({ queryKey: ['gallery', data.galleryUri] }) | ||
queryClient.invalidateQueries({ queryKey: galleryQuery(data.galleryUri).queryKey }) | ||
goto(galleryPath(gallery)) | ||
} catch (err: any) { | ||
error = err.message || 'Failed to save. Please try again.' | ||
web/src/routes/+layout.svelte+8−1
| @@ -12,7 +12,7 @@ | ||
</script> | ||
| ||
<script lang="ts"> | ||
import { type Snippet } from 'svelte' | ||
import { onMount, tick, type Snippet } from 'svelte' | ||
import '../app.css' | ||
import Shell from '$lib/components/templates/Shell.svelte' | ||
import { setViewerHandle } from '$lib/auth' | ||
| @@ -32,6 +32,13 @@ | ||
| ||
$effect(() => initTheme()) | ||
| ||
// Marked once the page's script has taken it over, so that something | ||
// driving the page, as the end-to-end flows do, clicks only once a click | ||
// reaches a handler. | ||
onMount(() => { | ||
tick().then(() => document.documentElement.setAttribute('data-ready', '')) | ||
}) | ||
| ||
// Clear stale body overflow locks left by overlays (e.g. StoryViewer) | ||
afterNavigate(({ from, to, type }) => { | ||
if (document.body.style.overflow === 'hidden') { | ||