Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ auth*.json
**/playwright-report/
_bmad
_bmad-output
skills-lock.json

!docs/
docs/*
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ While email notifications are available so that you may get notified when feeds
- `BACKEND_API_SMTP_HOST`
- `BACKEND_API_SMTP_USERNAME`
- `BACKEND_API_SMTP_PASSWORD`
- `BACKEND_API_SMTP_FROM`

Plus exactly one of the following (config validation fails on boot if SMTP is configured without one of these):

- `BACKEND_API_SMTP_FROM_DOMAIN` — your sending domain only (e.g. `mydomain.com`). MonitoRSS will send from distinct per-purpose addresses on this domain such as `alerts@mydomain.com` (feed/connection alerts) and `noreply@mydomain.com` (email verification). Recommended.
- `BACKEND_API_SMTP_FROM` — a full RFC 5322 sender (e.g. `"MyService" <mail@mydomain.com>`). Used verbatim for every outbound email. Use this only if your SMTP provider restricts you to a single fixed sender address. Takes precedence over `BACKEND_API_SMTP_FROM_DOMAIN`.

Make sure to opt into email notifications in the control panel's user settings page afterwards.

Expand Down
40 changes: 40 additions & 0 deletions docker-compose.e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ services:
- /data/db
- /data/configdb
ports:
# Default host 27019 (not 27018) so the e2e stack can run alongside the dev
# stack (docker-compose.dev.yml publishes mongo on 27018). Parameterized so
# concurrent e2e runs can each bind a distinct host port. The backend reaches
# mongo over the internal network (mongo:27017), unaffected by this mapping;
# only host-side e2e helpers use it (see e2e/helpers/constants.ts MONGO_URI).
- "${E2E_MONGO_PORT:-27019}:27017"
command: ["--replSet", "dbrs", "--bind_ip_all", "--port", "27017"]
# The replica set is initiated via the healthcheck rather than a
Expand Down Expand Up @@ -242,6 +247,14 @@ services:
context: services/backend-api
dockerfile: Dockerfile.dev
restart: "no"
# Source is bind-mounted (narrowly: src only, node_modules stays in the
# image) so a fresh `up` always runs current backend code — image rebuilds
# are only needed for dependency changes, and the stale-COPY-layer trap (a
# cached build silently running old source) is structurally impossible.
# Cheap here because Node reads modules once at boot; web-client stays
# in-image (see its comment).
volumes:
- ./services/backend-api/src:/usr/src/app/src
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
Expand Down Expand Up @@ -274,11 +287,32 @@ services:
- BACKEND_API_MONGODB_URI=mongodb://mongo:27017/rss?replicaSet=dbrs&directConnection=true
- BACKEND_API_RABBITMQ_BROKER_URL=amqp://guest:guest@rabbitmq-broker:5672/
- BACKEND_API_LOGIN_REDIRECT_URI=http://localhost:${E2E_FRONTEND_PORT:-3100}
# Mock mailer (plain SMTP on the host, reached via host.docker.internal) so
# the email-verification one-time-code flow can be driven through the UI.
- BACKEND_API_SMTP_HOST=host.docker.internal
- BACKEND_API_SMTP_PORT=${E2E_MOCK_SMTP_PORT:-3004}
- BACKEND_API_SMTP_SECURE=false
- BACKEND_API_SMTP_USERNAME=mock
- BACKEND_API_SMTP_PASSWORD=mock
- BACKEND_API_SMTP_FROM_DOMAIN=example.com
- BACKEND_API_PADDLE_KEY=${BACKEND_API_PADDLE_KEY:-}
- BACKEND_API_PADDLE_URL=${BACKEND_API_PADDLE_URL:-}
- BACKEND_API_PADDLE_WEBHOOK_SECRET=${BACKEND_API_PADDLE_WEBHOOK_SECRET:-}
# Reddit OAuth + authenticated feed fetches are served by the host-side mock
# reddit server (e2e/mock-reddit-server.ts), reached via host.docker.internal
# by both the backend (token exchange) and the browser popup (authorize).
- BACKEND_API_REDDIT_CLIENT_ID=${BACKEND_API_REDDIT_CLIENT_ID:-}
- BACKEND_API_REDDIT_CLIENT_SECRET=fake-reddit-client-secret
- BACKEND_API_REDDIT_REDIRECT_URI=http://localhost:${E2E_BACKEND_PORT:-8100}/api/v1/reddit/callback
- BACKEND_API_REDDIT_API_BASE_URL=http://host.docker.internal:${E2E_MOCK_REDDIT_PORT:-3006}/api/v1
- BACKEND_API_REDDIT_AUTHENTICATED_FEED_BASE_URL=http://host.docker.internal:${E2E_MOCK_REDDIT_PORT:-3006}
# Reddit tokens are encrypted at rest; any fixed 64-char hex key works for e2e.
- BACKEND_API_ENCRYPTION_KEY_HEX=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
- BACKEND_API_ENABLE_SUPPORTERS=true
# Small workspace feed limit so the over-limit enforcement spec can exercise
# disable/re-enable with a handful of seeded feeds. Other specs create at
# most a couple of feeds per workspace.
- BACKEND_API_DEFAULT_MAX_WORKSPACE_FEEDS=5
- LOG_LEVEL=debug
- NODE_ENV=local
command: npm run dev
Expand All @@ -291,6 +325,12 @@ services:
dockerfile: Dockerfile.dev
working_dir: /usr/src/app/client
restart: "no"
# Deliberately NOT bind-mounted (unlike web-api): vite transforms modules per
# request, and on a Windows host that I/O through the Docker file bridge made
# parallel-worker app loads exceed their timeouts (measured: 6/29 workspace
# specs failed on load). Client source changes therefore still require an
# image rebuild — use `build --no-cache web-client` (a cached `up --build`
# can silently reuse a stale COPY layer).
depends_on:
web-api:
condition: service_healthy
Expand Down
12 changes: 9 additions & 3 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@ The E2E Docker stack (defined in `docker-compose.e2e.yml`) provides all required

`e2e-mock.sh` is the canonical wrapper: it brings up the full Docker stack (`up -d --build --wait`), runs Playwright, and tears the stack down on exit. **Any arguments after the script name are forwarded straight to `playwright test`**, so you can scope a run to a single file and/or project. Always go through this script (or the `npm run e2e*` aliases) rather than starting the stack and Playwright by hand — `--build` is required because the `web-api` source is baked into its image (no bind mount), so backend changes won't take effect otherwise.

The script writes two logs to `e2e/logs/` (gitignored) that outlive the torn-down stack:
The script writes logs to `e2e/logs/` (gitignored) that outlive the torn-down stack. **If a run fails, read `logs/combined.log` first** — it is the one file containing everything, top to bottom:

- `logs/playwright.log` — the full Playwright run output.
- `logs/docker-stack.log` — `docker compose logs` for all services, captured just before teardown. This is the only place to inspect container-side behaviour after a run, e.g. inbound Paddle webhooks in `web-api` ("Paddle webhook received" / "Invalid signature received for paddle webhook event").
- `logs/combined.log` — **read this first after a run ends.** Playwright run output + every container's logs + all three mock servers, concatenated under `===== SECTION =====` headers. Assembled on teardown. The script prints this path when the run starts and again when it ends.
- `logs/playwright.log` — the Playwright run output (written live via `tee`).
- `logs/docker-stack.log` — `docker compose logs --timestamps --follow` for all services, streamed **live** for the whole run. The place to inspect container-side behaviour, e.g. inbound Paddle webhooks in `web-api` ("Paddle webhook received" / "Invalid signature received for paddle webhook event").
- `logs/mock-rss.log`, `logs/mock-discord.log`, `logs/mock-smtp.log` — the host-side mock servers Playwright launches, written live. Look here for things like `[mock-discord] Unmatched: <method> <path>` when a request isn't being mocked.

`combined.log` is assembled on teardown, so it only exists once the run ends. **While a run is still going (e.g. a hang), read the four source files above — they are all written live.**

Concurrent runs (`E2E_INSTANCE > 0`) suffix every log file with `-<instance>` (e.g. `combined-1.log`).

```bash
# Run all regular (non-paddle) tests via Docker stack (defaults to --project=e2e-web)
Expand Down
73 changes: 62 additions & 11 deletions e2e/e2e-mock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ port_in_use() {
# already held 3001/3002.
instance_ports() {
local off=$(($1 * STRIDE))
echo "$((8100 + off)) $((3100 + off)) $((27019 + off)) $((3001 + off)) $((3002 + off))"
echo "$((8100 + off)) $((3100 + off)) $((27019 + off)) $((3001 + off)) $((3002 + off)) $((3006 + off))"
}

instance_is_free() {
Expand Down Expand Up @@ -68,10 +68,12 @@ export E2E_FRONTEND_PORT=$((3100 + OFF))
export E2E_MONGO_PORT=$((27019 + OFF))
export E2E_MOCK_RSS_PORT=$((3001 + OFF))
export E2E_MOCK_DISCORD_PORT=$((3002 + OFF))
export E2E_MOCK_REDDIT_PORT=$((3006 + OFF))

# Enable the mandatory-Reddit-connection gate for the mocked suite so the
# gate-render flow can be exercised. Reddit OAuth itself is never hit (the gate
# short-circuits before any outbound request), so any non-empty id will do.
# Enable the mandatory-Reddit-connection gate for the mocked suite. Reddit OAuth
# and authenticated feed fetches are served by the host-side mock reddit server
# (see docker-compose.e2e.yml BACKEND_API_REDDIT_* env vars), so the full
# connect -> fetch flow can be exercised; any non-empty id will do.
export BACKEND_API_REDDIT_CLIENT_ID="${BACKEND_API_REDDIT_CLIENT_ID:-e2e-reddit-client-id}"

if [ "$E2E_INSTANCE" = 0 ]; then
Expand All @@ -97,21 +99,33 @@ PLAYWRIGHT_ARGS="${@:---project=e2e-web}"
LOG_DIR="$SCRIPT_DIR/logs"
RUN_LOG="$LOG_DIR/playwright${INSTANCE_SUFFIX}.log"
DOCKER_LOG="$LOG_DIR/docker-stack${INSTANCE_SUFFIX}.log"
# Single file an agent can read top-to-bottom after a failure: Playwright run +
# every container's logs + all three host-side mock servers, with section headers.
COMBINED_LOG="$LOG_DIR/combined${INSTANCE_SUFFIX}.log"
mkdir -p "$LOG_DIR"

# When a Paddle key is present, create an EPHEMERAL notification setting for this run
# instead of repointing a shared one (which would hijack local dev's webhook delivery).
# Its signing secret must be known before the backend boots (the backend reads
# BACKEND_API_PADDLE_WEBHOOK_SECRET once at startup), so create it here, not in Playwright.
#
# Only runs that include the e2e-paddle project (or target a billing spec directly)
# receive real Paddle webhooks; the mocked e2e-web project never does, so creating a
# setting for every CI shard only consumed Paddle's cap on active notification
# settings ("notification_maximum_active_settings_reached" killed shards at setup).
RUN_NEEDS_PADDLE_SETTING=""
case "$PLAYWRIGHT_ARGS" in
*e2e-paddle*|*billing*) RUN_NEEDS_PADDLE_SETTING=1 ;;
esac

# To use your OWN notification setting instead, set E2E_PADDLE_NOTIFICATION_SETTING_ID
# (and the matching BACKEND_API_PADDLE_WEBHOOK_SECRET) in e2e/.env: the script then skips
# create/delete and leaves your setting in place (only repointing its destination to the
# tunnel during setup).
PADDLE_SETTING_EPHEMERAL=""
if [ -n "${E2E_PADDLE_NOTIFICATION_SETTING_ID:-}" ]; then
echo "Using provided Paddle notification setting: $E2E_PADDLE_NOTIFICATION_SETTING_ID"
elif [ -n "${BACKEND_API_PADDLE_KEY:-}" ]; then
elif [ -n "${BACKEND_API_PADDLE_KEY:-}" ] && [ -n "$RUN_NEEDS_PADDLE_SETTING" ]; then
echo "Creating ephemeral Paddle notification setting..."
created="$(npx tsx "$SCRIPT_DIR/scripts/paddle-notification-setting.ts" create)"
PADDLE_SETTING_EPHEMERAL="$(echo "$created" | sed -n '1p')"
Expand All @@ -126,11 +140,32 @@ cleanup() {
echo "Deleting ephemeral Paddle notification setting: $PADDLE_SETTING_EPHEMERAL"
npx tsx "$SCRIPT_DIR/scripts/paddle-notification-setting.ts" delete "$PADDLE_SETTING_EPHEMERAL" || true
fi
echo "Capturing container logs to $DOCKER_LOG ..."
docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT_NAME" logs --no-color \
>"$DOCKER_LOG" 2>&1 || true
# Stop the live `logs --follow` (started after stack boot). $DOCKER_LOG is already
# populated in real time, so there's nothing to capture here — just end the stream.
if [ -n "${DOCKER_LOGS_PID:-}" ]; then
kill "$DOCKER_LOGS_PID" 2>/dev/null || true
wait "$DOCKER_LOGS_PID" 2>/dev/null || true
fi

# Fold everything into one file so an agent can read a single log after a failure.
# The mock-*.log files are written by Playwright's webServers (see playwright.config.ts).
echo "Writing combined log to $COMBINED_LOG ..."
{
echo "===== PLAYWRIGHT ====="
cat "$RUN_LOG" 2>/dev/null || echo "(no playwright log)"
echo
echo "===== DOCKER STACK ====="
cat "$DOCKER_LOG" 2>/dev/null || echo "(no docker log)"
for mock in rss discord smtp reddit; do
echo
echo "===== MOCK: $mock ====="
cat "$LOG_DIR/mock-${mock}${INSTANCE_SUFFIX}.log" 2>/dev/null || echo "(no mock-$mock log)"
done
} >"$COMBINED_LOG" 2>&1 || true

echo "Tearing down E2E Docker stack..."
docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT_NAME" down --volumes --remove-orphans
echo "Combined log (read this first if the run failed): $COMBINED_LOG"
}
trap cleanup EXIT

Expand All @@ -141,17 +176,33 @@ else
fi

echo "Starting E2E Docker stack (instance: $E2E_INSTANCE, project: $COMPOSE_PROJECT_NAME)..."
echo " backend=$E2E_BACKEND_PORT frontend=$E2E_FRONTEND_PORT mongo=$E2E_MONGO_PORT rss-mock=$E2E_MOCK_RSS_PORT discord-mock=$E2E_MOCK_DISCORD_PORT"
echo " backend=$E2E_BACKEND_PORT frontend=$E2E_FRONTEND_PORT mongo=$E2E_MONGO_PORT rss-mock=$E2E_MOCK_RSS_PORT discord-mock=$E2E_MOCK_DISCORD_PORT reddit-mock=$E2E_MOCK_REDDIT_PORT"
# On startup failure, dump container status + logs to stdout before the EXIT trap
# tears the stack down — in CI this output is the only diagnosable record of why a
# container exited (e.g. web-api crashing before becoming healthy).
# tears the stack down — the live log follower below hasn't started yet, so this
# output is the only diagnosable record of why a container exited (e.g. web-api
# crashing before becoming healthy).
if ! docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT_NAME" up -d --build --wait; then
echo "E2E stack failed to start; container status and recent logs follow:"
docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT_NAME" ps -a || true
docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT_NAME" logs --no-color --tail=200 || true
exit 1
fi

# Follow container logs into $DOCKER_LOG live, so an agent inspecting a hung/slow run
# sees current container output without waiting for teardown. The follower is stopped
# in cleanup. (Playwright and mock-server logs are already written live elsewhere.)
docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT_NAME" logs --no-color --timestamps --follow \
>"$DOCKER_LOG" 2>&1 &
DOCKER_LOGS_PID=$!

echo "Running E2E tests... (output also written to $RUN_LOG)"
echo "On failure, read the combined log (Playwright + all containers + mock servers): $COMBINED_LOG"
# Playwright discovers its config from the CURRENT WORKING DIRECTORY, and the only
# config is e2e/playwright.config.ts (there is none at the repo root). This script
# does not cd, so it must be run with cwd = e2e/ (the `npm run e2e*` aliases do this).
# Run it from the repo root instead and Playwright finds no config: it falls back to
# an implicit unnamed project with no baseURL, so `page.goto("/feeds")` fails with
# "Cannot navigate to invalid URL" and `--project=e2e-web` errors with
# 'Available projects: ""'. Run from e2e/ (or via `npm run e2e -- <spec>`).
E2E_BACKEND_URL="$BACKEND_URL" E2E_BASE_URL="$FRONTEND_URL" \
npx playwright test $PLAYWRIGHT_ARGS 2>&1 | tee "$RUN_LOG"
44 changes: 42 additions & 2 deletions e2e/fixtures/test-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type Response,
type BrowserContext,
type Browser,
type TestInfo,
} from "@playwright/test";
import {
createFeed,
Expand Down Expand Up @@ -68,6 +69,45 @@ async function createMockSessionCookies(): Promise<MockCookie[]> {
];
}

// A timed-out `toBeVisible` on an authenticated page is usually a symptom: the
// real cause (an auth/session failure, an unhandled fetch error) only ever
// reached the browser console, which Playwright does not echo into the report.
// This promotes those signals to test annotations so the next failure is legible
// from the report alone, without downloading and parsing a trace. Attaching at the
// context level covers every page the context opens — including ones created after
// this call — so callers never wire individual pages.
function surfaceBrowserErrors(context: BrowserContext, testInfo: TestInfo): void {
const note = (kind: string, detail: string) => {
testInfo.annotations.push({ type: kind, description: detail });
};

context.on("console", (msg) => {
if (msg.type() === "error" && /unauthorized|401|ApiAdapterError/i.test(msg.text())) {
note("browser-auth-error", msg.text().slice(0, 300));
}
});
context.on("weberror", (err) => note("browser-page-error", err.error().message.slice(0, 300)));
context.on("response", (res) => {
const url = res.url();
if (res.status() === 401 && url.includes("/api/v1/")) {
note("http-401", `401 on ${url.replace(/^https?:\/\/[^/]+/, "")}`);
}
});
}

// Creates a fresh browser context with browser-error surfacing already attached.
// Both the default `page` fixture and any spec that needs its own context (e.g. a
// second logged-out actor) go through this, so error visibility is uniform and no
// call site touches the listener wiring directly.
export async function newInstrumentedContext(
browser: Browser,
testInfo: TestInfo,
): Promise<BrowserContext> {
const context = await browser.newContext();
surfaceBrowserErrors(context, testInfo);
return context;
}

type TestFixtures = {
testFeed: Feed;
testFeedWithConnection: FeedWithConnection;
Expand Down Expand Up @@ -104,9 +144,9 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
{ scope: "worker" },
],

page: async ({ browser }, use) => {
page: async ({ browser }, use, testInfo) => {
const cookies = await createMockSessionCookies();
const context = await browser.newContext();
const context = await newInstrumentedContext(browser, testInfo);
await context.addCookies(cookies);
const page = await context.newPage();
await use(page);
Expand Down
19 changes: 19 additions & 0 deletions e2e/helpers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,25 @@ export async function copyConnectionSettings(
}
}

export async function createWorkspace(
page: Page,
workspace: { name: string; slug: string },
): Promise<{ id: string; name: string; slug: string }> {
const response = await page.request.post("/api/v1/workspaces", { data: workspace });

if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to create workspace: ${response.status()} - ${text}`);
}

const data = await response.json();
return {
id: data.result.id,
name: data.result.name,
slug: data.result.slug,
};
}

export async function getAllUserFeeds(page: Page): Promise<Feed[]> {
const response = await page.request.get(
"/api/v1/user-feeds?limit=100&offset=0",
Expand Down
Loading
Loading