Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/live-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Existing report content may differ from the synthetic CMS page. The report smoke

After this workflow is merged, choose **Actions → Live Siteimprove smoke test → Run workflow → main**. Complete the configured environment approval. The workflow runs functional and package/CMS tests before the live job; it neither creates a release nor publishes a package.

Only `live-smoke-outcome/result.json` is uploaded from the live run. It contains fixed test names, statuses and durations. Traces, videos, screenshots, raw browser output, cookies, account URLs and report bodies are not uploaded. Host output is discarded in live mode, private browser output is removed, and the disposable database and identity keys are removed on normal completion or handled failure. An abrupt runner termination relies on disposal of the hosted runner.
Only `live-smoke-outcome/result.json` is uploaded from the live run. It contains fixed test names, statuses, durations and the last allowlisted setup stage. Traces, videos, screenshots, raw browser output, cookies, account URLs and report bodies are not uploaded. Host output is discarded in live mode, private browser output is removed, and the disposable database and identity keys are removed on normal completion or handled failure. An abrupt runner termination relies on disposal of the hosted runner.

The browser permits only localhost and HTTPS Siteimprove domains. A changed external login dependency therefore needs review. The CMS's live HTTP handler permits only token acquisition and entitlement reads, with redirects disabled. Automatic public-page rechecks remain disabled. The browser explicitly requests two prepublish scans of synthetic draft content; no public page is published, no remote settings are changed and no subscription is activated.

Expand Down
100 changes: 61 additions & 39 deletions tests/live/editor.mjs
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { expect } from '@playwright/test';
import { test, expect } from '@playwright/test';
import { settings, allowedRequest, isReport } from './settings.mjs';

