| name | pom-from-recording |
| description | Convert Playwright recordings into proper Page Object Models (POMs) with correct
waitForProcessInstances usage, fixture integration, and assertion patterns.
Use this skill whenever:
(1) Creating new E2E tests from Playwright codegen recordings,
(2) Converting existing inline-selector tests to POM style,
(3) Adding new page objects or extending existing ones,
(4) Writing tests that involve vedtak, saksopprettelse, or SED verification,
(5) Reviewing test code for correct async process handling.
Triggers: "new test", "POM", "page object", "recording", "codegen",
"konverter test", "lag ny test", "opprett POM", "playwright recording".
|
POM from Recording
Transform Playwright codegen recordings into maintainable Page Object Model tests
for the melosys-e2e-tests project.
The Conversion Process
Step 1: Record with codegen
npm run codegen
This opens a browser where you perform the workflow. The Playwright Inspector generates raw test code with inline selectors.
Step 2: Identify which pages are involved
Map the recording's actions to existing POMs. Check pages/ for what already exists before creating new ones.
Existing POMs (read pages/ directory for current list):
HovedsidePage — main page, search, navigation
OpprettNySakPage — create case (brukerID, sakstype, sakstema, behandlingstema)
EuEosBehandlingPage — EU/EOS case setup (dates, countries)
ArbeidFlereLandBehandlingPage — multi-country treatment flow
VedtakPage / EuEosVedtakPage — decision pages
TrygdeavgiftPage — tax calculation
JournalforingPage — document journaling
Step 3: Create or extend POMs
See the POM Architecture section below.
Step 4: Write the test
See the Test Structure section below.
POM Architecture
File structure
pages/
├── shared/
│ ├── base.page.ts # Abstract base — NEVER modify without good reason
│ └── constants.ts # Shared test data and enums
├── feature-name/
│ ├── feature-name.page.ts # Actions (what you CAN DO)
│ └── feature-name.assertions.ts # Verifications (what you EXPECT)
Page Object template
import { Page, Locator } from '@playwright/test';
import { BasePage } from '../shared/base.page';
import { FeatureAssertions } from './feature.assertions';
export class FeaturePage extends BasePage {
readonly assertions: FeatureAssertions;
private readonly myButton: Locator;
private readonly myField: Locator;
constructor(page: Page) {
super(page);
this.assertions = new FeatureAssertions(page);
this.myButton = this.page.getByRole('button', { name: 'Opprett' });
this.myField = this..();
}
(): <> {
..();
}
fyllInnBelø(belø: ): <> {
..(beløp);
}
}
Assertions template
import { Page, expect } from '@playwright/test';
import { withDatabase } from '../../helpers/db-helper';
export class FeatureAssertions {
constructor(readonly page: Page) {}
async verifiserOpprettet(): Promise<void> {
await expect(this.page.getByText('Behandling opprettet')).toBeVisible();
}
async verifiserIDatabase(fnr: string): Promise<string> {
return await withDatabase(async (db) => {
const result = await db.queryOne(
'SELECT SAK_ID FROM SAK WHERE PERSONNUMMER = :pnr ORDER BY SAK_ID DESC',
{ pnr: fnr }
);
expect(result, 'Fant ingen sak i databasen').not.toBeNull();
return result.;
});
}
}
Naming conventions
| Pattern | Example | When |
|---|
fyllInn* | fyllInnBrukerID() | Fill text fields |
velg* | velgSakstype() | Select dropdowns/radios |
klikk* | klikkOpprettNyBehandling() | Click buttons |
verifiser* | verifiserBehandlingOpprettet() | Assertions only |
Using BasePage utilities
BasePage provides these — use them instead of raw Playwright calls:
await this.clickStepButtonWithRetry(this.bekreftButton, {
waitForContent: this.nextStepElement,
verifyHeadingChange: true,
});
await this.waitForDropdownToPopulate(this.kommuneDropdown);
await this.selectByVisibleText(this.kommuneDropdown, 'Oslo');
await this.checkRadioIfExists(this.radioButton);
await this.trySelectors([
this.page.getByRole('button', { name: 'Bekreft' }),
this..(, { : }),
]);
Step transition anti-patterns
Radio buttons on step wizard pages trigger auto-save API calls. Be aware of this
when creating POMs that call klikkBekreftOgFortsett after radio selections:
async velgYrkesaktivOgFortsett(): Promise<void> {
await this.velgYrkesaktiv();
await this.klikkBekreftOgFortsett();
}
async velgYrkesaktivOgFortsett(): Promise<void> {
await this.velgYrkesaktiv();
await this.klikkBekreftOgFortsett({
waitForContent: this.arbeidsgiverCheckbox,
});
}
Constants
Add shared test data to pages/shared/constants.ts — never hardcode in tests:
export const EU_EOS_LAND = {
SVERIGE: 'Sverige',
DANMARK: 'Danmark',
FAROEYENE: 'Færøyene',
GRONLAND: 'Grønland',
} as const;
Test Structure
Standard test template
import { test, expect } from '../../fixtures';
import { AuthHelper } from '../../helpers/auth-helper';
import { HovedsidePage } from '../../pages/hovedside.page';
import { OpprettNySakPage } from '../../pages/opprett-ny-sak/opprett-ny-sak.page';
import { waitForProcessInstances } from '../../helpers/api-helper';
import { USER_ID_VALID } from '../../pages/shared/constants';
test.describe('Feature Name', () => {
test('should complete workflow', async ({ page }) => {
test.setTimeout(120000);
const auth = new AuthHelper(page);
await auth.login();
const hovedside = new HovedsidePage(page);
const opprettSak = new OpprettNySakPage(page);
await hovedside.();
hovedside.();
opprettSak.();
opprettSak.();
(page., );
hovedside.();
page.(, { : }).();
page.();
opprettSak..();
});
});
Critical: Import from fixtures, not @playwright/test
import { test, expect } from '../../fixtures';
import { test, expect } from '@playwright/test';
The fixture automatically:
- Cleans database BEFORE each test
- Clears mock data BEFORE each test
- Resets Unleash toggles BEFORE each test
- Waits for process instances AFTER each test
- Checks docker logs for errors AFTER each test
waitForProcessInstances — When and How
This is the most common source of flaky tests. The rules are simple but critical.
The function
waitForProcessInstances(request, timeoutSeconds) calls the melosys-api endpoint
/internal/e2e/process-instances/await which blocks until all async process instances
(IVERKSETT_VEDTAK, SEND_BREV, MOTTAK_SED, etc.) are complete or the timeout expires.
Decision tree
After klikkOpprettNyBehandling():
→ ALWAYS call waitForProcessInstances(page.request, 30)
→ Then navigate: await hovedside.goto()
After fattVedtak() as FINAL step of test:
→ DO NOT call manually — the cleanup fixture handles it
→ The fixture calls waitForProcessInstances(30) and FAILS the test
if any process instance errors occur
After fattVedtak() when test CONTINUES (multi-vedtak, DB verification):
→ ALWAYS call waitForProcessInstances(page.request, 60)
→ Use 60s timeout — vedtak processes chain (IVERKSETT_VEDTAK → SEND_BREV)
and take longer than saksopprettelse processes
After journalføring:
→ ALWAYS call waitForProcessInstances(page.request, 30)
→ Before navigating to the created behandling
Race condition with chained processes
When a vedtak triggers IVERKSETT_VEDTAK_EOS, which in turn creates SEND_BREV
instances, there is a risk that waitForProcessInstances completes before the
child processes (SEND_BREV) are created. This manifests as:
- "Forventet N papir-A1 SEND_BREV, men fant M" (M < N)
- Intermittent failures on CI but passes locally
If you verify process instance state in the database after vedtak, be aware of
this. If tests become flaky, consider polling the DB directly with retry logic
instead of relying solely on waitForProcessInstances.
Correct patterns
After saksopprettelse:
await opprettSak.klikkOpprettNyBehandling();
console.log('📝 Venter på prosessinstanser...');
await waitForProcessInstances(page.request, 30);
await hovedside.goto();
After vedtak with DB verification:
await behandling.fattVedtak();
await waitForProcessInstances(page.request, 60);
await verifiserProsessinstanserEtterVedtak(['FO', 'GL']);
After vedtak as final step (NO manual wait needed):
await behandling.fattVedtak();
console.log('✅ Vedtak fattet');
Multi-vedtak workflow:
await vedtak.klikkFattVedtak();
await waitForProcessInstances(page.request, 30);
await hovedside.goto();
await page.getByRole('link', { name: /TRIVIELL KARAFFEL/ }).click();
await vedtak.fattVedtakForNyVurdering('FEIL_I_BEHANDLING');
Common mistakes
await opprettSak.klikkOpprettNyBehandling();
await hovedside.goto();
await behandling.fattVedtak();
await waitForProcessInstances(page.request, 60);
await behandling.fattVedtak();
await waitForProcessInstances(page.request, 10);
SED Verification Pattern
When verifying that SED documents were sent to EESSI after vedtak:
import { fetchStoredSedDocuments, findNewNavFormatSed } from '../../helpers/mock-helper';
const docsBefore = await fetchStoredSedDocuments(request, 'A003');
await behandling.fattVedtak();
await waitForProcessInstances(page.request, 60);
const sedContent = await findNewNavFormatSed(request, 'A003', docsBefore);
expect(sedContent, 'A003 SED was not sent to EESSI').toBeTruthy();
The before-snapshot is critical — findNewNavFormatSed compares before/after to
find only the SED created by THIS test's vedtak.
Dynamic Forms and API Waits
Melosys forms trigger API calls on blur. Codegen recordings don't capture this,
leading to timing issues. Use page.waitForResponse() or waitForLoadState('networkidle')
when fields trigger backend calculations.
Pattern for fields that trigger API calls:
async fyllInnBruttoinntekt(beløp: string): Promise<void> {
await this.bruttoinntektField.fill(beløp);
await this.page.keyboard.press('Tab');
await this.page.waitForLoadState('networkidle');
}
Pattern for vedtak button with API response wait:
async klikkFattVedtak(): Promise<void> {
const responsePromise = this.page.waitForResponse(
response => response.url().includes('/api/saksflyt/vedtak/') &&
response.url().includes('/fatt') &&
response.request().method() === 'POST' &&
(response.status() === 200 || response.status() === 204),
{ timeout: 60000 }
);
await this.fattVedtakButton.click();
await responsePromise;
}
Database Verification
For tests that verify DB state, use withDatabase from helpers/db-helper:
import { withDatabase } from '../../helpers/db-helper';
await withDatabase(async (db) => {
const vedtak = await db.queryOne<{ STATUS: string }>(
`SELECT STATUS FROM PROSESSINSTANS
WHERE PROSESS_TYPE = 'IVERKSETT_VEDTAK_EOS'
AND REGISTRERT_DATO > SYSDATE - INTERVAL '10' MINUTE
ORDER BY REGISTRERT_DATO DESC
FETCH FIRST 1 ROWS ONLY`,
{}
);
expect(vedtak).not.toBeNull();
expect(vedtak!.STATUS).toBe('FERDIG');
});
Tips:
- Always use
SYSDATE - INTERVAL 'N' MINUTE to scope queries to current test
- Use 10 minutes as window (accounts for retries on CI)
- Order by
DESC and FETCH FIRST 1 ROWS ONLY to get latest
- Use parameterized queries (
:param syntax) to prevent SQL injection
- For CLOB columns (e.g. DATA),
oracledb.fetchAsString = [oracledb.CLOB] is set in db-helper
Checklist: Converting a Recording to POM Test
- Record with
npm run codegen
- Map actions to existing POMs — check
pages/ directory first
- Create new POMs only for pages not yet covered
- Import from
../../fixtures — never from @playwright/test
- Add
test.setTimeout(120000) for tests involving vedtak
- Use
AuthHelper for login — first action in every test
- Call
waitForProcessInstances after saksopprettelse and journalføring
- Do NOT call
waitForProcessInstances after final fattVedtak (fixture handles it)
- Use
waitForProcessInstances(60) if you verify DB state after vedtak
- Snapshot SED documents BEFORE vedtak if verifying SED sending
- Use constants from
pages/shared/constants.ts — no hardcoded values
- Add assertions — both UI (expect visible elements) and DB (withDatabase)
- Use
page.waitForLoadState('networkidle') after navigation and form fills
- Navigate via
hovedside.goto() after waitForProcessInstances, not direct URL
Reference
For deeper details on specific topics, read these files:
pages/shared/base.page.ts — BasePage utilities and clickStepButtonWithRetry
fixtures/cleanup.ts — What the fixture does automatically
helpers/api-helper.ts — waitForProcessInstances implementation
docs/pom/MIGRATION-PLAN.md — Full POM migration strategy
docs/guides/FIXTURES.md — Fixture behavior and configuration