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 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..086dbf0610 100644 --- a/demos/cypress-shared/cypress/support/commands.ts +++ b/demos/cypress-shared/cypress/support/commands.ts @@ -21,13 +21,37 @@ import { type IUserSettings, Tier, } from "@prosopo/types"; -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 + 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[]; +} + +export function buildTestSolutions( + captchas: CaptchaWithoutId[], +): TestSolution[] { + return captchas + .filter((c) => c.solution) + .map((c) => ({ + itemHashes: c.items + .map((i) => i.hash) + .sort() + .join(","), + target: c.target, + solution: (c.solution ?? []).map((s) => s.toString()), + })); +} + declare global { namespace Cypress { // biome-ignore lint/suspicious/noExplicitAny: TODO fix any @@ -219,17 +243,24 @@ 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 + 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 && + s.target === captcha.target, ); - 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(