From cd0b0ccfb4ed1917f9003369a6a15f386f87ccea Mon Sep 17 00:00:00 2001 From: dawn Date: Thu, 22 Oct 2026 05:29:17 +0300 Subject: [PATCH] e2e: cover ticket openers, state change notices and mutes Signed-off-by: dawn --- e2e/README.md | 3 +- e2e/harness/config.ts | 2 + e2e/harness/firehose.ts | 3 + e2e/harness/flow.ts | 16 +- e2e/harness/run.ts | 11 + e2e/harness/services.ts | 124 +++++++++++- e2e/harness/shared.ts | 152 +++++++++++++- e2e/harness/webhooks.ts | 118 +++++++++-- e2e/specs/access.spec.ts | 6 +- e2e/specs/fork-pulls.spec.ts | 12 +- e2e/specs/issues.spec.ts | 68 ++++++- e2e/specs/merges.spec.ts | 34 +++- e2e/specs/social.spec.ts | 377 ++++++++++++++++++++++++++++++++++- 13 files changed, 874 insertions(+), 52 deletions(-) diff --git a/e2e/README.md b/e2e/README.md index 9e6ad5319..76fdc4b63 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -30,7 +30,8 @@ the stack is either compose or the container-free one in `localinfra/pc`. `E2E_S is installed. for pc run `pnpm e2e:up` (the nix app brings go and cargo), it starts the stack in the background, and `localinfra/pc/bin/pc down`, from the repo root, stops it. service urls come from `E2E_*_URL` (see `harness/config.ts`) and default to the localinfra -hostnames. making accounts needs the pds admin password, read from `localinfra/pds.env` or +hostnames. the `deliberi` hop asks deliberi's dev only `/xrpc/_ingested` route, so deliberi has to run with +`DELIBERI_DEV=true` (both localinfra stacks set it). making accounts needs the pds admin password, read from `localinfra/pds.env` or `E2E_PDS_ADMIN_PASSWORD`. ## outages diff --git a/e2e/harness/config.ts b/e2e/harness/config.ts index 874bed4cc..47b0133b3 100644 --- a/e2e/harness/config.ts +++ b/e2e/harness/config.ts @@ -34,6 +34,8 @@ export const config = { hydrantUrl: process.env.E2E_HYDRANT_URL ?? host("hydrant"), bobbinUrl: process.env.E2E_BOBBIN_URL ?? host("bobbin"), spindleUrl: process.env.E2E_SPINDLE_URL ?? host("spindle"), + // the service is named mirror here, not gitmirror + gitmirrorUrl: process.env.E2E_GITMIRROR_URL ?? host("mirror"), sitesUrl: process.env.E2E_SITES_URL ?? host("sites"), // deliberi follows the bobbin url, so a run pointed at another bobbin gets its own deliberi deliberiUrl: diff --git a/e2e/harness/firehose.ts b/e2e/harness/firehose.ts index 799dc827d..8e57ae97c 100644 --- a/e2e/harness/firehose.ts +++ b/e2e/harness/firehose.ts @@ -11,6 +11,8 @@ interface Seen { id: number; action: Action; cid?: string; + // a tid, so plain string compare orders it by time + rev: string; at: number; } type Message = MessageOf; @@ -161,6 +163,7 @@ export class Firehose extends Frames { id: message.seq, action: op.action, cid: op.cid?.$link, + rev: message.rev, at: Date.now() }); } diff --git a/e2e/harness/flow.ts b/e2e/harness/flow.ts index 5e8716145..80e243bf1 100644 --- a/e2e/harness/flow.ts +++ b/e2e/harness/flow.ts @@ -23,7 +23,7 @@ import { } from "./vocabulary"; export * from "./vocabulary"; -export { bobbin, git, hydrant, knot, knotCopy, pds } from "./services"; +export { bobbin, deliberi, git, gitmirror, hydrant, knot, knotCopy, pds } from "./services"; export { note } from "./record"; interface Place { @@ -227,14 +227,14 @@ class Flow { const held = await run.hold(slot); const before = { ...(await lookBefore(hops, held)), released: Date.now() }; const written = await run.release(slot, held); - await settle(written, hops, run.actor, slot.nsid, held.method, before); + await settle(written, hops, run.actor, slot.nsid, held.method, before, run.everyone); if (isAttested(held)) await claimAttestation(run, written); }); } follow(slot: Slot, hops: Hop[]) { return this.add(`follow ${slot.nsid} through ${route(hops)}`, (run) => - settle(run.get(slot), hops, run.actor, slot.nsid, "settled") + settle(run.get(slot), hops, run.actor, slot.nsid, "settled", undefined, run.everyone) ); } @@ -275,11 +275,17 @@ class Flow { } // the button only flips once bobbin has the new state, so a held write changes nothing - changeState(slot: Slot, to: "open" | "closed", clicking: Target, then: Target) { + changeState( + slot: Slot, + to: "open" | "closed", + clicking: Target, + then: Target, + hops: Hop[] = through(knot, hydrant, bobbin) + ) { return this.click(clicking) .sends(slot, knot, replacing(slot, { state: to }), "put") .sees(clicking) - .trace(slot, through(knot, hydrant, bobbin)) + .trace(slot, hops) .knotStored(slot, to) .sees(then); } diff --git a/e2e/harness/run.ts b/e2e/harness/run.ts index 3820b202a..936069ae0 100644 --- a/e2e/harness/run.ts +++ b/e2e/harness/run.ts @@ -24,6 +24,7 @@ class People { private labelled = new Map(); private members = new Set(); private throughThePage = new Set(); + private met = new Map(); who?: Account; constructor(private tidy: Tidy) {} @@ -35,6 +36,7 @@ class People { async switchTo(who: string | Account, options: { login?: "ui" } = {}) { if (typeof who !== "string") { + this.met.set(who.did, who); this.who = who; return; } @@ -46,9 +48,14 @@ class People { account = made; } if (options.login === "ui") this.throughThePage.add(account.name); + this.met.set(account.did, account); this.who = account; } + get everyone() { + return [...this.met.values()]; + } + signsInThroughThePage(account: Account) { return config.login === "ui" || this.throughThePage.has(account.name); } @@ -126,6 +133,10 @@ export class Run implements Ctx { this.parent = repo; } + get everyone() { + return this.people.everyone; + } + get pushed() { if (!this.last) throw new Error("this step needs a .push() before it"); return this.last; diff --git a/e2e/harness/services.ts b/e2e/harness/services.ts index 4ea67b036..72c069065 100644 --- a/e2e/harness/services.ts +++ b/e2e/harness/services.ts @@ -25,6 +25,8 @@ export interface Before { export interface Settling { how: Held["method"] | "settled"; before?: Before; + // whose notices get checked. with nobody here an "absent" check can't fail, so it proves nothing + people?: Account[]; } export interface Hop { @@ -417,9 +419,10 @@ export const settle = async ( who: Account, nsid: string, how: Settling["how"], - before?: Before + before?: Before, + people?: Account[] ) => { - const settling = { how, before }; + const settling = { how, before, people }; const checks = await Promise.allSettled( hops.map((hop) => step(hop.name, () => @@ -505,8 +508,125 @@ export interface Notice { actorDid: string; repoDid?: string; issueAt?: string; + pullAt?: string; } export const noticesOf = async (who: Account): Promise => (await deliberiGet(who, "org.tangled.temp.notification.listNotifications", { limit: "50" })) .notifications; + +export const aboutOf = (notice: Notice) => notice.issueAt ?? notice.pullAt; + +// a state change notice is the ticket uri with #rev on the end +const madeFrom = (notice: Notice, uri: string) => + notice.uri === uri || notice.uri.startsWith(`${uri}#`); + +// deliberi only shows each person their own notices, so ask as each one +export const noticesMadeFrom = async (uri: string, people: Account[]) => + ( + await Promise.all( + people.map(async (who) => + (await noticesOf(who)) + .filter((n) => madeFrom(n, uri)) + .map((notice) => ({ who, notice })) + ) + ) + ).flat(); + +// deliberi saves the rev with its notices, so once _ingested shows the rev the notices are written +const ingested = async (written: Written) => { + // an edit keeps the uri, so only a rev at least as new as the write proves it landed + const watcher = isCob(written.uri) ? knotFirehose : pdsFirehose; + const { rev } = await watcher.check(written); + return until( + `deliberi should have ingested ${written.uri} at ${written.cid}`, + 20_000, + async () => { + const { status, body } = await get( + `${config.deliberiUrl}/xrpc/_ingested?${new URLSearchParams({ uri: written.uri })}` + ); + // a json 404 is just unseen, a plain text 404 means the route is missing + if (status === 404 && typeof body === "string") + throw new Error( + `deliberi has no /xrpc/_ingested route, run it with DELIBERI_DEV=true, nothing else can say it processed ${written.uri}` + ); + // only tickets carry a cid, so a missing one doesn't count against it + const has = + status === 200 && + body.rev >= rev && + (body.cid === undefined || body.cid === written.cid); + return [ + has, + `/_ingested answered ${status} ${JSON.stringify(body)}, the write is at rev ${rev}` + ]; + } + ); +}; + +const told = (found: { who: Account; notice: Notice }[]) => + found.map(({ who, notice }) => `${notice.type} to ${who.name}`); + +export const deliberi: Hop = { + name: "deliberi", + present: async (written, _who, settling) => { + await step("ingested", () => ingested(written)); + const found = await noticesMadeFrom(written.uri, settling?.people ?? []); + note("deliberi made", told(found).join(", ") || "no notice"); + }, + absent: async ({ uri }, _who, settling) => { + await until(`deliberi should have no notice made from ${uri}`, 15_000, async () => { + const found = await noticesMadeFrom(uri, settling?.people ?? []); + return [found.length === 0, `it still has ${told(found).join(", ")}`]; + }); + } +}; + +// the opener is the editor of the first version, read from the knot's git +export const gitmirror = { + // the mirror lags the knot a moment, so this polls + openerOf: (uri: string) => + until(`gitmirror should list who opened ${uri}`, 30_000, async () => { + const { repo, collection, rkey } = partsOf(uri); + const { status, body } = await get( + xrpc(config.gitmirrorUrl, "org.tangled.temp.repo.listRecordOrigins", { + repo, + collection, + limit: "1000" + }) + ); + const found = + status === 200 + ? body.origins?.find((o: { rkey: string }) => o.rkey === rkey) + : undefined; + return [ + Boolean(found), + `listRecordOrigins answered ${status} ${JSON.stringify(body)}`, + found?.author as string + ]; + }) +}; + +export const openers = { + view: async (uri: string): Promise => + (await getOk(xrpc(config.bobbinUrl, "org.tangled.track.getTicketView", { uri }))).author + .did, + listed: async (repoDid: string, list: "listIssues" | "listPulls", uri: string) => { + const body = await getOk( + xrpc(config.bobbinUrl, `org.tangled.repo.${list}`, { subject: repoDid, limit: "100" }) + ); + return body.items.find((item: { uri: string }) => item.uri === uri)?.author as + string | undefined; + }, + searched: async (repoDid: string, kind: "issue" | "pull", query: string) => { + const body = await getOk( + xrpc(config.bobbinUrl, "org.tangled.search.query", { + repo: repoDid, + nsid: "org.tangled.track.ticket", + kind, + ...(query ? { q: query } : {}), + limit: "100" + }) + ); + return body.hits as { uri: string; author?: string }[]; + } +}; diff --git a/e2e/harness/shared.ts b/e2e/harness/shared.ts index 3def716b9..32b650957 100644 --- a/e2e/harness/shared.ts +++ b/e2e/harness/shared.ts @@ -5,7 +5,17 @@ import { config, type Account } from "./config"; import { flow, type Flow } from "./flow"; import { deleteBranch, lsRemote, pushAnnotatedTag } from "./git"; import { get, ok, selfUri, sleep, until, xrpc, type Reply, type Written } from "./net"; -import { bobbin, hydrant, knot, pds, refsNaming, spindle } from "./services"; +import { + bobbin, + gitmirror, + hydrant, + knot, + openers, + pds, + refsNaming, + spindle, + type Hop +} from "./services"; import { alerts, commentId, @@ -31,6 +41,7 @@ import { resolve, saveButton, subscriptionId, + target, testid, text, through, @@ -42,7 +53,8 @@ import { within, type Ctx, type Lazy, - type Slot + type Slot, + type Target } from "./vocabulary"; const remembered = new WeakMap>(); @@ -328,6 +340,14 @@ export const forkedAhead = (name: string, options: { ci?: boolean } = {}) => }) .mirrored(); +// a fork's first diff read fetches its commits, so the page fails until a reload +export const reloadsUntil = (what: Target) => async (ctx: Ctx) => + until(`reloading should show ${what.describe}`, 60_000, async () => { + const shown = (await what.locate(ctx.page, ctx).count()) > 0; + if (!shown) await ctx.page.reload(); + return [shown, "it is still not on the page"]; + }); + export const composeFromFork = where( "the new pull form in fork mode", (ctx) => @@ -363,13 +383,15 @@ export const opensForkPull = (title: string) => (flow: Flow) => .sees(link(title)); // the page marks the ticket completed with its own put, after the merge -export const merges = (flow: Flow) => +export const mergesThrough = (hops: Hop[]) => (flow: Flow) => flow .click(mergeButton) .sends(pullId, knot, replacing(pullId, { state: "completed" }), "put") - .trace(pullId, through(knot, hydrant, bobbin)) + .trace(pullId, hops) .sees(testid("ticketStatePill", { state: "completed" })); +export const merges = mergesThrough(through(knot, hydrant, bobbin)); + export const targetIsAt = (branch: string, who = (ctx: Ctx) => ctx.actor) => async (ctx: Ctx) => { @@ -377,6 +399,56 @@ export const targetIsAt = expect(heads[`refs/heads/${branches.into}`]).toBe(ctx.repo.heads[branch]); }; +// a rebase makes a new tip, so match the tree and commit count, not the sha +const tips = new WeakMap(); +export const mergedTip = (ctx: Ctx) => { + const tip = tips.get(ctx); + if (!tip) throw new Error("no merge was checked yet, use targetTookTheFeature first"); + return tip; +}; + +export const targetTookTheFeature = + (who = (ctx: Ctx) => ctx.actor, { fromFork = false } = {}) => + async (ctx: Ctx) => { + const target = fromFork ? ctx.upstream : ctx.repo; + const before = target.heads[branches.into]; + const head = ctx.repo.heads[branches.from]; + const repos = [...new Set([target.did, ctx.repo.did])] + .map((did) => `repos=${did}`) + .join("&"); + const tip = (await lsRemote(who(ctx), target.did))[`refs/heads/${branches.into}`]; + expect(tip, "the target branch didn't move").not.toBe(before); + tips.set(ctx, tip); + const between = async (to: string) => { + const { status, body } = await get( + `${xrpc(config.bobbinUrl, "org.tangled.temp.git.listCommits", { ranges: `${before}..${to}` })}&${repos}` + ); + return { + status, + body, + commits: (body?.commits ?? []) as { oid: string; parents: string[]; tree: string }[] + }; + }; + await until(`the mirror should list the feature's commits on ${tip}`, 30_000, async () => { + const [merged, feature] = [await between(tip), await between(head)]; + const [tipCommit, headCommit] = [ + merged.commits.find((c) => c.oid === tip), + feature.commits.find((c) => c.oid === head) + ]; + const oldest = merged.commits.find((c) => c.parents[0] === before); + const took = + merged.status === 200 && + feature.commits.length > 0 && + merged.commits.length === feature.commits.length && + Boolean(oldest) && + tipCommit?.tree === headCommit?.tree; + return [ + took, + `merged ${JSON.stringify(merged.body)}, feature ${JSON.stringify(feature.body)}` + ]; + }); + }; + export const commented = "needs a comment"; export const note = "same here"; @@ -424,3 +496,75 @@ export const renamesTo = (name: (ctx: Ctx) => string) => (flow: Flow) => .trace(manifestId, through(knot, hydrant, bobbin)) .sends(declarationId, pds, (ctx) => ({ rkey: ctx.repo.name }), "delete") .trace(declarationId, through(pds, hydrant, bobbin)); + +export const rowOf = (slot: Slot) => + target("the ticket's row", (tab, ctx) => + tab.locator(`[data-testid="ticketRow"][data-rkey=${JSON.stringify(rkeyOf(ctx, slot))}]`) + ); + +export const authorLink = (who: (ctx: Ctx) => Account) => + target("the link to the ticket's author", (tab, ctx) => + // the hidden narrow copy comes first, so take the visible one + tab + .locator(`a[href="/${who(ctx).handle}"]`) + .filter({ visible: true }) + .first() + ); + +export const bobbinNamesOpener = + ( + slot: Slot, + kind: "issue" | "pull", + opener: (ctx: Ctx) => Account, + lastWriter: (ctx: Ctx) => Account + ) => + async (ctx: Ctx) => { + const { uri } = ctx.get(slot); + const list = kind === "issue" ? "listIssues" : "listPulls"; + const [opened, wrote] = [opener(ctx).did, lastWriter(ctx).did]; + await until(`bobbin should name ${opened} as the opener of ${uri}`, 20_000, async () => { + const seen = { + view: await openers.view(uri), + listed: await openers.listed(ctx.repo.did, list, uri), + searched: (await openers.searched(ctx.repo.did, kind, "")).find( + (h) => h.uri === uri + )?.author + }; + const byOpener = await openers.searched(ctx.repo.did, kind, `author:${opened}`); + const byWriter = await openers.searched(ctx.repo.did, kind, `author:${wrote}`); + const right = + Object.values(seen).every((author) => author === opened) && + byOpener.some((h) => h.uri === uri) && + !byWriter.some((h) => h.uri === uri); + return [ + right, + `views name ${JSON.stringify(seen)}, author:opener lists ${byOpener.length}, author:last writer lists ${byWriter.length}` + ]; + }); + }; + +export const gitmirrorNamesOpener = + (slot: Slot, opener: (ctx: Ctx) => Account) => async (ctx: Ctx) => + expect(await gitmirror.openerOf(ctx.get(slot).uri)).toBe(opener(ctx).did); + +export const theFan = (ctx: Ctx) => recall(ctx, "fan"); +export const theOwner = (ctx: Ctx) => recall(ctx, "owner"); + +export const fanPulled = (name: string, title = "a pull from the fan") => + onBranches(name) + .step("remember the owner", remember("owner")) + .actor("fan") + .step("remember the fan", remember("fan")) + .pull(pullId, patchId, { ...branches, title }) + .actor("owner"); + +export const fanOpened = (name: string, title = "opened by somebody else") => + flow(name) + .actor("fan") + .step("remember the fan", remember("fan")) + .actor("owner") + .step("remember the owner", remember("owner")) + .repo() + .actor("fan") + .ticket(ticketId, { title }) + .actor("owner"); diff --git a/e2e/harness/webhooks.ts b/e2e/harness/webhooks.ts index 14a21f2a0..fb857ccd8 100644 --- a/e2e/harness/webhooks.ts +++ b/e2e/harness/webhooks.ts @@ -1,7 +1,10 @@ import { expect } from "@playwright/test"; +import { spawn, spawnSync } from "node:child_process"; import { createHmac } from "node:crypto"; +import { existsSync, readFileSync, readlinkSync } from "node:fs"; import { createServer, type IncomingHttpHeaders } from "node:http"; import type { AddressInfo } from "node:net"; +import { createInterface } from "node:readline"; import { spindleGet, spindlePost } from "./api"; import type { Account } from "./config"; @@ -17,31 +20,113 @@ export interface Delivery { body: any; } -// spindle and this process share the stack's network, and dev mode lets it dial loopback +// on linux the local stack has its own network namespace that can't reach the host's loopback, +// so the receiver runs inside it and reports over stdout. elsewhere it listens here const inboxes = new Map(); let serving: Promise | undefined; -const serve = () => - (serving ??= new Promise((ready) => { +const receiver = ` +const server = require("node:http").createServer((req, res) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString(); + console.log(JSON.stringify({ url: req.url, method: req.method, headers: req.headers, raw })); + res.end("ok"); + }); +}); +server.listen(0, "127.0.0.1", () => console.log(JSON.stringify({ port: server.address().port }))); +process.stdin.on("end", () => process.exit()); +process.stdin.resume(); +`; + +const take = ( + url: string, + method: string | undefined, + headers: IncomingHttpHeaders, + raw: string +) => { + const inbox = inboxes.get(url); + if (!inbox) return false; + let body: unknown; + try { + body = JSON.parse(raw); + } catch {} + inbox.push({ method, headers, raw, body }); + return true; +}; + +// ns.pid goes stale if linux-ns runs again, so look up process-compose by its socket +const namespaceOfTheStack = () => { + const state = process.env.E2E_PC_STATE; + if (!state) return undefined; + let named: string; + try { + named = readFileSync(`${state}/run/ns.pid`, "utf8").trim(); + } catch { + return undefined; + } + if (existsSync(`/proc/${named}/ns/net`)) return named; + // the wrapper matches the same pgrep but is still in the host namespace + const here = readlinkSync("/proc/self/ns/net"); + const found = spawnSync("pgrep", ["-f", `process-compose up .*${state}/run/pc.sock`], { + encoding: "utf8" + }) + .stdout.split("\n") + .find((pid) => pid && readlinkSync(`/proc/${pid}/ns/net`) !== here); + if (!found) + throw new Error( + `${state}/run/ns.pid names ${named}, which is gone, and no process-compose is in the stack's namespace` + ); + return found; +}; + +const serveInsideTheStack = (ns: string) => + new Promise((ready, failed) => { + const child = spawn( + "nsenter", + ["--user", "--preserve-credentials", "--net", "-t", ns, "node", "-e", receiver], + { stdio: ["pipe", "pipe", "inherit"] } + ); + child.on("error", failed); + child.on("exit", (code) => + failed(new Error(`the receiver in the stack's namespace exited with ${code}`)) + ); + createInterface({ input: child.stdout }).on("line", (line) => { + const said = JSON.parse(line); + if (said.port) return ready(said.port); + take(said.url, said.method, said.headers, said.raw); + }); + // unref or the test run waits on the receiver + child.unref(); + (child.stdout as unknown as { unref(): void }).unref(); + (child.stdin as unknown as { unref(): void }).unref(); + }); + +const serveHere = () => + new Promise((ready) => { const server = createServer((req, res) => { const chunks: Buffer[] = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { - const raw = Buffer.concat(chunks).toString(); - const inbox = inboxes.get(req.url ?? ""); - if (!inbox) return void res.writeHead(404).end(); - let body: unknown; - try { - body = JSON.parse(raw); - } catch {} - inbox.push({ method: req.method, headers: req.headers, raw, body }); + const found = take( + req.url ?? "", + req.method, + req.headers, + Buffer.concat(chunks).toString() + ); + if (!found) return void res.writeHead(404).end(); res.end("ok"); }); }); - // the worker owns this server, a test run shouldn't wait on it server.unref(); server.listen(0, "127.0.0.1", () => ready((server.address() as AddressInfo).port)); - })); + }); + +const serve = () => { + const ns = namespaceOfTheStack(); + return (serving ??= ns ? serveInsideTheStack(ns) : serveHere()); +}; interface Hook { id: number; @@ -49,7 +134,6 @@ interface Hook { owner: Account; secret?: string; inbox: Delivery[]; - // how many deliveries the flow has accounted for so far taken: number; } @@ -66,7 +150,7 @@ export const hookOn = const url = `http://127.0.0.1:${await serve()}/${stamp()}`; const inbox: Delivery[] = []; inboxes.set(new URL(url).pathname, inbox); - // spindle takes a while to learn a new repo + // spindle doesn't know a new repo for a bit, so this retries const id = await until("spindle should take the webhook", 15_000, async () => { const made = await spindlePost(ctx.actor, `${WEBHOOKS}.createWebhook`, { repoDid: ctx.repo.did, @@ -116,7 +200,7 @@ const logOf = async (ctx: Ctx, hook: Hook) => { const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -// the next delivery the flow hasn't accounted for has to be this event, so a repeat shows up as the wrong event +// deliveries are taken in order, so an extra one fails here as the wrong event export const delivers = (event: string, userAgent: Lazy, check: (body: any, ctx: Ctx) => void) => async (ctx: Ctx) => { @@ -163,7 +247,7 @@ export const delivers = }); }; -// a stray second delivery comes within a moment of the first, so give it that long +// a stray duplicate lands within a moment of the first, so sleep 3s first export const nothingMore = async (ctx: Ctx) => { const hook = hookOf(ctx); await sleep(3000); diff --git a/e2e/specs/access.spec.ts b/e2e/specs/access.spec.ts index 624585577..8f97aea78 100644 --- a/e2e/specs/access.spec.ts +++ b/e2e/specs/access.spec.ts @@ -48,7 +48,7 @@ import { onBranches, recall, remember, - targetIsAt + targetTookTheFeature } from "../harness/shared"; const COLLABORATOR = collaboratorId.nsid; @@ -297,8 +297,8 @@ onBranches("a collaborator merges the owner's pull request") .use(merges) .knotStored(pullId, "completed") .step( - "the target branch moved to the feature's commit", - targetIsAt(branches.from, (ctx) => recall(ctx, "owner")) + "the target branch took the feature's commit", + targetTookTheFeature((ctx) => recall(ctx, "owner")) ); const strangersPull = "a pull a stranger may only read"; diff --git a/e2e/specs/fork-pulls.spec.ts b/e2e/specs/fork-pulls.spec.ts index 31d7e4a5c..8f79f8890 100644 --- a/e2e/specs/fork-pulls.spec.ts +++ b/e2e/specs/fork-pulls.spec.ts @@ -12,15 +12,16 @@ import { unique, where } from "../harness/flow"; -import { lsRemote } from "../harness/git"; import { spindle } from "../harness/services"; import { branches, forkedAhead, merges, opensForkPull, + reloadsUntil, resubmitsForkPatch, runsNothing, + targetTookTheFeature, upstreamPull } from "../harness/shared"; import { delivers, dropsHook, hookOn, nothingMore } from "../harness/webhooks"; @@ -42,10 +43,10 @@ forkedAhead("merging a fork pull request moves the upstream's target to the fork .open(upstreamPull) .use(merges) .knotStored(pullId, "completed") - .step("the upstream's target branch moved to the fork's commit", async (ctx) => { - const heads = await lsRemote(ctx.actor, ctx.upstream.did); - expect(heads[`refs/heads/${branches.into}`]).toBe(forkHead(ctx)); - }) + .step( + "the upstream's target branch took the fork's commit", + targetTookTheFeature((ctx) => ctx.actor, { fromFork: true }) + ) .checkpoint("the merge moved the upstream's target branch") .reload() .sees(completed) @@ -63,6 +64,7 @@ forkedAhead("resubmitting a fork pull request after pushing to the fork's branch .sees(testid("resubmit")) .use(resubmitsForkPatch) .reload() + .step("the new file shows up", reloadsUntil(pushedFile)) .sees(pushedFile) .sees(commitRow(2)) .sees(not(commitRow(3))); diff --git a/e2e/specs/issues.spec.ts b/e2e/specs/issues.spec.ts index 0ea2142fb..4f65570c3 100644 --- a/e2e/specs/issues.spec.ts +++ b/e2e/specs/issues.spec.ts @@ -1,5 +1,6 @@ import { expect } from "@playwright/test"; +import { editCob, tickets } from "../harness/api"; import { bobbin, commentId, @@ -23,16 +24,24 @@ import { through, ticketId, unique, + where, within } from "../harness/flow"; import { + authorLink, + bobbinNamesOpener, branches, + fanOpened, deletesComment, deletesIssue, + gitmirrorNamesOpener, issue, note, onBranches, - stillStored + rowOf, + stillStored, + theFan, + theOwner } from "../harness/shared"; const closedTab = testid("segment", { segment: "closed" }); @@ -343,3 +352,60 @@ flow("an issue closed in another window shows closed after a reload") .sees(testid("requestStateChange", { state: "open" })) .reload() .sees(testid("requestStateChange", { state: "closed" })); + +// bobbin's issue list has no author filter, so this goes through search like the page does +const closedBy = (who: (ctx: Ctx) => { handle: string }) => + where("the closed issues one account opened", (ctx) => { + const query = `is:closed author:${who(ctx).handle}`; + return `${ctx.repo.path}/issues?q=${encodeURIComponent(query)}`; + }); + +fanOpened("an issue the owner closed still belongs to whoever opened it") + .open(page.issue(ticketId)) + .changeState( + ticketId, + "closed", + testid("requestStateChange", { state: "open" }), + testid("requestStateChange", { state: "closed" }) + ) + .step( + "bobbin names the fan as the opener everywhere, and the owner as no one's", + bobbinNamesOpener(ticketId, "issue", theFan, theOwner) + ) + .reload() + .sees(within(component("Ticket"), authorLink(theFan))) + + .open(closedBy(theFan)) + .sees(within(rowOf(ticketId), authorLink(theFan))) + // the row showing proves the list loaded, so an empty owner list means something + .open(closedBy(theOwner)) + .sees(testid("emptyState")) + .sees(not(rowOf(ticketId))); + +fanOpened("an issue the owner edited still belongs to whoever opened it") + // the edit button is for the author only, the owner has to use the api + .step("the owner retitles it through the api", async (ctx) => { + const edited = await editCob(ctx.actor, ctx.get(ticketId).uri, "retitle", (stored) => ({ + ...stored, + title: { ...stored.title, text: fixedTitle } + })); + expect(edited.status, JSON.stringify(edited.body)).toBe(200); + }) + .learn(ticketId) + .follow(ticketId, through(knot, hydrant, bobbin)) + .step( + "bobbin names the fan as the opener everywhere, and the owner as no one's", + bobbinNamesOpener(ticketId, "issue", theFan, theOwner) + ) + .open(page.issue(ticketId)) + .sees(within(testid("ticketTitle"), text(fixedTitle))) + .sees(within(component("Ticket"), authorLink(theFan))); + +fanOpened("gitmirror lists the fan as the opener of an issue the owner closed") + .step("the owner closes it through the api", async (ctx) => { + await tickets.setState(ctx.actor, ctx.get(ticketId), "closed"); + }) + .learn(ticketId) + .follow(ticketId, through(knot, hydrant, bobbin)) + .knotStored(ticketId, "closed") + .step("gitmirror names the fan", gitmirrorNamesOpener(ticketId, theFan)); diff --git a/e2e/specs/merges.spec.ts b/e2e/specs/merges.spec.ts index 2feac895b..8de2e85c9 100644 --- a/e2e/specs/merges.spec.ts +++ b/e2e/specs/merges.spec.ts @@ -19,17 +19,27 @@ import { text, through, unique, - where + where, + within } from "../harness/flow"; import { + authorLink, base, + bobbinNamesOpener, branches, + fanPulled, forkedAhead, + mergedTip, merges, onBranches, opensForkPull, pipelineFor, + reloadsUntil, + rowOf, targetIsAt, + targetTookTheFeature, + theFan, + theOwner, upstreamPull } from "../harness/shared"; @@ -43,6 +53,18 @@ onBranches("a merged pull moves from the open list to the merged list") .open(where("the merged pulls", (ctx) => `${ctx.repo.path}/pulls?state=completed`)) .sees(link(title)); +const fanTitle = unique("a pull the owner merges"); +fanPulled("a pull the owner merged still belongs to whoever opened it", fanTitle) + .actor("owner") + .open(page.pull(pullId)) + .use(merges) + .step( + "bobbin names the fan as the opener everywhere, and the owner as no one's", + bobbinNamesOpener(pullId, "pull", theFan, theOwner) + ) + .open(where("the merged pulls", (ctx) => `${ctx.repo.path}/pulls?state=completed`)) + .sees(within(rowOf(pullId), authorLink(theFan))); + onBranches("the merged branch can be deleted after a merge") .pull(pullId, patchId, branches) .open(page.pull(pullId)) @@ -54,12 +76,9 @@ onBranches("merging a pull request", { ci: true }) .open(page.pull(pullId)) .use(merges) .knotStored(pullId, "completed") - .step("the target branch moved to the feature's commit", targetIsAt(branches.from)) + .step("the target branch took the feature's commit", targetTookTheFeature()) .checkpoint("the merge moved the target branch") - .step( - "spindle has a pipeline for the push the merge made", - pipelineFor("push", (ctx) => ctx.repo.heads[branches.from]) - ); + .step("spindle has a pipeline for the push the merge made", pipelineFor("push", mergedTip)); // the patch names the merge base, so if the target moved after the feature was cut, open from the fork point const marks = new Map(); @@ -102,6 +121,7 @@ forkedAhead("opening a pull request from a fork to the upstream") .actor("owner") .open(upstreamPull) .sees(text(forkTitle)) + .step("the diff shows up", reloadsUntil(testid("diffFileName", { path: "e2e/fork.txt" }))) .sees(testid("diffFileName", { path: "e2e/fork.txt" })); const mergeDisabled = target("the disabled merge button", (tab) => @@ -249,7 +269,7 @@ const closedInAnotherWindow = (name: string) => mergedInAnotherWindow("a second merge from a stale window is refused and the page says why") .knotStored(pullId, "completed") - .step("the target branch moved once, to the feature's commit", targetIsAt(branches.from)); + .step("the target branch moved once", targetTookTheFeature()); mergedInAnotherWindow("a stale window stops offering merge once the knot said it was merged") .sees(not(mergeButton)) diff --git a/e2e/specs/social.spec.ts b/e2e/specs/social.spec.ts index 0436085a2..0a2089e8e 100644 --- a/e2e/specs/social.spec.ts +++ b/e2e/specs/social.spec.ts @@ -1,43 +1,66 @@ import { expect } from "@playwright/test"; -import { pdsCall } from "../harness/api"; +import { comments, pdsCall } from "../harness/api"; import { type Account, config } from "../harness/config"; import { bobbin, commentId, type Ctx, + deliberi, flow, + type Flow, followId, hydrant, knot, not, page, pds, + pullId, + record, rkeyOf, starCount, starId, subscriptionId, + type Slot, target, testid, text, through, ticketId, + type Where, where, within } from "../harness/flow"; import { get, until, xrpc } from "../harness/net"; -import { type Notice, noticesOf } from "../harness/services"; -import { deletesIssue, pdsRecordUrl, recall, remember } from "../harness/shared"; +import { aboutOf, type Notice, noticesMadeFrom, noticesOf } from "../harness/services"; +import { + deletesIssue, + fanOpened, + fanPulled, + made, + mergesThrough, + note as comment, + pdsRecordUrl, + recall, + remember, + theFan, + theOwner +} from "../harness/shared"; import { type Follow } from "../harness/lexicons"; -const noticeFor = async (ctx: Ctx, type: string, about: (notice: Notice) => boolean) => - until(`deliberi should list a ${type} notice for ${ctx.actor.handle}`, 20_000, async () => { - const found = (await noticesOf(ctx.actor)).filter( +const noticeFor = async ( + ctx: Ctx, + type: string, + about: (notice: Notice) => boolean, + who: Account = ctx.actor +) => + until(`deliberi should list a ${type} notice for ${who.handle}`, 20_000, async () => { + const found = (await noticesOf(who)).filter( (notice) => notice.type === type && about(notice) ); return [ found.length > 0, - `it lists ${JSON.stringify((await noticesOf(ctx.actor)).map((notice) => notice.type))}`, + `it lists ${JSON.stringify((await noticesOf(who)).map((notice) => notice.type))}`, found[0] ]; }); @@ -392,3 +415,343 @@ flow("subscribing to an issue and unsubscribing again") .sees(testid("toggleSubscription", { subscribed: "false" })) .reload() .sees(testid("toggleSubscription", { subscribed: "false" })); + +// state changes match by ticket, comments by their own uri +interface Told { + type: string; + slot: Slot; + from?: true; +} + +const isTold = (ctx: Ctx, told: Told) => (notice: Notice) => + notice.type === told.type && + (told.from ? notice.uri : aboutOf(notice)) === ctx.get(told.slot).uri; + +const q = JSON.stringify; +const noticeRow = (told: Told) => + target(`the ${told.type} notice`, (tab, ctx) => { + const uri = told.from ? `[data-uri=${q(ctx.get(told.slot).uri)}]` : ""; + return tab + .locator(`[data-testid="notificationRow"][data-type=${q(told.type)}]${uri}`) + .filter({ visible: true }) + .first(); + }); +const noticeOpener = (told: Told) => + target(`the link of the ${told.type} notice`, (tab, ctx) => + noticeRow(told).locate(tab, ctx).locator('[data-testid="notificationPhraseLink"]') + ); + +const toldTo = + (told: Told, who: (ctx: Ctx) => Account, actor: (ctx: Ctx) => Account) => async (ctx: Ctx) => { + const notice = await noticeFor(ctx, told.type, isTold(ctx, told), who(ctx)); + expect(notice.actorDid).toBe(actor(ctx).did); + expect(notice.read).toBe(false); + }; + +// a missing notice only proves something if someone else got one +const toldNobody = (told: Told, who: (ctx: Ctx) => Account) => async (ctx: Ctx) => { + const found = (await noticesOf(who(ctx))).filter(isTold(ctx, told)); + expect(found, `${who(ctx).name}'s ${told.type} notices`).toEqual([]); +}; + +const markedRead = (told: Told) => async (ctx: Ctx) => { + await until(`deliberi should list the ${told.type} notice as read`, 10_000, async () => { + const found = (await noticesOf(ctx.actor)).find(isTold(ctx, told)); + return [found?.read === true, `it lists ${JSON.stringify(found)}`]; + }); +}; + +const landsOn = (here: Where) => (flow: Flow) => flow.at(here).sees(testid("ticketTitle")); +// pull pages can have a version after the path +const landsUnder = (here: Where) => (flow: Flow) => + flow.step(`the notice opens ${here.describe}`, async (ctx) => { + const path = here.path(ctx).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + await expect(ctx.page).toHaveURL(new RegExp(`^${config.webUrl}${path}(/|$)`)); + await expect(testid("ticketTitle").locate(ctx.page, ctx)).toBeVisible(); + }); + +const opensNotice = (told: Told, unread: number, lands: (flow: Flow) => Flow) => (flow: Flow) => + flow + .open(notices) + .sees(noticeRow(told)) + .sees(bell(unread)) + .click(noticeOpener(told)) + .use(lands) + .step(`deliberi lists the ${told.type} notice as read`, markedRead(told)) + .sees(bell(unread - 1)); + +// a missing row only proves something once the control row shows the list loaded +const notAmongNotices = (control: Told, missing: Told) => (flow: Flow) => + flow + .open(notices) + .sees(noticeRow(control)) + .sees(not(noticeRow(missing))); + +const deliberiToo = through(knot, hydrant, bobbin, deliberi); + +const closed = { type: "issue_closed", slot: ticketId } satisfies Told; +const reopened = { type: "issue_reopen", slot: ticketId } satisfies Told; +const issueOpened = { type: "issue_created", slot: ticketId } satisfies Told; +const toggle = (subscribed: boolean) => + testid("toggleSubscription", { subscribed: String(subscribed) }); + +fanOpened("closing and reopening an issue tells whoever opened it and not whoever did it") + .open(page.issue(ticketId)) + .changeState( + ticketId, + "closed", + testid("requestStateChange", { state: "open" }), + testid("requestStateChange", { state: "closed" }), + deliberiToo + ) + .step( + "deliberi made the fan an issue_closed notice naming the owner", + toldTo(closed, theFan, theOwner) + ) + .step("deliberi made the owner none", toldNobody(closed, theOwner)) + .use(notAmongNotices(issueOpened, closed)) + .actor("fan") + .use(opensNotice(closed, 1, landsOn(page.issue(ticketId)))) + + .actor("owner") + .open(page.issue(ticketId)) + .changeState( + ticketId, + "open", + testid("requestStateChange", { state: "closed" }), + testid("requestStateChange", { state: "open" }), + deliberiToo + ) + .step( + "deliberi made the fan an issue_reopen notice naming the owner", + toldTo(reopened, theFan, theOwner) + ) + .step("deliberi made the owner none", toldNobody(reopened, theOwner)) + .use(notAmongNotices(issueOpened, reopened)) + .actor("fan") + .use(opensNotice(reopened, 1, landsOn(page.issue(ticketId)))); + +const merged = { type: "pull_merged", slot: pullId } satisfies Told; +const pullClosed = { type: "pull_closed", slot: pullId } satisfies Told; +const pullReopened = { type: "pull_reopen", slot: pullId } satisfies Told; +const pullOpened = { type: "pull_created", slot: pullId } satisfies Told; + +fanPulled("merging a pull tells whoever opened it and not whoever merged it") + .open(page.pull(pullId)) + .use(mergesThrough(deliberiToo)) + .step( + "deliberi made the fan a pull_merged notice naming the owner", + toldTo(merged, theFan, theOwner) + ) + .step("deliberi made the owner none", toldNobody(merged, theOwner)) + .use(notAmongNotices(pullOpened, merged)) + .actor("fan") + .use(opensNotice(merged, 1, landsUnder(page.pull(pullId)))); + +fanPulled("closing and reopening a pull tells whoever opened it and not whoever did it") + .open(page.pull(pullId)) + .changeState(pullId, "closed", testid("requestClose"), testid("changeState"), deliberiToo) + .step( + "deliberi made the fan a pull_closed notice naming the owner", + toldTo(pullClosed, theFan, theOwner) + ) + .step("deliberi made the owner none", toldNobody(pullClosed, theOwner)) + .use(notAmongNotices(pullOpened, pullClosed)) + .actor("fan") + .use(opensNotice(pullClosed, 1, landsUnder(page.pull(pullId)))) + + .actor("owner") + .open(page.pull(pullId)) + .changeState(pullId, "open", testid("changeState"), testid("requestClose"), deliberiToo) + .step( + "deliberi made the fan a pull_reopen notice naming the owner", + toldTo(pullReopened, theFan, theOwner) + ) + .step("deliberi made the owner none", toldNobody(pullReopened, theOwner)) + .use(notAmongNotices(pullOpened, pullReopened)) + .actor("fan") + .use(opensNotice(pullReopened, 1, landsUnder(page.pull(pullId)))); + +const aComment = record("org.tangled.feed.comment"); +const anotherComment = record("org.tangled.feed.comment"); + +// comments through the api, so the page sends nothing this flow could check +const commentsAs = (slot: Slot, words: string) => async (ctx: Ctx) => { + const written = made( + `${ctx.actor.name} commenting`, + await comments.add(ctx.actor, ctx.repo.did, ctx.get(ticketId), words) + ); + ctx.adopt(slot, written); + await bobbin.indexed(written); +}; + +const newComment = within(testid("commentEditor", { mode: "new" }), testid("body")); +const submitComment = within(testid("commentEditor", { mode: "new" }), testid("submit")); + +const hasIgnore = (muted: boolean) => async (ctx: Ctx) => { + const { body } = await get( + pdsRecordUrl(ctx.actor.did, subscriptionId.nsid, rkeyOf(ctx, subscriptionId)) + ); + expect(body.value.ignore === true, JSON.stringify(body.value)).toBe(muted); +}; + +const firstComment = { type: "issue_commented", slot: aComment, from: true } satisfies Told; +const secondComment = { type: "issue_commented", slot: anotherComment, from: true } satisfies Told; +const theAuthor = (ctx: Ctx) => recall(ctx, "author"); +const fanFollowsAuthor = async (ctx: Ctx) => { + const written = await pdsCall(ctx.actor, "com.atproto.repo.putRecord", { + collection: followId.nsid, + rkey: theAuthor(ctx).did, + record: { $type: followId.nsid, createdAt: new Date().toISOString() } satisfies Follow + }); + await bobbin.indexed(written); +}; +const followed = { type: "followed", slot: followId } satisfies Told; + +flow("an author who mutes their own issue hears no comments until they comment again") + .actor("owner") + .step("remember the owner", remember("owner")) + .repo() + .actor("author") + .step("remember the author", remember("author")) + .open(page.newIssue) + .fill(testid("title"), issueTitle) + .click(testid("submit")) + .trace(ticketId, deliberiToo) + // opening subscribes the author, so this record exists to mute + .trace(subscriptionId, through(pds, hydrant, bobbin, deliberi)) + .open(page.issue(ticketId)) + .sees(toggle(true)) + .click(toggle(true)) + .sends( + subscriptionId, + pds, + (ctx) => ({ rkey: rkeyOf(ctx, subscriptionId), record: { ignore: true } }), + "put" + ) + .sees(testid("toggleSubscription", { subscribed: "false", pending: "true" })) + .trace(subscriptionId, through(pds, hydrant, bobbin, deliberi)) + .step("bobbin has the subscription as a mute", subscribed(issueUri, "muted")) + .sees(toggle(false)) + + .actor("fan") + .step("remember the fan", remember("fan")) + .step("the fan comments", commentsAs(aComment, "while muted")) + .follow(aComment, through(deliberi)) + .step("the fan follows the author, a notice the mute doesn't touch", fanFollowsAuthor) + .actor("owner") + .step("deliberi told the owner about the comment", toldTo(firstComment, theOwner, theFan)) + .actor("author") + .step("deliberi told the author about the follow, so it had caught up", async (ctx) => { + await noticeFor(ctx, "followed", (n) => n.actorDid === recall(ctx, "fan").did); + }) + .step("deliberi made the muted author no notice from the comment", async (ctx) => { + expect(await noticesMadeFrom(ctx.get(aComment).uri, [ctx.actor])).toEqual([]); + }) + .open(notices) + .sees(noticeRow(followed)) + .sees(not(noticeRow(firstComment))) + .open(page.issue(ticketId)) + .fill(newComment, comment) + .click(submitComment) + .trace(commentId, deliberiToo) + .trace(subscriptionId, through(pds, hydrant, bobbin, deliberi)) + .step("the subscription record lost its ignore", hasIgnore(false)) + .step("bobbin lists the subscription as live", subscribed(issueUri, true)) + .sees(toggle(true)) + .reload() + .sees(toggle(true)) + + .actor("fan") + .step("the fan comments again", commentsAs(anotherComment, "now they are back")) + .follow(anotherComment, through(deliberi)) + .actor("author") + .step( + "deliberi told the author about the second comment", + toldTo(secondComment, theAuthor, theFan) + ) + .use(opensNotice(secondComment, 2, landsOn(page.issue(ticketId)))); + +const theOther = (ctx: Ctx) => recall(ctx, "other"); + +fanOpened("an owner who mutes an issue on their repo hears no comments until they unmute it") + .open(page.issue(ticketId)) + // no subscription row, but the owner still gets told + .step("bobbin has no subscription for the owner", subscribed(issueUri, false)) + .sees(toggle(true)) + .click(toggle(true)) + .sends( + subscriptionId, + pds, + (ctx) => ({ + record: { + ignore: true, + subject: { $type: "org.tangled.feed.subscription#record", uri: issueUri(ctx) } + } + }), + "create" + ) + .sees(testid("toggleSubscription", { subscribed: "false", pending: "true" })) + .trace(subscriptionId, through(pds, hydrant, bobbin, deliberi)) + .step("bobbin has the subscription as a mute", subscribed(issueUri, "muted")) + .reload() + .sees(toggle(false)) + + .actor("other") + .step("remember the other", remember("other")) + .step("the other comments", commentsAs(aComment, "while the owner is muted")) + .follow(aComment, through(deliberi)) + .actor("fan") + .step("deliberi told the fan who opened it", toldTo(firstComment, theFan, theOther)) + .actor("owner") + .step( + "deliberi made the muted owner no notice from the comment", + toldNobody(firstComment, theOwner) + ) + .use(notAmongNotices(issueOpened, firstComment)) + .actor("fan") + .use(opensNotice(firstComment, 1, landsOn(page.issue(ticketId)))) + + .actor("owner") + .open(page.issue(ticketId)) + .sees(toggle(false)) + .click(toggle(false)) + .sends(subscriptionId, pds, (ctx) => ({ rkey: rkeyOf(ctx, subscriptionId) }), "put") + .sees(testid("toggleSubscription", { subscribed: "true", pending: "true" })) + .trace(subscriptionId, through(pds, hydrant, bobbin, deliberi)) + .step("the subscription record lost its ignore", hasIgnore(false)) + .step("bobbin lists the subscription as live", subscribed(issueUri, true)) + + .actor("other") + .step("the other comments again", commentsAs(anotherComment, "now the owner is back")) + .follow(anotherComment, through(deliberi)) + .actor("owner") + .step( + "deliberi told the owner about the second comment", + toldTo(secondComment, theOwner, theOther) + ) + // the mute visit already marked the created notice read + .use(opensNotice(secondComment, 1, landsOn(page.issue(ticketId)))); + +flow( + "an issue's author and the repo's owner start out subscribed with no record, a stranger doesn't" +) + .actor("fan") + .step("remember the fan", remember("fan")) + .actor("owner") + .repo() + .actor("fan") + .ticket(ticketId, { title: "opened through the api" }) + + .actor("owner") + .open(page.issue(ticketId)) + .sees(toggle(true)) + .step("bobbin has no subscription for the owner", subscribed(issueUri, false)) + .actor("fan") + .open(page.issue(ticketId)) + .sees(toggle(true)) + .step("bobbin has no subscription for the author", subscribed(issueUri, false)) + .actor("other") + .open(page.issue(ticketId)) + .sees(toggle(false)) + .step("bobbin has no subscription for the stranger", subscribed(issueUri, false)); -- 2.54.0