Skip to content
Closed
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
17 changes: 17 additions & 0 deletions playwright-tests/DIAGNOSTICS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Failure diagnostics

The normal Playwright configuration retains traces from every failed attempt, including attempt zero. The shared fixture observes both standard pages and contexts created by `newContext()`; popup pages inherit their account role. Reused suite contexts are observed again for each test attempt.

Failed lookup/setup reports attach public usernames and addresses, timing, gateway URLs, HTTP outcomes, transport failures, pending requests, and sanitized lookup/injection/receipt responses. They do not read local storage or copy signed request bodies. Screenshots and UI state are captured before explicit recipient-page cleanup. The transfer fixture waits for both bounded setup results before closing either account and preserves the original error if cleanup fails.

`createAndSignInUser()` has a 90-second total setup budget for navigation, username availability and registration. It requires the active Chats screen and expected visible username; an error toast fails setup promptly. This budget leaves time inside the five-minute scenario timeout for evidence and cleanup. It does not repair application registration recovery ([web-client-v2 #1742](https://github.com/Liberdus/web-client-v2/issues/1742)).

Recipient lookup observes the request before entering the username, then gives the existing UI result up to ten seconds to become `found`. It does not retype, retry the lookup, or accept a negative result.

Run the offline harness from `playwright-tests`:

```sh
npm run test:diagnostics
```

Two cases deliberately fail attempt zero and pass their retry: a negative lookup and a missing registration confirmation in `beforeAll`. The report verifier checks the original trace, correct recipient screenshot, popup error, registration/cleanup evidence, and absence of the injected secret marker. Additional cases cover delayed success, explicit registration rejection, null/stalled receipts and transport failure. These routes do not create real accounts or contact a gateway.
12 changes: 12 additions & 0 deletions playwright-tests/diagnostics.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const { defineConfig } = require('@playwright/test');

// Offline tests of the test harness itself; no network setup or real accounts.
module.exports = defineConfig({
testDir: './harness-tests',
retries: 1,
workers: 1,
timeout: 20_000,
reporter: [['list'], ['json', { outputFile: './test-results/diagnostics-report.json' }]],
outputDir: './test-results/diagnostics',
use: { baseURL: 'http://diagnostics.test/', headless: true, trace: 'retain-on-failure' },
});
12 changes: 10 additions & 2 deletions playwright-tests/fixtures/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,22 @@
*/
const { test: baseTest, expect } = require('@playwright/test');
const { toastCloserScript } = require('../helpers/toastHelpers');
const { TestDiagnostics } = require('../helpers/testDiagnostics');

// Extend base test to override context fixture
const test = baseTest.extend({
context: async ({ browser }, use) => {
diagnostics: [async ({ browser }, use) => {
const diagnostics = TestDiagnostics.current();
for (const context of browser.contexts()) diagnostics.observeContext(context);
await use(diagnostics);
await diagnostics.finish();
}, { auto: true }],
context: async ({ browser, diagnostics }, use) => {
const context = await browser.newContext();
await context.addInitScript(toastCloserScript);
diagnostics.observeContext(context);
await use(context);
await context.close();
await diagnostics.close([context], null);
},
});

Expand Down
6 changes: 3 additions & 3 deletions playwright-tests/fixtures/newUserFixture.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ exports.test = base.extend({
const username = generateUsername(browserName);
await use(username);
},
page: async ({ context, username }, use, testInfo) => {
page: async ({ context, username, diagnostics }, use, testInfo) => {
// Attach the username to the test report for easy access
await testInfo.attach('username.txt', {
body: username,
Expand All @@ -21,12 +21,12 @@ exports.test = base.extend({
await dialog.dismiss();
}
});
diagnostics.identify(page, { role: 'sender' });
await createAndSignInUser(page, username);

await use(page);

await page.close();
await context.close();
await diagnostics.capturePage(page, 'before-page-close');
}
});

Expand Down
185 changes: 185 additions & 0 deletions playwright-tests/harness-tests/diagnostics.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
const fs = require('fs');
const http = require('node:http');
const { test, expect } = require('../fixtures/base');
const { newContext } = require('../helpers/toastHelpers');
const { AccountSetup } = require('../helpers/accountSetup');
const { RecipientLookup } = require('../helpers/recipientLookup');
const { TestDiagnostics } = require('../helpers/testDiagnostics');

class HarnessPage {
static lookup = `<span class="app-name">sender</span><input id="chatRecipient"><span id="chatRecipientError"></span>
<form id="newChatForm"><button type="submit" disabled>Continue</button></form><script>
chatRecipient.oninput = async () => {
try {
const response = await fetch('/address/' + chatRecipient.value);
const result = await response.json();
chatRecipientError.textContent = result.address ? 'found' : 'not found';
document.querySelector('button').disabled = !result.address;
} catch { chatRecipientError.textContent = 'not found'; }
};</script>`;

static registration = `<button id="createAccountButton">Create</button><div id="createAccountModal" style="display:none">
<input id="newUsername"><span id="newUsernameAvailable"></span><form id="createAccountForm"><button type="submit">Register</button></form></div>
<div id="chatsScreen" style="display:none"></div><span class="app-name"></span><script>
createAccountButton.onclick = () => { createAccountModal.style.display = 'block'; createAccountModal.classList.add('active'); };
newUsername.oninput = () => newUsernameAvailable.textContent = 'available';
createAccountForm.onsubmit = async event => {
event.preventDefault(); document.querySelector('button[type=submit]').disabled = true;
const result = await fetch('/inject', { method:'POST', body:JSON.stringify({tx:JSON.stringify({type:'register', alias:newUsername.value, from:'a'.repeat(64), secret:'DO_NOT_CAPTURE'})}) }).then(r=>r.json());
if (!result.success) { document.body.insertAdjacentHTML('beforeend','<div class="toast error show">Registration rejected</div>'); return; }
document.body.insertAdjacentHTML('beforeend','<div class="toast loading show">Creating account...</div>');
const receipt = await fetch('/transaction/' + result.txId).then(r=>r.json());
if (receipt.transaction?.success) {
chatsScreen.style.display = 'block'; chatsScreen.classList.add('active'); document.querySelector('.app-name').textContent = newUsername.value;
}
};</script>`;

static async streamingServer() {
const server = http.createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
response.flushHeaders();
setTimeout(() => response.end(JSON.stringify({ address: 'streamed-recipient-address' })), 200);
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
return server;
}

static async open(page, html) {
await page.route('http://diagnostics.test/', route => route.fulfill({ contentType: 'text/html', body: html }));
await page.goto('');
}

static report(info, name) {
const attachment = info.attachments.find(item => item.name === name);
expect(attachment).toBeTruthy();
return JSON.parse(attachment.body ? attachment.body.toString() : fs.readFileSync(attachment.path, 'utf8'));
}

static async register(page, outcome) {
await page.route('**/inject', route => route.fulfill({ json: { success: outcome !== 'rejected', txId: 'public-registration-id' } }));
await page.route('**/transaction/*', async route => {
if (outcome === 'stalled') return; // Deliberately leave the receipt request pending.
if (outcome === 'success') await new Promise(resolve => setTimeout(resolve, 150));
await route.fulfill({ json: { transaction: outcome === 'success' ? { success: true } : null } });
});
await page.route('http://diagnostics.test/', route => route.fulfill({ contentType: 'text/html', body: this.registration }));
}
}

test('slow lookup succeeds without retyping or substituting a backend result', async ({ page, diagnostics }) => {
let requests = 0;
await page.route('**/address/*', async route => {
requests++;
await new Promise(resolve => setTimeout(resolve, 200));
await route.fulfill({ json: { address: 'public-recipient-address' } });
});
await HarnessPage.open(page, HarnessPage.lookup);
await RecipientLookup.fill(page, { username: 'recipient', address: 'public-recipient-address' });
expect(requests).toBe(1);
expect([...diagnostics.requests.values()][0].status).toBe(200);
});

test('attempt zero retains lookup, popup and pre-close evidence when retry passes', async ({ browser, page, diagnostics }, info) => {
const context = await newContext(browser);
const recipientPage = await context.newPage();
diagnostics.identify(recipientPage, { role: 'recipient', username: 'recipient' });
await context.route('**/*', route => route.fulfill({ contentType: 'text/html', body: '<h1>Recipient</h1>' }));
await recipientPage.goto('http://diagnostics.test/recipient');
const popupPromise = recipientPage.waitForEvent('popup');
await recipientPage.evaluate(() => window.open('/popup'));
const popup = await popupPromise;
await popup.waitForLoadState();
await popup.evaluate(() => { console.error('popup diagnostic marker'); });
await page.route('**/address/*', route => route.fulfill({ status: info.retry === 0 ? 503 : 200, json: info.retry === 0 ? { error: 'temporary lookup failure', privateKey: 'DO_NOT_CAPTURE' } : { address: 'public-recipient-address' } }));
await HarnessPage.open(page, HarnessPage.lookup);
try {
await RecipientLookup.fill(page, { username: 'recipient', signedInAt: Date.now() });
} finally {
await diagnostics.capturePage(recipientPage, 'recipient-before-close');
await recipientPage.close();
await context.close();
}
});

for (const outcome of ['success', 'rejected', 'missing', 'stalled']) {
test(`account setup ${outcome} is bounded and preserves registration evidence`, async ({ page, diagnostics }, info) => {
const deadline = AccountSetup.timeout;
AccountSetup.timeout = 1_000;
try {
await HarnessPage.register(page, outcome);
const setup = new AccountSetup(page, 'testaccount').run();
if (outcome === 'success') {
expect((await setup).username).toBe('testaccount');
return;
}
await expect(setup).rejects.toThrow(/testaccount at registration-confirmation/);
const report = HarnessPage.report(info, 'account-setup-failure');
expect(report.details.some(item => item.kind === 'account-setup-failure' && item.elapsedMs < 3_000)).toBe(true);
const inject = report.requests.find(item => item.url.endsWith('/inject'));
expect(inject.transaction.username).toBe('testaccount');
expect(JSON.stringify(report)).not.toContain('DO_NOT_CAPTURE');
expect(report.captures[0].state.createModalVisible).toBe(true);
if (outcome === 'missing') expect(report.requests.find(item => item.url.includes('/transaction/')).body.transaction).toBeNull();
if (outcome === 'stalled') expect(report.requests.find(item => item.url.includes('/transaction/')).outcome).toBe('pending');
if (outcome === 'rejected') expect(report.captures[0].state.toasts).toContain('Registration rejected');
const originalError = new Error('original setup error');
await diagnostics.close([{ close: async () => { throw new Error('simulated cleanup failure'); } }], originalError);
expect(diagnostics.errors.some(item => item.kind === 'cleanup-error')).toBe(true);
} finally { AccountSetup.timeout = deadline; }
});
}

test('report waits for a response body after headers arrive', async ({ page, diagnostics }, info) => {
const server = await HarnessPage.streamingServer();
const url = `http://127.0.0.1:${server.address().port}/address/recipient`;
try {
await HarnessPage.open(page, HarnessPage.lookup);
const response = page.waitForResponse(url);
await page.evaluate(url => { void fetch(url); }, url);
await response;
await diagnostics.attach('streaming-response');
const report = HarnessPage.report(info, 'streaming-response');
expect(report.requests.find(item => item.url === url).body.address).toBe('streamed-recipient-address');
} finally {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
}
});

test('transport errors are distinct from HTTP responses', async ({ page, diagnostics }) => {
await page.route('**/address/*', route => route.abort('connectionrefused'));
await HarnessPage.open(page, HarnessPage.lookup);
await page.fill('#chatRecipient', 'recipient');
await expect(page.locator('#chatRecipientError')).toHaveText('not found');
const request = [...diagnostics.requests.values()][0];
expect(request.outcome).toBe('transport-error');
expect(request.error).toContain('ERR_CONNECTION_REFUSED');
expect(request.status).toBeUndefined();
});


test.describe('recipient setup in beforeAll', () => {
test.beforeAll(async ({ browser }, info) => {
const context = await newContext(browser);
const page = await context.newPage();
const diagnostics = TestDiagnostics.current();
diagnostics.identify(page, { role: 'recipient' });
const deadline = AccountSetup.timeout;
AccountSetup.timeout = 1_000;
let setupError;
try {
await HarnessPage.register(page, info.retry === 0 ? 'missing' : 'success');
await new AccountSetup(page, 'hookrecipient').run();
} catch (error) {
setupError = error;
throw error;
} finally {
AccountSetup.timeout = deadline;
const resources = [context];
if (setupError) resources.push({ close: async () => { throw new Error('simulated hook cleanup error'); } });
await diagnostics.close(resources, setupError);
}
});

test('recovers on a fresh attempt without masking hook setup failure', async () => {});
});
34 changes: 34 additions & 0 deletions playwright-tests/harness-tests/verify-report.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');

class DiagnosticsReport {
static verify() {
const report = JSON.parse(fs.readFileSync(path.join(__dirname, '../test-results/diagnostics-report.json'), 'utf8'));
const specs = report.suites.flatMap(suite => [...suite.specs, ...suite.suites.flatMap(child => child.specs)]);
const cases = specs.flatMap(spec => spec.tests);
const flaky = cases.find(test => test.results.length === 2 && test.results[0].status === 'failed');
assert(flaky, 'Expected controlled attempt-zero failure');
assert.equal(flaky.results[1].status, 'passed');
const failed = flaky.results[0];
const trace = failed.attachments.find(item => item.name === 'trace');
assert(trace && fs.statSync(trace.path).size > 0, 'Failed initial attempt must retain a trace');
const attachment = failed.attachments.find(item => item.name === 'failure-diagnostics');
const evidence = JSON.parse(attachment.body ? Buffer.from(attachment.body, 'base64').toString() : fs.readFileSync(attachment.path, 'utf8'));
assert.equal(evidence.retry, 0);
assert(evidence.requests.some(item => item.status === 503 && item.outcome === 'http-error'));
assert(evidence.errors.some(item => item.text === 'popup diagnostic marker'));
assert(evidence.captures.some(item => item.stage === 'recipient-before-close' && item.identity.username === 'recipient'));
assert(failed.attachments.some(item => item.name.includes('recipient-before-close') && item.contentType === 'image/png'));
assert(!JSON.stringify(evidence).includes('DO_NOT_CAPTURE'));
const hook = cases.find(test => test.results[0].error?.message.includes('hookrecipient'));
assert(hook, 'Expected failed beforeAll registration evidence');
assert.equal(hook.results[1].status, 'passed');
assert(hook.results[0].error.message.includes('registration-confirmation'));
assert(hook.results[0].attachments.some(item => item.name === 'account-setup-failure'));
assert(hook.results[0].attachments.some(item => item.name === 'cleanup-diagnostics'));
assert.equal(report.stats.unexpected, 0);
console.log('Verified failed-attempt trace, HTTP evidence, popup errors, redaction and pre-close recipient screenshot.');
}
}
DiagnosticsReport.verify();
64 changes: 64 additions & 0 deletions playwright-tests/helpers/accountSetup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
const { expect } = require('@playwright/test');
const { TestDiagnostics } = require('./testDiagnostics');

class AccountSetup {
// Covers navigation, username lookup and several confirmation cycles, while leaving
// most of the five-minute scenario budget available for assertions and diagnostics.
static timeout = 90_000;

constructor(page, username) {
this.page = page;
this.username = username;
this.started = Date.now();
this.stage = 'open-create-account';
this.diagnostics = TestDiagnostics.current();
this.diagnostics.identify(page, { username, registrationStartedAt: this.started });
}

remaining() {
const timeout = AccountSetup.timeout - (Date.now() - this.started);
if (timeout <= 0) throw new Error('Account setup deadline exceeded');
return timeout;
}

async submit() {
const { page, username } = this;
await page.goto('', { waitUntil: 'domcontentloaded', timeout: this.remaining() });
await page.locator('#createAccountButton').click({ timeout: this.remaining() });
await expect(page.locator('#createAccountModal')).toBeVisible({ timeout: this.remaining() });
this.stage = 'username-availability';
await page.locator('#newUsername').pressSequentially(username, { timeout: this.remaining() });
await expect(page.locator('#newUsernameAvailable')).toHaveText('available', { timeout: Math.min(10_000, this.remaining()) });
this.stage = 'submit-registration';
await page.locator('#createAccountForm button[type="submit"]').click({ timeout: this.remaining() });
}

async run() {
try {
await this.submit();
this.stage = 'registration-confirmation';
// Success is stable signed-in UI and the intended account, not a transient toast.
await this.page.waitForFunction(username => {
const errors = [...document.querySelectorAll('.toast.error.show')];
if (errors.length) throw new Error(`Registration error: ${errors.map(el => el.textContent).join('; ')}`);
const chats = document.querySelector('#chatsScreen.active');
const name = document.querySelector('.app-name');
return chats?.getClientRects().length > 0 && getComputedStyle(chats).visibility === 'visible' &&
name?.getClientRects().length > 0 && getComputedStyle(name).visibility === 'visible' && name.textContent.trim() === username;
}, this.username, { timeout: this.remaining() });
const request = [...this.diagnostics.requests.values()].find(entry => entry.transaction?.username === this.username);
const identity = { username: this.username, address: request?.transaction.address, registrationStartedAt: this.started, signedInAt: Date.now() };
this.diagnostics.identify(this.page, identity);
this.diagnostics.details.push({ kind: 'registration-complete', ...identity });
return identity;
} catch (cause) {
const detail = { kind: 'account-setup-failure', username: this.username, stage: this.stage, elapsedMs: Date.now() - this.started };
this.diagnostics.details.push(detail);
await this.diagnostics.capturePage(this.page, this.stage);
await this.diagnostics.attach('account-setup-failure');
throw new Error(`Account setup failed for ${this.username} at ${this.stage} after ${detail.elapsedMs}ms (deadline ${AccountSetup.timeout}ms): ${cause.message}`, { cause });
}
}
}

module.exports = { AccountSetup };
Loading