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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

ci: The pages and grain-serve are checked together, end to end

Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:ci/e2e

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') {
AGENTS.md+13−0
Justfile+10−0