test fixes (mostly intermittent ones)

This commit is contained in:
2026-10-17 15:40:27 -06:00
parent 5beace2822
commit d2cdd5785e
8 changed files with 158 additions and 13 deletions
+9 -2
View File
@@ -18,13 +18,20 @@ export default defineConfig({
fullyParallel: true, fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
/* Retry on CI only */ /* Load the app once before the tests so the dev server is warm; see that file
retries: process.env.CI ? 2 : 0, * for the Firefox WebAssembly failure it reduces. */
globalSetup: "./test-playwright/warmUpDevServer.ts",
/* Retry once locally: with several instances in parallel, Firefox can fail to
* start its WebAssembly compiler (so the app cannot open its database), at
* any point in a run. A retried pass is reported as "flaky", not hidden. */
retries: process.env.CI ? 2 : 1,
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: 3, workers: 3,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [ reporter: [
['list'], ['list'],
// explains failures caused by the Firefox WebAssembly start-up error
['./test-playwright/webAssemblyHintReporter.ts'],
['html'], ['html'],
// ['html', { open: 'never' }], // don't automatically open report page after tests complete even with failures // ['html', { open: 'never' }], // don't automatically open report page after tests complete even with failures
['json', { outputFile: 'test-playwright-results/test-results.json' }] ['json', { outputFile: 'test-playwright-results/test-results.json' }]
+9 -3
View File
@@ -774,6 +774,9 @@ export default class ClaimView extends Vue {
offerType?: string; offerType?: string;
} | null = null; } | null = null;
detailsForOffer: { fulfillsPlanHandleId?: string } | null = null; detailsForOffer: { fulfillsPlanHandleId?: string } | null = null;
// the claim and its linked offer and project; Affirm Delivery waits for it so
// an early tap does not fall back to the person in place of the project
claimLoad: Promise<void> = Promise.resolve();
// Project information for fulfillsPlanHandleId // Project information for fulfillsPlanHandleId
projectInfo: { projectInfo: {
name: string; name: string;
@@ -1003,7 +1006,8 @@ export default class ClaimView extends Vue {
const claimId = this.$route.params.id as string; const claimId = this.$route.params.id as string;
if (claimId) { if (claimId) {
await this.loadClaim(claimId, this.activeDid); this.claimLoad = this.loadClaim(claimId, this.activeDid);
await this.claimLoad;
await this.loadClaimNeighbors(); await this.loadClaimNeighbors();
} else { } else {
this.notify.error("No claim ID was provided."); this.notify.error("No claim ID was provided.");
@@ -1408,12 +1412,14 @@ export default class ClaimView extends Vue {
}; };
(this.$router as Router).push(route).then(async () => { (this.$router as Router).push(route).then(async () => {
this.resetThisValues(); this.resetThisValues();
await this.loadClaim(claimId, this.activeDid); this.claimLoad = this.loadClaim(claimId, this.activeDid);
await this.claimLoad;
await this.loadClaimNeighbors(); await this.loadClaimNeighbors();
}); });
} }
handleAffirmDeliveryClick() { async handleAffirmDeliveryClick() {
await this.claimLoad;
if (!this.isRegistered) { if (!this.isRegistered) {
this.notify.error("You must be registered to affirm delivery."); this.notify.error("You must be registered to affirm delivery.");
return; return;
+9 -3
View File
@@ -12,6 +12,7 @@ Raymer * @version 1.0.0 */
id="Content" id="Content"
class="p-6 pb-24 max-w-3xl mx-auto" class="p-6 pb-24 max-w-3xl mx-auto"
:data-active-did="activeDid" :data-active-did="activeDid"
:data-new-activity-loaded="newActivityLoaded"
> >
<TopMessage /> <TopMessage />
@@ -421,6 +422,8 @@ export default class HomeView extends Vue {
isRegistered = false; isRegistered = false;
// what each New Activity list holds that the person hasn't seen // what each New Activity list holds that the person hasn't seen
newActivityCounts: UnseenCounts | null = null; newActivityCounts: UnseenCounts | null = null;
// the New Activity load finished (and stored any first start), for tests
newActivityLoaded = false;
searchBoxes: Array<{ searchBoxes: Array<{
name: string; name: string;
bbox: BoundingBox; bbox: BoundingBox;
@@ -526,9 +529,9 @@ export default class HomeView extends Vue {
// Settings already loaded in initializeIdentity() // Settings already loaded in initializeIdentity()
// Contacts already loaded in initializeIdentity() // Contacts already loaded in initializeIdentity()
// Registration check already handled in initializeIdentity() // Registration check already handled in initializeIdentity()
await this.loadFeedData(); // in parallel, so a first visit records where New Activity starts even
// when the person leaves before the feed finishes
await this.loadNewActivityCounts(); await Promise.all([this.loadFeedData(), this.loadNewActivityCounts()]);
logger.debug("[HomeView] mounted() - component lifecycle completed", { logger.debug("[HomeView] mounted() - component lifecycle completed", {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -775,6 +778,7 @@ export default class HomeView extends Vue {
private async loadNewActivityCounts() { private async loadNewActivityCounts() {
if (!this.activeDid) { if (!this.activeDid) {
this.newActivityCounts = null; this.newActivityCounts = null;
this.newActivityLoaded = true;
return; return;
} }
try { try {
@@ -807,6 +811,8 @@ export default class HomeView extends Vue {
error: errorStringForLog(error), error: errorStringForLog(error),
}); });
this.newActivityCounts = null; this.newActivityCounts = null;
} finally {
this.newActivityLoaded = true;
} }
} }
+18 -1
View File
@@ -32,7 +32,15 @@
<!-- Startup error: recovery options instead of identity creation --> <!-- Startup error: recovery options instead of identity creation -->
<div v-if="startupError" class="max-w-3xl mx-auto"> <div v-if="startupError" class="max-w-3xl mx-auto">
<div class="p-4 bg-red-50 border border-red-300 rounded-lg"> <div class="p-4 bg-red-50 border border-red-300 rounded-lg">
<p class="text-red-800"> <p v-if="webAssemblyUnavailable" class="text-red-800">
This browser could not run WebAssembly, which the app needs to open
its data. Try Again first, since this is sometimes temporary. If it
keeps happening, a privacy or security setting is probably blocking it
(such as Tor Browser's "Safest" level, Apple's Lockdown Mode, or a
strict security mode in Edge): allow this site in that setting, or use
another browser.
</p>
<p v-else class="text-red-800">
The app could not open its data on this device, so nothing else will The app could not open its data on this device, so nothing else will
work until this is fixed. Reinstalling usually does not help, because work until this is fixed. Reinstalling usually does not help, because
the phone restores the same data from its backup. the phone restores the same data from its backup.
@@ -297,6 +305,15 @@ export default class StartView extends Vue {
].join("\n\n"); ].join("\n\n");
} }
/**
* The web database (sql.js) needs WebAssembly; browsers report its absence
* as, for example, "no WebAssembly compiler available" (Firefox) or
* "WebAssembly is not defined".
*/
get webAssemblyUnavailable(): boolean {
return /WebAssembly/i.test(this.startupError);
}
get supportMailto(): string { get supportMailto(): string {
// Mail apps truncate long mailto links; Copy Details carries the full text. // Mail apps truncate long mailto links; Copy Details carries the full text.
const body = this.errorReport.slice(0, 1800); const body = this.errorReport.slice(0, 1800);
+3 -1
View File
@@ -1,11 +1,13 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { switchToUser, getTestUserData, importUserFromAccount } from './testUtils'; import { switchToUser, getTestUserData, importUserFromAccount, waitForNewActivityLoaded } from './testUtils';
test('New offers for another user', async ({ page }) => { test('New offers for another user', async ({ page }) => {
await page.goto('./'); await page.goto('./');
// Get the auto-created DID from the HomeView // Get the auto-created DID from the HomeView
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
// this first Home visit records where this user's New Activity starts
await waitForNewActivityLoaded(page);
const autoCreatedDid = await page.getAttribute('#Content', 'data-active-did'); const autoCreatedDid = await page.getAttribute('#Content', 'data-active-did');
if (!autoCreatedDid) { if (!autoCreatedDid) {
+19 -2
View File
@@ -31,11 +31,18 @@ export async function importUserFromAccount(page: Page, id?: string): Promise<st
// Navigate to AccountViewView to use the Identity Switcher // Navigate to AccountViewView to use the Identity Switcher
await page.goto("./account"); await page.goto("./account");
// Click "Show Advanced Settings" to reveal the identity switcher // Click "Show Advanced Settings" to reveal the identity switcher; loading the
// settings resets that section to closed, so retry a click that lands first
const switchIdentityLink = page.locator("#switch-identity-link");
await expect(async () => {
if (!(await switchIdentityLink.isVisible())) {
await page.getByTestId("advancedSettings").click(); await page.getByTestId("advancedSettings").click();
}
await expect(switchIdentityLink).toBeVisible({ timeout: 1000 });
}).toPass({ timeout: 15000 });
// Use the identity switcher to add User Zero // Use the identity switcher to add User Zero
await page.locator("#switch-identity-link").click(); await switchIdentityLink.click();
await page.locator("#start-link").click(); await page.locator("#start-link").click();
// Select "You have a seed" option // Select "You have a seed" option
@@ -356,6 +363,15 @@ export function getPageLoadTimeout(): number {
return getAdaptiveTimeout(30000, 1.4); return getAdaptiveTimeout(30000, 1.4);
} }
/**
* Wait for Home to finish loading New Activity. A first visit for an identity
* records where New Activity starts; leaving before then makes a later visit
* the start, and activity created in between never counts as new.
*/
export async function waitForNewActivityLoaded(page: Page): Promise<void> {
await expect(page.locator('#Content')).toHaveAttribute('data-new-activity-loaded', 'true', { timeout: 15000 });
}
/** /**
* PHASE 1 FIX: Wait for registration status to settle * PHASE 1 FIX: Wait for registration status to settle
* *
@@ -385,6 +401,7 @@ export async function waitForRegistrationStatusToSettle(page: Page): Promise<voi
// Navigate to home to trigger the registration check // Navigate to home to trigger the registration check
await page.goto('./'); await page.goto('./');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
await waitForNewActivityLoaded(page);
// Wait for the registration check to complete by monitoring the usage limits section // Wait for the registration check to complete by monitoring the usage limits section
// This ensures the async registration check has finished // This ensures the async registration check has finished
+26
View File
@@ -0,0 +1,26 @@
import { chromium, FullConfig } from "@playwright/test";
/**
* Loads the app once before any test so Vite has bundled its dependencies and
* transformed the modules (including the SQL worker) before the workers start.
*
* Reason: with several Firefox instances running in parallel, Firefox can fail
* with "no WebAssembly compiler available", sql.js cannot start, and the app
* shows "Time Safari Cannot Start". It is most frequent at the start of a run
* against a cold dev server, which this avoids; it also happens mid-run, which
* this does not, so the retry in playwright.config-local.ts stays.
*/
export default async function warmUpDevServer(config: FullConfig) {
const baseURL = config.projects[0]?.use?.baseURL || "http://localhost:8080";
const browser = await chromium.launch();
try {
const page = await browser.newPage();
await page.goto(baseURL, { timeout: 120000 });
// Vite reloads the page if it finds dependencies it has not bundled yet,
// so wait for the app to render and the network to go quiet afterward.
await page.locator("#Content").first().waitFor({ timeout: 120000 });
await page.waitForLoadState("networkidle", { timeout: 120000 });
} finally {
await browser.close();
}
}
@@ -0,0 +1,64 @@
import { readFileSync } from "fs";
import type {
Reporter,
TestCase,
TestResult,
} from "@playwright/test/reporter";
/**
* Explains failures where the app could not start because Firefox reported
* "no WebAssembly compiler available" (see warmUpDevServer.ts). The error
* shows only in the page snapshot, since the test itself times out waiting
* for an element on a page that never loaded.
*/
const MARKER = "no WebAssembly compiler available";
const HINT = [
`The app could not start: Firefox reported "${MARKER}", so sql.js could not open the database.`,
"Previous hypothesis was a cold dev server while several Firefox instances start, so warmUpDevServer.ts",
"loads the app before the tests; it is not a definitive fix (this also happens mid-run), so you may",
"want to look further. The local retry in playwright.config-local.ts usually passes on the second try.",
].join("\n ");
export default class WebAssemblyHintReporter implements Reporter {
private hits: string[] = [];
printsToStdio(): boolean {
return false;
}
onTestEnd(test: TestCase, result: TestResult): void {
if (result.status === "passed" || result.status === "skipped") {
return;
}
const texts = result.errors.map((e) => e.message || "");
for (const attachment of result.attachments) {
if (attachment.name === "error-context" && attachment.path) {
try {
// the page appears under "Page snapshot" or, for a failed expect,
// under "Error details"; skip "Test source", which quotes the spec
const context = readFileSync(attachment.path, "utf8");
const end = context.indexOf("# Test source");
texts.push(end >= 0 ? context.slice(0, end) : context);
} catch {
// the snapshot file may be missing; the errors are still checked
}
}
}
if (texts.some((text) => text.includes(MARKER))) {
const name = `${test.parent.project()?.name ?? ""} › ${test.title}${result.retry ? ` (retry ${result.retry})` : ""}`;
this.hits.push(name);
console.log(`\n WebAssembly start-up failure: ${name}\n ${HINT}\n`);
}
}
onEnd(): void {
if (this.hits.length > 0) {
console.log(
`\n ${this.hits.length} failure(s) were the Firefox WebAssembly start-up error:\n ` +
this.hits.join("\n ") +
`\n ${HINT}\n`,
);
}
}
}