From 45bcd875bf616dae930c3fb546b651d39b127758 Mon Sep 17 00:00:00 2001 From: Hugh Parry Date: Wed, 8 Apr 2026 13:42:31 +0100 Subject: [PATCH 1/4] flacky cypress tests --- .../cypress/e2e/correct.captcha.cy.ts | 11 ++--- .../cypress/e2e/correct.captcha.signup.cy.ts | 11 ++--- .../cypress/support/commands.ts | 43 +++++++++++++++---- 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/demos/cypress-shared/cypress/e2e/correct.captcha.cy.ts b/demos/cypress-shared/cypress/e2e/correct.captcha.cy.ts index 6e8600b509..103e666f45 100644 --- a/demos/cypress-shared/cypress/e2e/correct.captcha.cy.ts +++ b/demos/cypress-shared/cypress/e2e/correct.captcha.cy.ts @@ -16,7 +16,11 @@ import "@cypress/xpath"; import { ProsopoDatasetError } from "@prosopo/common"; import { datasetWithSolutionHashes } from "@prosopo/datasets"; import type { Captcha, CaptchaType } from "@prosopo/types"; -import { checkboxClass, getWidgetElement } from "../support/commands.js"; +import { + buildTestSolutions, + checkboxClass, + getWidgetElement, +} from "../support/commands.js"; const baseCaptchaType: CaptchaType = Cypress.env("CAPTCHA_TYPE") || "image"; @@ -55,10 +59,7 @@ describe("Captchas", () => { }); beforeEach(() => { - const solutions = datasetWithSolutionHashes.captchas.map((captcha) => ({ - captchaContentId: captcha.captchaContentId, - solution: captcha.solution, - })); + const solutions = buildTestSolutions(datasetWithSolutionHashes.captchas); if (!solutions) { throw new ProsopoDatasetError( diff --git a/demos/cypress-shared/cypress/e2e/correct.captcha.signup.cy.ts b/demos/cypress-shared/cypress/e2e/correct.captcha.signup.cy.ts index 804b5d6c5a..eb607be0f0 100644 --- a/demos/cypress-shared/cypress/e2e/correct.captcha.signup.cy.ts +++ b/demos/cypress-shared/cypress/e2e/correct.captcha.signup.cy.ts @@ -16,7 +16,11 @@ import "@cypress/xpath"; import { ProsopoDatasetError } from "@prosopo/common"; import { datasetWithSolutionHashes } from "@prosopo/datasets"; import type { Captcha, CaptchaType } from "@prosopo/types"; -import { checkboxClass, getWidgetElement } from "../support/commands.js"; +import { + buildTestSolutions, + checkboxClass, + getWidgetElement, +} from "../support/commands.js"; const baseCaptchaType: CaptchaType = Cypress.env("CAPTCHA_TYPE") || "image"; @@ -55,10 +59,7 @@ describe("Captchas", () => { }); beforeEach(() => { - const solutions = datasetWithSolutionHashes.captchas.map((captcha) => ({ - captchaContentId: captcha.captchaContentId, - solution: captcha.solution, - })); + const solutions = buildTestSolutions(datasetWithSolutionHashes.captchas); if (!solutions) { throw new ProsopoDatasetError( diff --git a/demos/cypress-shared/cypress/support/commands.ts b/demos/cypress-shared/cypress/support/commands.ts index 2849debf4f..6bae626719 100644 --- a/demos/cypress-shared/cypress/support/commands.ts +++ b/demos/cypress-shared/cypress/support/commands.ts @@ -24,10 +24,32 @@ import { import { at } from "@prosopo/util"; import Chainable = Cypress.Chainable; import { getPair } from "@prosopo/keyring"; -import type { SolutionRecord } from "@prosopo/types"; +import type { CaptchaWithoutId } from "@prosopo/types"; export const MAX_IMAGE_CAPTCHA_ROUNDS = 3; +// Solution record keyed by item hashes for stable matching across dataset rebuilds. +// buildDataset recomputes captchaContentId via merkle trees, so matching by +// captchaContentId against the static fixture is unreliable. +interface TestSolution { + itemHashes: string; + solution: string[]; +} + +export function buildTestSolutions( + captchas: CaptchaWithoutId[], +): TestSolution[] { + return captchas + .filter((c) => c.solution) + .map((c) => ({ + itemHashes: c.items + .map((i) => i.hash) + .sort() + .join(","), + solution: (c.solution ?? []).map((s) => s.toString()), + })); +} + declare global { namespace Cypress { // biome-ignore lint/suspicious/noExplicitAny: TODO fix any @@ -219,17 +241,20 @@ function captchaImages(): Cypress.Chainable> { function getSelectors(captcha: Captcha): Chainable { cy.wrap({ captcha }) .then(({ captcha }) => { - cy.get("@solutions").then((solutions) => { + cy.get("@solutions").then((solutions) => { let selectors: string[] = []; - // Get the index of the captcha in the solution records array - const captchaIndex = solutions.findIndex( - (testSolution) => - testSolution.captchaContentId === captcha.captchaContentId, + // Match by item hashes rather than captchaContentId, because + // buildDataset recomputes captchaContentId via merkle trees. + const captchaItemHashes = captcha.items + .map((i) => i.hash) + .sort() + .join(","); + const match = solutions.find( + (s) => s.itemHashes === captchaItemHashes, ); - if (captchaIndex !== -1) { - const solution = at(solutions, captchaIndex).solution; + if (match) { selectors = captcha.items - .filter((item) => solution.includes(item.hash)) + .filter((item) => match.solution.includes(item.hash)) // create a query selector for each image that is a solution // drop https from the urls as this is what procaptcha does (avoids mixed-content warnings, e.g. resources loaded via a mix of http / https) .map( From 72a04ee365371804d231522797ece6cf3a56a748 Mon Sep 17 00:00:00 2001 From: Hugh Parry Date: Wed, 8 Apr 2026 13:42:45 +0100 Subject: [PATCH 2/4] lint fix --- demos/cypress-shared/cypress/support/commands.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/demos/cypress-shared/cypress/support/commands.ts b/demos/cypress-shared/cypress/support/commands.ts index 6bae626719..e992978a2f 100644 --- a/demos/cypress-shared/cypress/support/commands.ts +++ b/demos/cypress-shared/cypress/support/commands.ts @@ -21,7 +21,6 @@ import { type IUserSettings, Tier, } from "@prosopo/types"; -import { at } from "@prosopo/util"; import Chainable = Cypress.Chainable; import { getPair } from "@prosopo/keyring"; import type { CaptchaWithoutId } from "@prosopo/types"; @@ -249,9 +248,7 @@ function getSelectors(captcha: Captcha): Chainable { .map((i) => i.hash) .sort() .join(","); - const match = solutions.find( - (s) => s.itemHashes === captchaItemHashes, - ); + const match = solutions.find((s) => s.itemHashes === captchaItemHashes); if (match) { selectors = captcha.items .filter((item) => match.solution.includes(item.hash)) From c4c9bab78080ff00b01356f0d89b21b0bd76b5d2 Mon Sep 17 00:00:00 2001 From: Hugh Parry Date: Wed, 8 Apr 2026 13:43:05 +0100 Subject: [PATCH 3/4] docs(changeset): Flaky cypress tests fix --- .changeset/solid-hornets-move.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/solid-hornets-move.md diff --git a/.changeset/solid-hornets-move.md b/.changeset/solid-hornets-move.md new file mode 100644 index 0000000000..e8bfd1e0d0 --- /dev/null +++ b/.changeset/solid-hornets-move.md @@ -0,0 +1,6 @@ +--- +"@prosopo/cypress-shared": patch +--- + +Flaky cypress tests fix + \ No newline at end of file From 5266ce85add4d60a6c3df2528006dd51e316ed09 Mon Sep 17 00:00:00 2001 From: Hugh Parry Date: Wed, 8 Apr 2026 14:15:25 +0100 Subject: [PATCH 4/4] potential flaky test fix --- .../cypress/support/commands.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/demos/cypress-shared/cypress/support/commands.ts b/demos/cypress-shared/cypress/support/commands.ts index e992978a2f..086dbf0610 100644 --- a/demos/cypress-shared/cypress/support/commands.ts +++ b/demos/cypress-shared/cypress/support/commands.ts @@ -27,11 +27,13 @@ import type { CaptchaWithoutId } from "@prosopo/types"; export const MAX_IMAGE_CAPTCHA_ROUNDS = 3; -// Solution record keyed by item hashes for stable matching across dataset rebuilds. -// buildDataset recomputes captchaContentId via merkle trees, so matching by -// captchaContentId against the static fixture is unreliable. +// Solution record keyed by item hashes + target for stable matching across +// dataset rebuilds. We can't match by captchaContentId because buildDataset +// recomputes it via merkle trees. We must include target because multiple +// captchas can share the same images with different targets/solutions. interface TestSolution { itemHashes: string; + target: string; solution: string[]; } @@ -45,6 +47,7 @@ export function buildTestSolutions( .map((i) => i.hash) .sort() .join(","), + target: c.target, solution: (c.solution ?? []).map((s) => s.toString()), })); } @@ -242,13 +245,19 @@ function getSelectors(captcha: Captcha): Chainable { .then(({ captcha }) => { cy.get("@solutions").then((solutions) => { let selectors: string[] = []; - // Match by item hashes rather than captchaContentId, because - // buildDataset recomputes captchaContentId via merkle trees. + // Match by item hashes + target rather than captchaContentId, because + // buildDataset recomputes captchaContentId via merkle trees. Target is + // needed because multiple captchas share the same images with different + // targets and solutions. const captchaItemHashes = captcha.items .map((i) => i.hash) .sort() .join(","); - const match = solutions.find((s) => s.itemHashes === captchaItemHashes); + const match = solutions.find( + (s) => + s.itemHashes === captchaItemHashes && + s.target === captcha.target, + ); if (match) { selectors = captcha.items .filter((item) => match.solution.includes(item.hash))