Scaffold a new Playwright e2e or API test spec following project conventions
When the user asks to create a new test, follow these steps:
Before writing test cases from scratch, check if a test plan already exists:
Glob for tests/test_plans/* matching the feature name or ticket IDDetect whether this is an e2e test (UI interaction), API test (endpoint validation), or performance test (page load metrics):
| Signal | Type |
|---|---|
| User says "API test", "endpoint test", "register endpoint" | API |
| Target is a REST endpoint, webhook, or backend service | API |
| User says "test this flow", "test this page", references a URL | E2E |
| Target involves UI interaction, forms, navigation | E2E |
| User says "performance test", "page load", "web vitals", "LCP", "TTFB" |
| Performance |
| Target is measuring speed, bundle size, or load time | Performance |
tests/performance_tests/PUBLIC_PAGES or AUTHENTICATED_PAGES array in the existing spec (prefer extending existing specs over creating new ones)PAGE_SPECIFIC_THRESHOLDS in tests/resources/constants/performanceThresholds.tsTEST_TAGS.PERFORMANCE@playwright/test directly (NOT from page_objects — perf tests don't need POM fixtures)performanceHelper functions: injectPerformanceObservers, collectPerformanceMetrics, assertPerformanceThresholds, logPerformanceSummarytests/docs/performance-testing-guide.md for full architecture and patternsconnect-account, cottage-user-move-in, homepage, payment, or a new onetests/e2e_tests/<feature>/{feature}_{scenario}.spec.tsGlob to follow naming and structural patternsAlways import from barrel exports:
import { test, expect } from '../../resources/page_objects';
import { TIMEOUTS, TEST_TAGS } from '../../resources/constants';
import { createLogger } from '../../resources/utils/logger';
const log = createLogger('FeatureName');
Adjust relative paths based on file depth. Note: use createLogger('Name') — NOT import { log }.
let result: SomeType | null = null;
test.describe('Feature: Description', () => {
test.beforeEach(async ({ page }) => {
// Setup
});
test.afterEach(async ({ page }) => {
// Cleanup created test data
if (result?.pgUserEmail) {
await CleanUp.Test_User_Clean_Up(result.pgUserEmail);
}
await page.close();
});
test('descriptive test name', { tag: [TEST_TAGS.REGRESSION1] }, async ({ page }) => {
test.setTimeout(TIMEOUTS.TEST_MOVE_IN);
// Implementation
});
});
If the test interacts with a page that doesn't have a POM yet, create one:
mcp__playwright__browser_navigate, then use mcp__playwright__browser_snapshot to capture the accessibility tree and identify correct roles, names, and labels for locatorstests/resources/page_objects/{page_name}_page.tsreadonly class propertiesgetByRole > getByText > getByLabel > getByTestId > locator('css') (last resort)TIMEOUTS constants for any timeout valuestests/resources/page_objects/index.ts AND add to tests/resources/page_objects/base/baseFixture.tsimport { type Page, type Locator, expect } from '@playwright/test';
import { TIMEOUTS } from '../constants';
export class ExamplePage {
readonly page: Page;
readonly submitButton: Locator;
constructor(page: Page) {
this.page = page;
this.submitButton = page.getByRole('button', { name: 'Submit' });
}
async clickSubmit(): Promise<void> {
await this.submitButton.click({ timeout: TIMEOUTS.MEDIUM });
}
}
If the test needs database queries or utilities that don't exist yet:
First, inspect the actual schema — use Supabase MCP to get accurate table/column info:
mcp__supabase__list_tables to see available tablesmcp__supabase__execute_sql to inspect column names, types, constraints, and relationships for the relevant tablesDatabase query module — place in tests/resources/fixtures/database/{name}Queries.ts:
import { createClient } from '../../utils/supabase';
import { createLogger } from '../../utils/logger';
const log = createLogger('ModuleQueries');
export class ModuleQueries {
private supabase;
constructor() { this.supabase = createClient(); }
async getRecord(id: string): Promise<RecordType> {
log.info('Fetching record', { id });
const { data, error } = await this.supabase.from('table').select('*').eq('id', id).single();
if (error) { log.error('Failed', { id, error: error.message }); throw error; }
return data;
}
}
Export from tests/resources/fixtures/database/index.ts.
Test utility — place in tests/resources/fixtures/{name}Utilities.ts, follow patterns from paymentUtilities.ts or billUploadUtilities.ts.
When tests require async backend processing (subscriptions, bill ingestion, payment processing):
Two types of Inngest functions:
import { execSync } from 'child_process';
function triggerInngest(eventName: string): void {
const key = process.env.INNGEST_EVENT_KEY;
execSync(`curl -s -X POST "https://inn.gs/e/${key}" -H "Content-Type: application/json" -d '{"name": "${eventName}", "data": {}}'`);
}
Key event names (dev only — production uses cron):
transaction-generation-trigger — creates pending SubscriptionMetadatasubscriptions-payment-trigger — processes pending metadata into paymentspreparing-for-move — pre-move-in reminder email (2 days before startDate)email.send — generic email dispatch*/5 schedule):balance-ledger-batch — processes approved bills → processed, creates Payment in requires_capturestripe-payment-capture-batch — captures payments → succeededinn.gs/e/ returns 200 but is a no-op for cron functionsFull reference: See tests/docs/inngest-functions.md for all known event names, apps, eligibility criteria, and gotchas.
Wait for processing: Inngest functions are async. After triggering (or after cron fires), poll the DB for expected state changes with a timeout. Do NOT use fixed sleep — use a polling helper like billQueries.checkElectricBillIsProcessed().
Bill test data setup pattern (for tests that need processed bills on the overview):
maintainedFor = null) can't process billsElectricAccount.status = 'ACTIVE', isActive = true, registrationJobCompleted = trueResident.isRegistrationComplete = trueingestionState = 'approved' via billQueries.insertApprovedElectricBill()balance-ledger-batch cron (*/5 min) — polls via billQueries.checkElectricBillIsProcessed()requires_capture → stripe-payment-capture-batch captures → only then next bill can processprocessed via SQL to avoid waiting for full pipelinePrerequisites for subscription tests:
ElectricAccount.status must be ACTIVESubscriptionConfiguration.dayOfMonth must match todaySubscription.startDate must be at least 1 billing cycle in the pastSee CLAUDE.md → Inngest Integration for full details.
When a test needs to add a payment method via the UI (subscription activation, paused-to-active flow):
Stripe iframe IS accessible via Playwright — use frameLocator:
// Fill Stripe card form
const stripeFrame = page.frameLocator('iframe[title="Secure payment input frame"]');
await stripeFrame.getByRole('textbox', { name: 'Card number' }).fill('4242424242424242');
await stripeFrame.getByRole('textbox', { name: /Expiration/ }).fill('12 / 30');
await stripeFrame.getByRole('textbox', { name: 'Security code' }).fill('123');
await stripeFrame.getByLabel('Country').selectOption('United States');
await stripeFrame.getByRole('textbox', { name: 'ZIP code' }).fill('10001');
// Click save on parent page (not inside iframe)
await page.getByRole('button', { name: 'Save details' }).click();
Important: The iframe name attribute changes per session (__privateStripeFrame{N}). Use the title selector which is stable: iframe[title="Secure payment input frame"].
When scaffolding tests for the move-in/transfer/bill-upload flows:
mi-session/start interceptor is mandatory — without it, the page auto-redirects:
// Install BEFORE the page loads content
await page.addInitScript(() => {
const origFetch = window.fetch;
window.fetch = function(...args: Parameters<typeof fetch>) {
const url = typeof args[0] === 'string' ? args[0] : (args[0] as Request)?.url || '';
if (url.includes('mi-session/start')) {
return new Promise(() => {}); // Block forever
}
return origFetch.apply(window, args);
};
});
await page.goto('https://dev.publicgrid.energy/move-in?shortCode=autotest');
Building flag alignment for RE tests — all three must be true for RE option to appear:
Building.offerRenewableEnergy = trueUtilityCompany.offerRenewableEnergy = trueUtilityCompany.subscriptionConfigurationID is set (not null)Use DB setup in beforeEach and restore in afterEach.
When tests need specific feature flag states, create setup/teardown helpers:
// In beforeEach — set flags
await supabase.from('Building').update({ isHandleBilling: false }).eq('shortCode', 'autotest');
await supabase.from('UtilityCompany').update({ offerRenewableEnergy: true }).eq('id', 'SDGE');
// In afterEach — ALWAYS restore
await supabase.from('Building').update({ isHandleBilling: true }).eq('shortCode', 'autotest');
await supabase.from('UtilityCompany').update({ offerRenewableEnergy: false }).eq('id', 'SDGE');
Key flags used in sidebar/subscription tests:
Building.isHandleBilling — billing vs non-billingBuilding.offerRenewableEnergyDashboard — sidebar renewable card + recommendationBuilding.offerRenewableEnergy — move-in flow RE optionBuilding.shouldShowDemandResponse — GridRewards recommendationUtilityCompany.offerRenewableEnergy — RE resolutionUtilityCompany.subscriptionConfigurationID — links to pricing configCottageUsers.enrollmentPreference — null/verification_only/automatic/manual → controls "Search for savings"SubscriptionConfiguration.dayOfMonth — billing day (set to today for Inngest tests)Subscription.startDate — must be in past for Inngest processingWhen a test needs to switch between users:
async function clearSession(page: Page): Promise<void> {
await page.evaluate(() => {
document.cookie.split(';').forEach(c => {
document.cookie = c.replace(/^ +/, '').replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/');
});
localStorage.clear();
sessionStorage.clear();
});
await page.goto('https://dev.publicgrid.energy/sign-in');
}
When a test needs to sign in as a user with unknown password:
import { execSync } from 'child_process';
function resetPassword(userId: string, password: string = 'PG#12345'): void {
const serviceKey = process.env.SUPABASE_API_KEY;
const url = process.env.SUPABASE_URL;
execSync(`curl -s -X PUT "${url}/auth/v1/admin/users/${userId}" -H "Authorization: Bearer ${serviceKey}" -H "apikey: ${serviceKey}" -H "Content-Type: application/json" -d '{"password": "${password"}'`);
}
Note: Supabase blocks password reuse — if the user already has PG#12345, the reset will return 422. Handle the password reset dialog via DOM removal if needed.
tests/api_tests/<feature>/{feature}_{scenario}.spec.tsGlob for tests/api_tests/**/*.spec.ts to follow patterns (e.g., tests/api_tests/register/, tests/api_tests/v2/)CRITICAL: Do NOT write TypeScript types from a spec alone. Specs drift from implementation. Before creating types or assertions:
curl each endpoint to capture the actual response shape — fields, types, nesting, pagination structureprocess.env for test data IDs from the start — don't rely on "first item in list" from paginated endpoints. Wire env vars (e.g., API_V2_TEST_PROPERTY_UUID) into beforeAll setup immediately.Example probe:
curl -s "https://api-dev.publicgrd.com/v2/buildings?limit=1" \
-H "Authorization: Bearer $API_KEY" | node -e "
const d=JSON.parse(require('fs').readFileSync(0,'utf8'));
console.log('Keys:', Object.keys(d));
console.log('Item keys:', d.data?.[0] ? Object.keys(d.data[0]) : 'empty');
"
This prevents the expensive rewrite cycle of: write types from spec → tests fail → discover actual shape → rewrite everything.
Create a reusable API helper following the RegisterApi or PublicGridApiV2 pattern at tests/resources/fixtures/api/:
import { createLogger } from '../../utils/logger';
const log = createLogger('FeatureApi');
interface FeaturePayload { /* request body type */ }
interface FeatureResponse { /* response body type */ }
export class FeatureApi {
private baseUrl: string;
private token: string;
constructor(baseUrl: string, token: string) {
this.baseUrl = baseUrl;
this.token = token;
}
async createResource(payload: FeaturePayload): Promise<{ status: number; body: FeatureResponse }> {
log.info('POST /endpoint', { payload });
const response = await fetch(`${this.baseUrl}/endpoint`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const body = await response.json();
return { status: response.status, body };
}
}
Export from tests/resources/fixtures/api/index.ts.
import { test, expect } from '@playwright/test';
import { FeatureApi } from '../../resources/fixtures/api';
import { TIMEOUTS, TEST_TAGS } from '../../resources/constants';
import { createLogger } from '../../resources/utils/logger';
const log = createLogger('FeatureApiTest');
test.describe('API: Feature Name', () => {
let api: FeatureApi;
test.beforeAll(async () => {
api = new FeatureApi(process.env.API_BASE_URL!, process.env.API_TOKEN!);
});
test('returns 201 for valid payload', { tag: [TEST_TAGS.REGRESSION1] }, async () => {
const { status, body } = await api.createResource({ /* valid payload */ });
expect(status).toBe(201);
expect(body).toHaveProperty('id');
});
test('returns 400 for missing required field', { tag: [TEST_TAGS.REGRESSION1] }, async () => {
const { status } = await api.createResource({ /* incomplete payload */ });
expect(status).toBe(400);
});
});
Key patterns:
test.describe.configure({ mode: 'serial' }) only if tests share stateafterAll/afterEachTEST_TAGS constants for tags — never raw strings like '@smoke'TIMEOUTS constants — never magic numbers like 30000createLogger('Name')) — never console.logany types — import proper types from tests/resources/types/afterEach with cleanup logictest.describe.configure({ mode: "serial" }) only if tests share statepage.getByRole('heading', { name: /Upload document/i }) instead of exact 'Upload document'. Regex survives minor UI text changes without breaking tests.FastmailActions.Get_OTP() for shared test accounts — it asserts content.length === 1 which breaks when prior sessions left stale OTP emails. Instead, create a custom getLatestOTP() that takes the most recent email. Import Email type from tests/resources/utils/fastmail/types for proper typing.page.evaluate() to fill form inputs — it sets the DOM value but does NOT trigger React controlled component state. Form validation will still see the field as empty. Always use Playwright's native fill() method (page.locator(...).fill(value) or browser_fill_form via MCP).overviewPage.Setup_Password() before Accept_New_Terms_And_Conditions().balance-ledger-batch requires a ChargeAccount with ledgerBalanceID. This is created by the registration Inngest pipeline, NOT by manually setting status = ACTIVE. If your test user has no ChargeAccount, inserted bills will stay approved forever.Iron rule: no test is "done" without fresh, real verification evidence. Do not claim a test works based on reasoning alone. The phrase "should pass" is banned — show output.
any types in any created/modified fileTIMEOUTS constantsTEST_TAGS constantsconsole.logafterEachtest.skip() requires a reason string (ticket or data-precondition)/create-test creates new .spec.ts files. Those are checked full-content by the CI gate (.github/workflows/standards-gate.yml) with ZERO tolerance for violations. There's no debt-exemption for new code — the gate is strict because you're writing it from scratch.
Do NOT claim a new spec follows CODE_STANDARDS.md based on reading it top-to-bottom. Machine-check it:
FILES="<the new .spec.ts + .ts files you created this session>"
# Same 6 checks the CI gate runs on new files:
grep -nE "page\.(getByRole|getByText|getByLabel|getByTestId|locator)\(" $FILES # POM
grep -nE ":\s*any\b|as\s+any\b" $FILES # any
grep -nE "console\.(log|error|warn|info|debug)" $FILES # console
grep -nE "tag:\s*\[\s*['\"]@" $FILES # raw tags
grep -nE "(setTimeout|waitForTimeout)\([0-9]+\)|timeout:\s*[0-9]{3,}" $FILES # magic timeouts
grep -nE "test\.skip\(\s*\)" $FILES # naked skips
ANY output from ANY grep = refactor, do NOT report done. POM compliance is per-line — skipped tests, edge-case locators, and failure-terminus assertions (invalid-cred errors, auth-code-error pages) all count. Acceptable page.* calls in specs: page.goto, page.waitForURL, page.waitForResponse, page.waitForTimeout, page.context, page.addInitScript, page.on, page.evaluate (framework primitives, not UI interactions).
If you also MODIFIED an existing file as part of creating your spec (e.g. adding a method to MoveInPage), the gate only blocks on lines your diff adds (see /fix-test SKILL.md for the diff-aware check). Pre-existing debt in the same file warns but doesn't block. Still, clean it up where cheap — "boy scout rule."
Why: On 2026-04-18 I shipped 3 specs, told the user "follows CODE_STANDARDS.md," then the user asked me to verify. 30-second grep found 14 POM violations in my own files. See memory/feedback_run_standards_audit_before_claiming_compliance.md and memory/feedback_pom_compliance_is_per_line.md.
| Thought | What to do instead |
|---|---|
| "The test logic looks correct, it should pass" | Run it. "Looks correct" is not evidence. |
| "I'll skip running it — it's straightforward" | The simplest tests catch the most surprising bugs. Run it. |
| "It failed but that's just a data issue, the test is fine" | A test that can't run is not a test. Fix the data setup. |
"I'll mark it as .skip for now and come back later" | No. Either make it pass or don't create it yet. |
| "The POM locators match what I saw in the snapshot" | Snapshots are a moment in time. Run the test to prove they work in the full flow. |
Always run the new test locally to verify it works before declaring done:
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/e2e_tests/<feature>/<new_file>.spec.ts
After running the test (pass or fail), clean up generated artifacts before declaring done:
test-results/ directory — Playwright generates traces, screenshots, and videos on failure; remove after diagnosis.playwright-mcp/ directory — if Playwright MCP was used to inspect UI for locators, remove session datarm -rf test-results/ # test runner artifacts
rm -rf .playwright-mcp/ # Playwright MCP session data
rm -f *.png # screenshots in project root
Do NOT skip cleanup — leftover artifacts bloat the repo and can be accidentally committed.
After the test is created and passing:
/run-tests to run it in CI or with different browsers/exploratory-test if the test revealed areas needing further investigation/fix-test if it exposed issues in existing page objects or fixturestests/test_plans/ to mark the test case as automated (if a plan exists)tests/docs/tests/docs/inngest-functions.md if Inngest-related| Tool | Purpose |
|---|---|
| Playwright MCP | browser_navigate, browser_snapshot — inspect live UI for accurate POM locators |
| Supabase MCP | list_tables, execute_sql — inspect actual schema before writing query modules |
Glob, Grep | Find existing test plans, tests, and patterns to follow |
Write, Edit | Create spec files, POMs, fixtures |
Bash | Run the test locally to verify it works |
After completing this skill, check: did any step not match reality? Did a tool not work as expected? Did you discover a better approach? If so, update this SKILL.md with what you learned.
tests/resources/fixtures/payment/ (AutoPaymentChecks, ManualPaymentChecks, FailedPaymentChecks). No facade class — each fixture file exports its own class.newUserMoveInEncouraged() for pgtest/funnel/partner shortcodes — NOT the standard 6-step newUserStandardMoveIn().import { log } but actual codebase pattern is import { createLogger } from '../../resources/utils/logger' + const log = createLogger('Name'). Fixed in Step 3.FastmailActions.Get_OTP() is fragile: Asserts content.length === 1 which fails when prior exploratory sessions left stale OTP emails. Created custom getLatestOTP() that iterates bodyValues keys and takes the most recent match. Added to Rules.BillUploadPage.uploadBillHeading broke when UI text changed from "Upload your bill" to "Upload document". Using /Upload document/i regex would have survived the change. Added regex preference to Rules.Email type import: import type { Email } from '../../resources/utils/fastmail/types' for proper typing of Fastmail API responses — avoids any[].frameLocator pattern. Key: use iframe[title="Secure payment input frame"] (stable) not iframe[name="__privateStripeFrame{N}"] (dynamic).page.addInitScript() for spec-based tests (more reliable than runtime page.evaluate).1111111111 for test phone numbers: In move-in and registration flows, always use this number to avoid sending SMS to real people. Add as a constant or in test data generators.inviteCode from email HTML href containing resident%3FinviteCode. Resident page is at /resident?inviteCode={code}.<LegalLinks /> across flows — check 3 links (Terms, Privacy Policy, LPOA) with correct href, target="_blank", rel="noopener noreferrer". Consider a shared assertion helper.termsAndConditionsDate, lpoaConsentDate, ipAddressTerms, ipAddressLPOA. Add to userQueries.ts or create consentQueries.ts.autotest (Moved), funnel4324534 (Funnel), venn325435435 (Venn), renew4543665999 (Renew). Useful for white-label test coverage.maintainedFor = null. balance-ledger-batch silently skips those bills — they stay approved forever. Tests that need processed bills MUST use billing move-in ("Public Grid handles everything" + Stripe card).balance-ledger-batch (/5 cron) processes approved bill → creates Payment in requires_capture → stripe-payment-capture-batch (/5 cron) captures payment → only then next bill can process. For N sequential bills, worst case ~N×10 min.inn.gs/e/balance-ledger.batch returns 200 but does nothing. Updated section 6b to distinguish event-triggered vs cron-only functions.processed via SQL.GasAccount manually with valid utilityCompanyID (e.g., PEOPLES-GAS, DUKE, BGE). Set maintainedFor, status = ACTIVE, registrationJobCompleted = true.sendMethod() must omit Content-Type: When testing unsupported HTTP methods (PUT/DELETE/PATCH), do NOT send Content-Type: application/json. Fastify validates the body schema before checking the route, returning 400 instead of the expected 404. Omit the header to get the correct 404.create_resident_from_utility_verification RPC sets termsAndConditionsDate: Even when consentDate is not provided, the Supabase RPC sets termsAndConditionsDate to NOW(). Don't assert null — assert it's a recent timestamp.enrollRaw() method pattern: For validation tests where you need to send invalid types (numbers, booleans) or partial bodies, use a Record<string, unknown> overload instead of the typed interface. This avoids TypeScript blocking the invalid payloads you need to test.Referrals.referredBy → MoveInPartner.id, NOT via Property.buildingID. Property.buildingID is null for API-enrolled users.tests/api_tests/v1/ is now the home for v1 partner API tests alongside tests/api_tests/v2/ for Public Grid API v2.