export async function openLiveEditor(page, context) {
const config = settings(process.env);
await context.route('**/*', route => allowedRequest(route.request().url()) ? route.continue() : route.abort());
// Read existing entitlement. Never enable a subscription or trigger a recheck.
const entitlement = await fetch('https://api.siteimprove.com/v2/settings/content_checking', {
headers: { Authorization: `Basic ${Buffer.from(`${config.SITEIMPROVE_API_USERNAME}:${config.SITEIMPROVE_API_KEY}`).toString('base64')}` },
redirect: 'error', signal: AbortSignal.timeout(30_000),
await test.step('live: entitlement', async () => {
const entitlement = await fetch('https://api.siteimprove.com/v2/settings/content_checking', {
headers: { Authorization: `Basic ${Buffer.from(`${config.SITEIMPROVE_API_USERNAME}:${config.SITEIMPROVE_API_KEY}`).toString('base64')}` },
redirect: 'error', signal: AbortSignal.timeout(30_000),
});
expect(entitlement.ok).toBe(true);
expect((await entitlement.json()).is_ready).toBe(true);
});
expect(entitlement.ok).toBe(true);
expect((await entitlement.json()).is_ready).toBe(true);

let reportReceived = false;
page.on('response', async response => {
Expand All @@ -21,39 +23,59 @@ export async function openLiveEditor(page, context) {
&& isReport(await response.json(), config.crawledUrl)) reportReceived = true;
} catch { /* Invalid or failed responses cannot satisfy report readiness. */ }
});
await page.goto('/episerver/cms');
await page.locator('input[name="Username"], input[name="UserName"]').fill('editor');
await page.locator('input[name="Password"]').fill(process.env.CMS_EDITOR_PASSWORD);
await page.getByRole('button', { name: /log in/i }).click();
await expect(page.locator('input[name="Password"]')).toHaveCount(0);
const routesResponse = await page.request.get('/test/routes');
expect(routesResponse.ok()).toBe(true);
const { plugin } = await routesResponse.json();
const targetResponse = await page.request.get('/test/live-target');
expect(targetResponse.ok()).toBe(true);
const { contentId } = await targetResponse.json();
const mapping = await page.request.get(`${plugin}/PageUrl?contentId=${contentId}&locale=en`);
expect(mapping.ok()).toBe(true);
expect((await mapping.json()).url).toBe(config.crawledUrl);
await page.goto(`/episerver/cms/#context=epi.cms.contentdata:///${contentId}`);
await expect(page.frameLocator('iframe[name="sitePreview"]').locator('h1')).toBeVisible();
await test.step('live: CMS login', async () => {
await page.goto('/episerver/cms');
await page.locator('input[name="Username"], input[name="UserName"]').fill('editor');
await page.locator('input[name="Password"]').fill(process.env.CMS_EDITOR_PASSWORD);
await page.getByRole('button', { name: /log in/i }).click();
await expect(page.locator('input[name="Password"]')).toHaveCount(0);
});
const contentId = await test.step('live: public URL mapping', async () => {
const routesResponse = await page.request.get('/test/routes');
expect(routesResponse.ok()).toBe(true);
const { plugin } = await routesResponse.json();
const targetResponse = await page.request.get('/test/live-target');
expect(targetResponse.ok()).toBe(true);
const { contentId } = await targetResponse.json();
const mapping = await page.request.get(`${plugin}/PageUrl?contentId=${contentId}&locale=en`);
expect(mapping.ok()).toBe(true);
expect((await mapping.json()).url).toBe(config.crawledUrl);
return contentId;
});
await test.step('live: draft preview', async () => {
await page.goto(`/episerver/cms/#context=epi.cms.contentdata:///${contentId}`);
await expect(page.frameLocator('iframe[name="sitePreview"]').locator('h1')).toBeVisible();
});

const [popup] = await Promise.all([
page.waitForEvent('popup'), page.locator('.si-smallbox button.si-button').click(),
]);
await popup.waitForURL(url => url.origin === 'https://identity.siteimprove.com');
await popup.locator('input[name=loginId]').fill(config.SITEIMPROVE_USERNAME);
await popup.getByRole('button', { name: 'Continue', exact: true }).click();
await popup.locator('input[type=password]').waitFor();
expect(new URL(popup.url()).origin).toBe('https://identity.siteimprove.com');
await popup.locator('input[type=password]').fill(config.SITEIMPROVE_PASSWORD);
await Promise.all([
popup.waitForEvent('close', { timeout: 60_000 }),
popup.getByRole('button', { name: /^(Sign in|Log in|Continue)$/i }).click(),
]);
const panel = page.locator('iframe.si-iframe-element');
if (!await panel.isVisible()) await page.locator('.si-smallbox button.si-button').click();
await expect(panel).toBeVisible();
await expect.poll(() => reportReceived, { timeout: 60_000 }).toBe(true);
const popup = await test.step('live: open login popup', async () => {
const [popup] = await Promise.all([
page.waitForEvent('popup'), page.locator('.si-smallbox button.si-button').click(),
]);
return popup;
});
await test.step('live: identity username', async () => {
await popup.waitForURL(url => url.origin === 'https://identity.siteimprove.com');
await popup.locator('input[name=loginId]').fill(config.SITEIMPROVE_USERNAME);
await popup.getByRole('button', { name: 'Continue', exact: true }).click();
});
await test.step('live: identity password', async () => {
await popup.locator('input[type=password]').waitFor();
expect(new URL(popup.url()).origin).toBe('https://identity.siteimprove.com');
await popup.locator('input[type=password]').fill(config.SITEIMPROVE_PASSWORD);
});
await test.step('live: submit login', async () => {
await Promise.all([
popup.waitForEvent('close', { timeout: 60_000 }),
popup.getByRole('button', { name: /^(Sign in|Log in|Continue)$/i }).click(),
]);
});
await test.step('live: report panel', async () => {
const panel = page.locator('iframe.si-iframe-element');
if (!await panel.isVisible()) await page.locator('.si-smallbox button.si-button').click();
await expect(panel).toBeVisible();
});
await test.step('live: mapped report data', async () => {
await expect.poll(() => reportReceived, { timeout: 60_000 }).toBe(true);
});
return { config, contentId };
}
12 changes: 11 additions & 1 deletion tests/live/safe-reporter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,20 @@ import { mkdirSync, writeFileSync } from 'node:fs';

// Live errors can contain credentials, cookies, URLs and report content.
// Only fixed test names and statuses may leave the browser run.
const stages = new Set(['live: entitlement', 'live: CMS login', 'live: public URL mapping',
'live: draft preview', 'live: open login popup', 'live: identity username',
'live: identity password', 'live: submit login', 'live: report panel', 'live: mapped report data']);

export default class SafeReporter {
results = [];
stages = new Map();
onStepBegin(test, result, step) {
if (step.category === 'test.step' && stages.has(step.title))
this.stages.set(result, step.title);
}
onTestEnd(test, result) {
this.results.push({ test: test.title, status: result.status, durationMs: result.duration });
this.results.push({ test: test.title, status: result.status, durationMs: result.duration,
lastStage: this.stages.get(result) ?? null });
}
onError() {}
onEnd(result) {
Expand Down
9 changes: 7 additions & 2 deletions tests/unit/live.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,17 @@ test('live reporting discards raw failures and attachments', async () => {
process.chdir(temp);
const reporter = new Reporter();
reporter.onError(new Error('private-value'));
reporter.onTestEnd({ title: 'fixed smoke test' }, { status: 'failed', duration: 1,
error: { message: 'private-value' }, attachments: [{ body: 'private-value' }] });
const result = { status: 'failed', duration: 1,
error: { message: 'private-value' }, attachments: [{ body: 'private-value' }] };
reporter.onStepBegin({}, result, { category: 'test.step', title: 'live: CMS login' });
reporter.onStepBegin({}, result, { category: 'test.step', title: 'private-value' });
reporter.onStepBegin({}, result, { category: 'pw:api', title: 'private-value' });
reporter.onTestEnd({ title: 'fixed smoke test' }, result);
reporter.onEnd({ status: 'failed' });
const output = fs.readFileSync('artifacts/live/result.json', 'utf8');
assert.equal(output.includes('private-value'), false);
assert.equal(JSON.parse(output).tests[0].status, 'failed');
assert.equal(JSON.parse(output).tests[0].lastStage, 'live: CMS login');
} finally { process.chdir(cwd); fs.rmSync(temp, { recursive: true, force: true }); }
});

Expand Down
Loading