Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
23 changes: 23 additions & 0 deletions .github/workflows/e2e-staging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,24 @@ jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
environment: staging
permissions:
id-token: write
actions: write
contents: read
env:
SST_STAGE: staging
AWS_REGION: us-east-2
BASE_URL: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.base_url || 'https://staging.fil.one' }}
E2E_PAID_EMAIL: ${{ secrets.E2E_PAID_EMAIL }}
E2E_PAID_PASSWORD: ${{ secrets.E2E_PAID_PASSWORD }}
E2E_PAID_USER_ID: ${{ secrets.E2E_PAID_USER_ID }}
E2E_UNPAID_EMAIL: ${{ secrets.E2E_UNPAID_EMAIL }}
E2E_UNPAID_PASSWORD: ${{ secrets.E2E_UNPAID_PASSWORD }}
E2E_UNPAID_USER_ID: ${{ secrets.E2E_UNPAID_USER_ID }}
E2E_TRIAL_EMAIL: ${{ secrets.E2E_TRIAL_EMAIL }}
E2E_TRIAL_PASSWORD: ${{ secrets.E2E_TRIAL_PASSWORD }}
E2E_TRIAL_USER_ID: ${{ secrets.E2E_TRIAL_USER_ID }}

steps:
- uses: actions/checkout@v5
Expand All @@ -42,6 +55,16 @@ jobs:
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps

# auth.setup.ts re-seeds BillingTable rows for the test users via direct
# DynamoDB writes (see tests/e2e/billing-reset.ts). The test:e2e script
# wraps Playwright in `sst shell` so SST Resource bindings (BillingTable
# name, etc.) resolve to the staging stage.
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: us-east-2

- name: Run E2E tests
run: pnpm test:e2e

Expand Down
29 changes: 27 additions & 2 deletions .github/workflows/test-staging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
environment: staging
permissions:
id-token: write
contents: read
env:
SST_STAGE: staging
AWS_REGION: us-east-2
BASE_URL: https://staging.fil.one
E2E_PAID_EMAIL: ${{ secrets.E2E_PAID_EMAIL }}
E2E_PAID_PASSWORD: ${{ secrets.E2E_PAID_PASSWORD }}
E2E_PAID_USER_ID: ${{ secrets.E2E_PAID_USER_ID }}
E2E_UNPAID_EMAIL: ${{ secrets.E2E_UNPAID_EMAIL }}
E2E_UNPAID_PASSWORD: ${{ secrets.E2E_UNPAID_PASSWORD }}
E2E_UNPAID_USER_ID: ${{ secrets.E2E_UNPAID_USER_ID }}
E2E_TRIAL_EMAIL: ${{ secrets.E2E_TRIAL_EMAIL }}
E2E_TRIAL_PASSWORD: ${{ secrets.E2E_TRIAL_PASSWORD }}
E2E_TRIAL_USER_ID: ${{ secrets.E2E_TRIAL_USER_ID }}

steps:
- uses: actions/checkout@v5
Expand All @@ -29,10 +46,18 @@ jobs:
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps

# auth.setup.ts re-seeds BillingTable rows for the test users via direct
# DynamoDB writes (see tests/e2e/billing-reset.ts). The test:e2e script
# wraps Playwright in `sst shell` so SST Resource bindings (BillingTable
# name, etc.) resolve to the staging stage.
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: us-east-2

- name: Run E2E tests
run: pnpm test:e2e
env:
BASE_URL: https://staging.fil.one

- name: Upload Playwright report
uses: actions/upload-artifact@v4
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ contracts/out/

# Sentry Config File
.env.sentry-build-plugin
.auth
52 changes: 48 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,21 +134,39 @@ cd packages/website && pnpm run dev

### E2E Tests

The repo includes a Playwright end-to-end test suite under `tests/e2e/`. The `@playwright/test` package is already a devDependency, so `pnpm install` covers it.
Playwright end-to-end test suite under `tests/e2e/` that runs against a deployed environment (typically staging). The `@playwright/test` package is already a devDependency, so `pnpm install` covers it.
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sentence is missing a subject (reads like a fragment). Consider rewriting to a complete sentence (e.g., The repo includes a Playwright end-to-end test suite...) for clarity.

Copilot uses AI. Check for mistakes.

**Install browser binaries** (one-time):

```bash
pnpm exec playwright install --with-deps
```

**Run tests** against a deployed stage:
#### Required env vars

| Variable | Purpose |
| ----------------------------------------------------------------- | ----------------------------------------------------- |
| `BASE_URL` | The deployed app URL (e.g. `https://staging.fil.one`) |
| `E2E_PAID_EMAIL` / `E2E_PAID_PASSWORD` / `E2E_PAID_USER_ID` | Paid test user (Auth0 sub goes in `_USER_ID`) |
| `E2E_UNPAID_EMAIL` / `E2E_UNPAID_PASSWORD` / `E2E_UNPAID_USER_ID` | Unpaid test user |
| `E2E_TRIAL_EMAIL` / `E2E_TRIAL_PASSWORD` / `E2E_TRIAL_USER_ID` | Trial test user |

In CI, all nine credential vars come from GitHub repository secrets (see [.github/workflows/e2e-staging.yaml](.github/workflows/e2e-staging.yaml) and [.github/workflows/test-staging.yaml](.github/workflows/test-staging.yaml)). Both workflows also configure AWS via OIDC (using `vars.AWS_ROLE_ARN`) so the billing-state reset can write to the staging `BillingTable`.

#### Running locally

The `test:e2e` script wraps Playwright in `sst shell` so SST Resource bindings (e.g. `BillingTable` name) resolve to the current SST stage. Deploy a stage first, then:

```bash
BASE_URL=<your-cloudfront-url> pnpm test:e2e
SST_STAGE=staging \
BASE_URL=https://staging.fil.one \
E2E_PAID_EMAIL=... E2E_PAID_PASSWORD=... E2E_PAID_USER_ID=auth0|... \
E2E_UNPAID_EMAIL=... E2E_UNPAID_PASSWORD=... E2E_UNPAID_USER_ID=auth0|... \
E2E_TRIAL_EMAIL=... E2E_TRIAL_PASSWORD=... E2E_TRIAL_USER_ID=auth0|... \
pnpm test:e2e --project=chromium
```

`BASE_URL` is required and should point to a deployed SST stage (personal dev stack, staging, etc.).
Your local AWS credentials (e.g. via `aws sso login`) must have write access to the staging stage's `BillingTable`.

After a run, an HTML report is generated at `playwright-report/`. To view it:

Expand All @@ -158,6 +176,32 @@ pnpm exec playwright show-report

> CI runs these tests automatically against preview deployments on PRs.

#### Subscription state reset

[tests/e2e/auth.setup.ts](tests/e2e/auth.setup.ts) re-seeds the `BillingTable` row for each role before logging that role in. This is necessary because trial periods can elapse and `past_due` subscriptions can advance to `canceled` between scheduled runs, so prior runs' state is not safe to reuse.

The seed values come from [tests/e2e/billing-reset.ts](tests/e2e/billing-reset.ts):

- **paid** → `active`, `currentPeriodEnd` = now + 30d
- **unpaid** → `past_due`, `lastPaymentFailedAt` = yesterday
- **trial** → `trialing`, `trialEndsAt` = now + 14d

The reset writes directly to DynamoDB (mirrors the integration-test pattern in [tests/integration/helpers.ts](tests/integration/helpers.ts)). It does **not** sync with Stripe — the local DB is the source of truth for `subscriptionStatus`, and Stripe state is allowed to drift for these test users.

#### How auth works

Each role logs in once via [tests/e2e/auth.setup.ts](tests/e2e/auth.setup.ts) and the session cookies are persisted to `.auth/<role>.json`. Authenticated specs reuse that storage state instead of going through the Auth0 login UI on every test.

The `.auth/` directory is gitignored — the JSON files are regenerated on every run and **must not be committed**.

#### Adding a new role

1. Add an entry to [tests/e2e/roles.ts](tests/e2e/roles.ts) `STORAGE_STATE`.
2. Add the role's desired subscription state to `DESIRED_STATE` in [tests/e2e/billing-reset.ts](tests/e2e/billing-reset.ts).
3. Add the role to the `roles` array in [tests/e2e/auth.setup.ts](tests/e2e/auth.setup.ts) (with `email`, `password`, `userId`).
4. Add `E2E_<ROLE>_EMAIL`, `E2E_<ROLE>_PASSWORD`, `E2E_<ROLE>_USER_ID` to `REQUIRED_CREDENTIAL_VARS` in [playwright.config.ts](playwright.config.ts).
5. Add the same three vars to the `env:` block in both [.github/workflows/e2e-staging.yaml](.github/workflows/e2e-staging.yaml) and [.github/workflows/test-staging.yaml](.github/workflows/test-staging.yaml), and provision the corresponding GitHub secrets.

### Integration Tests

Integration tests, located in tests/integration/, confirm that individual modules or services interact correctly with one another — for instance, ensuring Stripe webhook handlers produce the expected state transitions in DynamoDB — by running against real AWS and Stripe resources.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@
"storybook": "pnpm --filter @filone/website storybook",
"test": "pnpm lint && pnpm --filter @filone/shared --filter @filone/backend --filter @filone/website test",
"test:storybook": "pnpm --filter @filone/website test:storybook",
"test:e2e": "pnpm exec playwright test",
"test:e2e": "pnpm exec sst shell -- pnpm exec playwright test",
"test:integration": "pnpm --filter @filone/integration test"
},
"dependencies": {
"@filone/shared": "workspace:*"
},
"devDependencies": {
"@aws-sdk/client-dynamodb": "^3.1023.0",
"@playwright/test": "^1.58.2",
"@pulumi/aws": "^7.23.0",
"@pulumi/command": "^1.2.1",
Expand Down
27 changes: 27 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@ if (!baseURL) {
);
}

const REQUIRED_CREDENTIAL_VARS = [
'E2E_PAID_EMAIL',
'E2E_PAID_PASSWORD',
'E2E_PAID_USER_ID',
'E2E_UNPAID_EMAIL',
'E2E_UNPAID_PASSWORD',
'E2E_UNPAID_USER_ID',
'E2E_TRIAL_EMAIL',
'E2E_TRIAL_PASSWORD',
'E2E_TRIAL_USER_ID',
] as const;

const missingCredentials = REQUIRED_CREDENTIAL_VARS.filter((name) => !process.env[name]);
if (missingCredentials.length > 0) {
throw new Error(
`Missing required E2E credential env vars: ${missingCredentials.join(', ')}. ` +
`See README.md for details.`,
);
}

export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
Expand All @@ -22,17 +42,24 @@ export default defineConfig({
trace: 'on-first-retry',
},
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
dependencies: ['setup'],
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
dependencies: ['setup'],
},
],
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 52 additions & 0 deletions tests/e2e/auth.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { test as setup, expect } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
import { STORAGE_STATE, type Role } from './roles.ts';
import { resetBillingState } from './billing-reset.ts';

const roles: ReadonlyArray<{
name: Role;
email: string;
password: string;
userId: string;
}> = [
{
name: 'paid',
email: process.env.E2E_PAID_EMAIL!,
password: process.env.E2E_PAID_PASSWORD!,
userId: process.env.E2E_PAID_USER_ID!,
},
{
name: 'unpaid',
email: process.env.E2E_UNPAID_EMAIL!,
password: process.env.E2E_UNPAID_PASSWORD!,
userId: process.env.E2E_UNPAID_USER_ID!,
},
{
name: 'trial',
email: process.env.E2E_TRIAL_EMAIL!,
password: process.env.E2E_TRIAL_PASSWORD!,
userId: process.env.E2E_TRIAL_USER_ID!,
},
];

for (const role of roles) {
setup(`authenticate as ${role.name}`, async ({ page }) => {
// Re-seed the BillingTable record so dashboard tests see deterministic
// state. Trial periods elapse and `past_due` can advance to `canceled`
// between scheduled runs, so the prior run's state is not safe to reuse.
await resetBillingState(role.name, role.userId);

await page.goto('/');
await page.getByRole('textbox', { name: 'Email address' }).fill(role.email);
await page.getByRole('textbox', { name: 'Password' }).fill(role.password);
await page.getByRole('button', { name: 'Continue', exact: true }).click();

await expect(page).toHaveURL(/\/dashboard$/);

const storagePath = STORAGE_STATE[role.name];
await fs.mkdir(path.dirname(storagePath), { recursive: true });
await page.context().storageState({ path: storagePath });
await page.context().storageState({ path: STORAGE_STATE[role.name] });
Comment on lines +47 to +50
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The storage state is written twice to the same path (line 49 and 50). This is redundant and can obscure intent; keep a single storageState({ path }) call (using the storagePath variable) to avoid extra I/O and confusion.

Copilot uses AI. Check for mistakes.
});
}
Loading
Loading