From 0df60979d02be6a983323b0787c429c2c2c9a577 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Fri, 22 Aug 2025 15:28:07 -0400 Subject: [PATCH 01/60] Add dynamic Playwright test runner and enhance env support Introduces a new utilities/test-runner.js script to dynamically build and execute Playwright test commands based on environment variables and tags. Updates package.json scripts to use the new runner and provide environment/tag-specific test commands. Extends env.ts to support 'prd' and 'int' as TARGET_ENV values. Updates CircleCI config to support test tags and improves Slack notifications for main and develop branches. --- .circleci/config.yml | 65 ++++++++++++++-- package.json | 16 +++- utilities/env.ts | 4 +- utilities/test-runner.js | 156 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 utilities/test-runner.js diff --git a/.circleci/config.yml b/.circleci/config.yml index 77bedc5..b46b340 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,6 +10,9 @@ parameters: testExecKey: type: string default: 'none' + testTags: + type: string + default: '' jobs: code-quality-check: working_directory: ~/tidepool-org/webuitests @@ -38,6 +41,7 @@ jobs: environment: TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> TARGET_ENV: << pipeline.parameters.testEnvironment >> + TEST_TAGS: << pipeline.parameters.testTags >> steps: - checkout - node/install @@ -69,19 +73,69 @@ jobs: path: test-results - store_test_results: path: test-output/test-results.xml - # Commit workflow notifications - basic templates for all branches + # Main and Develop branch notifications - always notify with branch name - slack/notify: event: fail + branch_pattern: main mentions: '<@UG56AQFK2>' - template: basic_fail_1 + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":x: *Tidepool Web UI Tests Failed on Main Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + } + } + ] + } - slack/notify: event: pass branch_pattern: main - template: basic_success_1 + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":white_check_mark: *Tidepool Web UI Tests Passed on Main Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + } + } + ] + } + - slack/notify: + event: fail + branch_pattern: develop + mentions: '<@UG56AQFK2>' + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":x: *Tidepool Web UI Tests Failed on Develop Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + } + } + ] + } - slack/notify: event: pass - branch_pattern: /^(?!main$).*/ - template: basic_success_1 + branch_pattern: develop + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":white_check_mark: *Tidepool Web UI Tests Passed on Develop Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + } + } + ] + } - unless: condition: and: @@ -114,6 +168,7 @@ jobs: environment: TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> TARGET_ENV: << pipeline.parameters.testEnvironment >> + TEST_TAGS: << pipeline.parameters.testTags >> steps: - checkout - node/install diff --git a/package.json b/package.json index 276f5e4..afaf8b2 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,20 @@ "lint:fix": "eslint --ext .ts . --fix", "typecheck": "tsc --noEmit", "check": "npm run lint && npm run typecheck", - "debug": "TARGET_ENV=qa1 npx playwright test --debug ", - "test": "TARGET_ENV=qa1 playwright test", + "debug": "node utilities/test-runner.js --debug", + "test": "node utilities/test-runner.js", + "test:qa1": "TARGET_ENV=qa1 node utilities/test-runner.js", + "test:qa2": "TARGET_ENV=qa2 node utilities/test-runner.js", + "test:qa3": "TARGET_ENV=qa3 node utilities/test-runner.js", + "test:qa4": "TARGET_ENV=qa4 node utilities/test-runner.js", + "test:prd": "TARGET_ENV=prd node utilities/test-runner.js", + "test:int": "TARGET_ENV=int node utilities/test-runner.js", + "test:smoke": "TEST_TAGS='@smoke' node utilities/test-runner.js", + "test:critical": "TEST_TAGS='@critical' node utilities/test-runner.js", + "test:api": "TEST_TAGS='@api' node utilities/test-runner.js", + "test:ui": "TEST_TAGS='@ui' node utilities/test-runner.js", + "test:patient": "TEST_TAGS='@patient' node utilities/test-runner.js", + "test:clinician": "TEST_TAGS='@clinician' node utilities/test-runner.js", "format": "prettier --write ." }, "repository": { diff --git a/utilities/env.ts b/utilities/env.ts index 5c11e15..9323afe 100644 --- a/utilities/env.ts +++ b/utilities/env.ts @@ -14,7 +14,7 @@ const envSchema = z.object({ SHARED_PASSWORD: z.string(), CLINICIAN_USERNAME: z.string(), CLINICIAN_PASSWORD: z.string(), - TARGET_ENV: z.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production']), + TARGET_ENV: z.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production', 'prd', 'int']), XRAY_CLIENT_ID: z.string().optional(), XRAY_CLIENT_SECRET: z.string().optional(), }); @@ -32,6 +32,8 @@ const URL_MAP: Record = { qa4: 'https://qa4.development.tidepool.org', qa5: 'https://qa5.development.tidepool.org', production: 'https://app.tidepool.org', + prd: 'https://app.tidepool.org', // Alias for production + int: 'https://int.development.tidepool.org', // Integration environment }; export default { diff --git a/utilities/test-runner.js b/utilities/test-runner.js new file mode 100644 index 0000000..405acbb --- /dev/null +++ b/utilities/test-runner.js @@ -0,0 +1,156 @@ +/** + * Dynamic Test Runner Utility + * + * This utility builds and executes Playwright test commands dynamically based on: + * - TARGET_ENV environment variable (defaults to qa1) + * - TEST_TAGS environment variable (space or comma separated) + * - Command line arguments for additional Playwright flags + * + * Usage: + * node utilities/test-runner.js # Run all tests on qa1 + * TARGET_ENV=qa2 node utilities/test-runner.js # Run all tests on qa2 + * TEST_TAGS="@smoke @critical" node utilities/test-runner.js # Run smoke AND critical tests + * TEST_TAGS="@api,@ui" node utilities/test-runner.js # Run api OR ui tests (comma-separated = OR) + * node utilities/test-runner.js --debug # Pass additional flags to Playwright + */ + +const { spawn } = require('node:child_process'); +const { existsSync } = require('node:fs'); +const path = require('node:path'); + +// Get environment variables with defaults +const targetEnv = process.env.TARGET_ENV || 'qa1'; +const testTags = process.env.TEST_TAGS || ''; +const circleCINodeIndex = process.env.CIRCLE_NODE_INDEX; +const circleCINodeTotal = process.env.CIRCLE_NODE_TOTAL; + +// Get additional command line arguments (everything after the script name) +const additionalArgs = process.argv.slice(2); + +/** + * Parse test tags and build Playwright grep arguments + * @param {string} tags - Space or comma separated tags + * @returns {string[]} Array of grep arguments for Playwright + */ +function buildGrepArgs(tags) { + if (!tags || tags.trim() === '') { + return []; + } + + // Normalize tags: remove @, handle both space and comma separation + const tagList = tags + .split(/[\s,]+/) + .map(tag => tag.trim()) + .filter(tag => tag.length > 0) + .map(tag => (tag.startsWith('@') ? tag.slice(1) : tag)); + + if (tagList.length === 0) { + return []; + } + + if (tagList.length === 1) { + // Single tag: simple grep + return ['--grep', `@${tagList[0]}`]; + } + + // Multiple tags: check if original input used commas (OR logic) or spaces (AND logic) + const hasCommas = tags.includes(','); + + if (hasCommas) { + // Comma-separated = OR logic: @tag1|@tag2|@tag3 + const orPattern = tagList.map(tag => `@${tag}`).join('|'); + return ['--grep', orPattern]; + } + // Space-separated = AND logic: (?=.*@tag1)(?=.*@tag2)(?=.*@tag3) + const andPattern = tagList.map(tag => `(?=.*@${tag})`).join(''); + return ['--grep', andPattern]; +} + +/** + * Build the complete Playwright command + * @returns {object} Command and arguments for spawning + */ +function buildPlaywrightCommand() { + const baseArgs = ['test']; + + // Add sharding for CircleCI if available + if (circleCINodeIndex !== undefined && circleCINodeTotal !== undefined) { + baseArgs.push(`--shard=${circleCINodeIndex}/${circleCINodeTotal}`); + } + + // Add grep arguments for tags + const grepArgs = buildGrepArgs(testTags); + baseArgs.push(...grepArgs); + + // Add any additional command line arguments + baseArgs.push(...additionalArgs); + + return { + command: 'npx', + args: ['playwright', ...baseArgs], + env: { + ...process.env, + TARGET_ENV: targetEnv, + }, + }; +} + +/** + * Main execution function + */ +function main() { + const { command, args, env } = buildPlaywrightCommand(); + + // Log the command being executed for transparency + console.log(`šŸŽ­ Running Playwright tests:`); + console.log(` Environment: ${targetEnv}`); + console.log(` Tags: ${testTags || '(all tests)'}`); + console.log(` Command: ${command} ${args.join(' ')}`); + console.log(''); + + // Validate that we're in the right directory + if (!existsSync('playwright.config.ts')) { + console.error( + 'āŒ Error: playwright.config.ts not found. Please run this script from the project root.', + ); + process.exit(1); + } + + // Spawn the Playwright process + const playwrightProcess = spawn(command, args, { + env, + stdio: 'inherit', // Pass through all stdio streams + shell: process.platform === 'win32', // Use shell on Windows + }); + + // Handle process events + playwrightProcess.on('error', error => { + console.error(`āŒ Failed to start Playwright: ${error.message}`); + process.exit(1); + }); + + playwrightProcess.on('close', code => { + const emoji = code === 0 ? 'āœ…' : 'āŒ'; + console.log(`${emoji} Playwright tests completed with exit code: ${code}`); + process.exit(code); + }); + + // Handle graceful shutdown + process.on('SIGINT', () => { + console.log('\nšŸ›‘ Received SIGINT, terminating Playwright...'); + playwrightProcess.kill('SIGINT'); + }); + + process.on('SIGTERM', () => { + console.log('\nšŸ›‘ Received SIGTERM, terminating Playwright...'); + playwrightProcess.kill('SIGTERM'); + }); +} + +// Export for testing +module.exports = { buildGrepArgs, buildPlaywrightCommand }; + +// Run if called directly +if (require.main === module) { + main(); +} From 69b21ac7184139986bb7fcb6853319c494f76030 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Fri, 22 Aug 2025 15:31:11 -0400 Subject: [PATCH 02/60] Update CircleCI workflow to remove test job The commit-workflow in .circleci/config.yml now only runs the code-quality-check job, removing the test job and its dependency on eslint-check. --- .circleci/config.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index b46b340..a08587f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -290,7 +290,4 @@ jobs: workflows: commit-workflow: jobs: - - code-quality-check - - test: - requires: - - eslint-check \ No newline at end of file + - code-quality-check \ No newline at end of file From 65080fda2c1259aedb9c134c131cd8eefc465ce5 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 25 Aug 2025 15:13:54 -0400 Subject: [PATCH 03/60] Add Playwright test framework and Xray integration Introduces Playwright-based end-to-end test infrastructure, including page objects for patient, clinician, and account flows, endpoint schema validation utilities, and Xray JSON reporter integration. Updates CircleCI config to build TypeScript utilities and upload test results to Xray in JSON format. Adds supporting utilities, test fixtures, and documentation for Xray integration. --- .circleci/config.yml | 24 +- build/endpoint-schema/auth-endpoints.js | 53 ++ build/endpoint-schema/endpoint-registry.js | 52 ++ .../endpoint-schema/patient-data-endpoints.js | 56 ++ build/endpoint-schema/profile-endpoints.js | 107 ++++ build/page-objects/LoginPage.js | 44 ++ .../page-objects/account/AccountNavigation.js | 62 +++ .../account/AccountSettingsPage.js | 13 + .../clinician/ClinicCreationPage.js | 84 +++ .../clinician/ClinicianDashboardPage.js | 79 +++ .../clinician/ClinicianNavigation.js | 119 +++++ .../clinician/WorkspaceSettingsPage.js | 29 ++ .../page-objects/clinician/WorkspacesPage.js | 36 ++ .../components/navigation-menu.section.js | 27 + .../components/navigation.section.js | 22 + build/page-objects/patient/BasicsPage.js | 143 ++++++ build/page-objects/patient/DailyPage.js | 17 + .../page-objects/patient/PatientNavigation.js | 100 ++++ build/page-objects/patient/ProfilePage.js | 115 +++++ .../patient/components/daily-chart.js | 14 + build/playwright.config.js | 113 ++++ .../claimed-profile-edit-fullname.spec.js | 148 ++++++ .../comprehensive-profile-access-test.spec.js | 159 ++++++ .../API-User/claimed-email-edit.spec.js | 95 ++++ .../edit-custodial-profile-API.spec.js | 91 ++++ build/tests/clinician/add-patient.spec.js | 38 ++ .../clinician/create-clinic-workspace.spec.js | 86 ++++ .../clinician/edit-clinic-address.spec.js | 47 ++ build/tests/clinician/filter-patient.spec.js | 70 +++ build/tests/fixtures/account-helpers.js | 123 +++++ build/tests/fixtures/base.js | 257 ++++++++++ build/tests/fixtures/clinic-helpers.js | 280 ++++++++++ build/tests/fixtures/network-helpers.js | 480 +++++++++++++++++ build/tests/fixtures/patient-helpers.js | 483 ++++++++++++++++++ build/tests/fixtures/test-tags.js | 98 ++++ build/tests/global-setup.js | 47 ++ .../edit-personal-profile-API.spec.js | 75 +++ .../personal/basic-functionality.spec.js | 240 +++++++++ build/tests/personal/login.spec.js | 66 +++ build/utilities/annotations.js | 24 + build/utilities/env.js | 42 ++ build/utilities/xray-json-reporter.js | 268 ++++++++++ build/utilities/xray-reporter.js | 134 +++++ dist/endpoint-schema/auth-endpoints.d.ts | 13 + dist/endpoint-schema/auth-endpoints.js | 50 ++ dist/endpoint-schema/endpoint-registry.d.ts | 34 ++ dist/endpoint-schema/endpoint-registry.js | 48 ++ .../patient-data-endpoints.d.ts | 13 + .../endpoint-schema/patient-data-endpoints.js | 53 ++ dist/endpoint-schema/profile-endpoints.d.ts | 32 ++ dist/endpoint-schema/profile-endpoints.js | 104 ++++ dist/page-objects/LoginPage.d.ts | 32 ++ dist/page-objects/LoginPage.js | 41 ++ .../account/AccountNavigation.d.ts | 18 + .../page-objects/account/AccountNavigation.js | 59 +++ .../account/AccountSettingsPage.d.ts | 9 + .../account/AccountSettingsPage.js | 9 + .../clinician/ClinicCreationPage.d.ts | 55 ++ .../clinician/ClinicCreationPage.js | 81 +++ .../clinician/ClinicianDashboardPage.d.ts | 46 ++ .../clinician/ClinicianDashboardPage.js | 77 +++ .../clinician/ClinicianNavigation.d.ts | 20 + .../clinician/ClinicianNavigation.js | 116 +++++ .../clinician/WorkspaceSettingsPage.d.ts | 18 + .../clinician/WorkspaceSettingsPage.js | 26 + .../clinician/WorkspacesPage.d.ts | 16 + dist/page-objects/clinician/WorkspacesPage.js | 30 ++ .../components/navigation-menu.section.d.ts | 16 + .../components/navigation-menu.section.js | 24 + .../components/navigation.section.d.ts | 14 + .../components/navigation.section.js | 16 + dist/page-objects/patient/BasicsPage.d.ts | 58 +++ dist/page-objects/patient/BasicsPage.js | 138 +++++ dist/page-objects/patient/DailyPage.d.ts | 11 + dist/page-objects/patient/DailyPage.js | 11 + .../patient/PatientNavigation.d.ts | 13 + .../page-objects/patient/PatientNavigation.js | 97 ++++ dist/page-objects/patient/ProfilePage.d.ts | 22 + dist/page-objects/patient/ProfilePage.js | 111 ++++ .../patient/components/daily-chart.d.ts | 11 + .../patient/components/daily-chart.js | 11 + dist/playwright.config.d.ts | 2 + dist/playwright.config.js | 108 ++++ .../claimed-profile-edit-fullname.spec.d.ts | 1 + .../claimed-profile-edit-fullname.spec.js | 146 ++++++ ...omprehensive-profile-access-test.spec.d.ts | 1 + .../comprehensive-profile-access-test.spec.js | 124 +++++ .../API-User/claimed-email-edit.spec.d.ts | 1 + .../API-User/claimed-email-edit.spec.js | 93 ++++ .../edit-custodial-profile-API.spec.d.ts | 1 + .../edit-custodial-profile-API.spec.js | 89 ++++ dist/tests/clinician/add-patient.spec.d.ts | 1 + dist/tests/clinician/add-patient.spec.js | 33 ++ .../create-clinic-workspace.spec.d.ts | 1 + .../clinician/create-clinic-workspace.spec.js | 81 +++ .../clinician/edit-clinic-address.spec.d.ts | 1 + .../clinician/edit-clinic-address.spec.js | 42 ++ dist/tests/clinician/filter-patient.spec.d.ts | 1 + dist/tests/clinician/filter-patient.spec.js | 65 +++ dist/tests/fixtures/account-helpers.d.ts | 20 + dist/tests/fixtures/account-helpers.js | 84 +++ dist/tests/fixtures/base.d.ts | 23 + dist/tests/fixtures/base.js | 219 ++++++++ dist/tests/fixtures/clinic-helpers.d.ts | 61 +++ dist/tests/fixtures/clinic-helpers.js | 274 ++++++++++ dist/tests/fixtures/network-helpers.d.ts | 112 ++++ dist/tests/fixtures/network-helpers.js | 442 ++++++++++++++++ dist/tests/fixtures/patient-helpers.d.ts | 18 + dist/tests/fixtures/patient-helpers.js | 477 +++++++++++++++++ dist/tests/fixtures/test-tags.d.ts | 60 +++ dist/tests/fixtures/test-tags.js | 93 ++++ dist/tests/global-setup.d.ts | 2 + dist/tests/global-setup.js | 41 ++ .../edit-personal-profile-API.spec.d.ts | 1 + .../edit-personal-profile-API.spec.js | 73 +++ .../personal/basic-functionality.spec.d.ts | 1 + .../personal/basic-functionality.spec.js | 235 +++++++++ dist/tests/personal/login.spec.d.ts | 1 + dist/tests/personal/login.spec.js | 61 +++ dist/utilities/annotations.d.ts | 15 + dist/utilities/annotations.js | 21 + dist/utilities/env.d.ts | 17 + dist/utilities/env.js | 37 ++ dist/utilities/xray-json-reporter.d.ts | 93 ++++ dist/utilities/xray-json-reporter.js | 263 ++++++++++ dist/utilities/xray-reporter.d.ts | 44 ++ dist/utilities/xray-reporter.js | 129 +++++ docs/XRAY_INTEGRATION.md | 166 ++++++ endpoint-schema/auth-endpoints.ts | 18 +- endpoint-schema/endpoint-registry.ts | 4 +- endpoint-schema/patient-data-endpoints.ts | 18 +- package.json | 2 + page-objects/account/AccountSettingsPage.ts | 3 + page-objects/patient/PatientNavigation.ts | 2 +- page-objects/patient/ProfilePage.ts | 36 +- playwright.config.ts | 3 + .../claimed-profile-edit-fullname.spec.ts | 91 ++-- .../comprehensive-profile-access-test.spec.ts | 4 +- .../API-User/claimed-email-edit.spec.ts | 71 +-- tests/fixtures/account-helpers.ts | 8 +- tests/fixtures/base.ts | 2 +- tests/fixtures/network-helpers.ts | 67 +-- tests/fixtures/patient-helpers.ts | 216 ++++---- tests/fixtures/test-tags.ts | 2 +- .../edit-personal-profile-API.spec.ts | 1 - tsconfig.json | 4 +- utilities/upload-to-xray.js | 36 ++ utilities/xray-json-reporter.ts | 365 +++++++++++++ 148 files changed, 10771 insertions(+), 269 deletions(-) create mode 100644 build/endpoint-schema/auth-endpoints.js create mode 100644 build/endpoint-schema/endpoint-registry.js create mode 100644 build/endpoint-schema/patient-data-endpoints.js create mode 100644 build/endpoint-schema/profile-endpoints.js create mode 100644 build/page-objects/LoginPage.js create mode 100644 build/page-objects/account/AccountNavigation.js create mode 100644 build/page-objects/account/AccountSettingsPage.js create mode 100644 build/page-objects/clinician/ClinicCreationPage.js create mode 100644 build/page-objects/clinician/ClinicianDashboardPage.js create mode 100644 build/page-objects/clinician/ClinicianNavigation.js create mode 100644 build/page-objects/clinician/WorkspaceSettingsPage.js create mode 100644 build/page-objects/clinician/WorkspacesPage.js create mode 100644 build/page-objects/clinician/components/navigation-menu.section.js create mode 100644 build/page-objects/clinician/components/navigation.section.js create mode 100644 build/page-objects/patient/BasicsPage.js create mode 100644 build/page-objects/patient/DailyPage.js create mode 100644 build/page-objects/patient/PatientNavigation.js create mode 100644 build/page-objects/patient/ProfilePage.js create mode 100644 build/page-objects/patient/components/daily-chart.js create mode 100644 build/playwright.config.js create mode 100644 build/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js create mode 100644 build/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js create mode 100644 build/tests/claimed/API-User/claimed-email-edit.spec.js create mode 100644 build/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js create mode 100644 build/tests/clinician/add-patient.spec.js create mode 100644 build/tests/clinician/create-clinic-workspace.spec.js create mode 100644 build/tests/clinician/edit-clinic-address.spec.js create mode 100644 build/tests/clinician/filter-patient.spec.js create mode 100644 build/tests/fixtures/account-helpers.js create mode 100644 build/tests/fixtures/base.js create mode 100644 build/tests/fixtures/clinic-helpers.js create mode 100644 build/tests/fixtures/network-helpers.js create mode 100644 build/tests/fixtures/patient-helpers.js create mode 100644 build/tests/fixtures/test-tags.js create mode 100644 build/tests/global-setup.js create mode 100644 build/tests/personal/AP-Profile/edit-personal-profile-API.spec.js create mode 100644 build/tests/personal/basic-functionality.spec.js create mode 100644 build/tests/personal/login.spec.js create mode 100644 build/utilities/annotations.js create mode 100644 build/utilities/env.js create mode 100644 build/utilities/xray-json-reporter.js create mode 100644 build/utilities/xray-reporter.js create mode 100644 dist/endpoint-schema/auth-endpoints.d.ts create mode 100644 dist/endpoint-schema/auth-endpoints.js create mode 100644 dist/endpoint-schema/endpoint-registry.d.ts create mode 100644 dist/endpoint-schema/endpoint-registry.js create mode 100644 dist/endpoint-schema/patient-data-endpoints.d.ts create mode 100644 dist/endpoint-schema/patient-data-endpoints.js create mode 100644 dist/endpoint-schema/profile-endpoints.d.ts create mode 100644 dist/endpoint-schema/profile-endpoints.js create mode 100644 dist/page-objects/LoginPage.d.ts create mode 100644 dist/page-objects/LoginPage.js create mode 100644 dist/page-objects/account/AccountNavigation.d.ts create mode 100644 dist/page-objects/account/AccountNavigation.js create mode 100644 dist/page-objects/account/AccountSettingsPage.d.ts create mode 100644 dist/page-objects/account/AccountSettingsPage.js create mode 100644 dist/page-objects/clinician/ClinicCreationPage.d.ts create mode 100644 dist/page-objects/clinician/ClinicCreationPage.js create mode 100644 dist/page-objects/clinician/ClinicianDashboardPage.d.ts create mode 100644 dist/page-objects/clinician/ClinicianDashboardPage.js create mode 100644 dist/page-objects/clinician/ClinicianNavigation.d.ts create mode 100644 dist/page-objects/clinician/ClinicianNavigation.js create mode 100644 dist/page-objects/clinician/WorkspaceSettingsPage.d.ts create mode 100644 dist/page-objects/clinician/WorkspaceSettingsPage.js create mode 100644 dist/page-objects/clinician/WorkspacesPage.d.ts create mode 100644 dist/page-objects/clinician/WorkspacesPage.js create mode 100644 dist/page-objects/clinician/components/navigation-menu.section.d.ts create mode 100644 dist/page-objects/clinician/components/navigation-menu.section.js create mode 100644 dist/page-objects/clinician/components/navigation.section.d.ts create mode 100644 dist/page-objects/clinician/components/navigation.section.js create mode 100644 dist/page-objects/patient/BasicsPage.d.ts create mode 100644 dist/page-objects/patient/BasicsPage.js create mode 100644 dist/page-objects/patient/DailyPage.d.ts create mode 100644 dist/page-objects/patient/DailyPage.js create mode 100644 dist/page-objects/patient/PatientNavigation.d.ts create mode 100644 dist/page-objects/patient/PatientNavigation.js create mode 100644 dist/page-objects/patient/ProfilePage.d.ts create mode 100644 dist/page-objects/patient/ProfilePage.js create mode 100644 dist/page-objects/patient/components/daily-chart.d.ts create mode 100644 dist/page-objects/patient/components/daily-chart.js create mode 100644 dist/playwright.config.d.ts create mode 100644 dist/playwright.config.js create mode 100644 dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.d.ts create mode 100644 dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js create mode 100644 dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.d.ts create mode 100644 dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js create mode 100644 dist/tests/claimed/API-User/claimed-email-edit.spec.d.ts create mode 100644 dist/tests/claimed/API-User/claimed-email-edit.spec.js create mode 100644 dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.d.ts create mode 100644 dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js create mode 100644 dist/tests/clinician/add-patient.spec.d.ts create mode 100644 dist/tests/clinician/add-patient.spec.js create mode 100644 dist/tests/clinician/create-clinic-workspace.spec.d.ts create mode 100644 dist/tests/clinician/create-clinic-workspace.spec.js create mode 100644 dist/tests/clinician/edit-clinic-address.spec.d.ts create mode 100644 dist/tests/clinician/edit-clinic-address.spec.js create mode 100644 dist/tests/clinician/filter-patient.spec.d.ts create mode 100644 dist/tests/clinician/filter-patient.spec.js create mode 100644 dist/tests/fixtures/account-helpers.d.ts create mode 100644 dist/tests/fixtures/account-helpers.js create mode 100644 dist/tests/fixtures/base.d.ts create mode 100644 dist/tests/fixtures/base.js create mode 100644 dist/tests/fixtures/clinic-helpers.d.ts create mode 100644 dist/tests/fixtures/clinic-helpers.js create mode 100644 dist/tests/fixtures/network-helpers.d.ts create mode 100644 dist/tests/fixtures/network-helpers.js create mode 100644 dist/tests/fixtures/patient-helpers.d.ts create mode 100644 dist/tests/fixtures/patient-helpers.js create mode 100644 dist/tests/fixtures/test-tags.d.ts create mode 100644 dist/tests/fixtures/test-tags.js create mode 100644 dist/tests/global-setup.d.ts create mode 100644 dist/tests/global-setup.js create mode 100644 dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.d.ts create mode 100644 dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.js create mode 100644 dist/tests/personal/basic-functionality.spec.d.ts create mode 100644 dist/tests/personal/basic-functionality.spec.js create mode 100644 dist/tests/personal/login.spec.d.ts create mode 100644 dist/tests/personal/login.spec.js create mode 100644 dist/utilities/annotations.d.ts create mode 100644 dist/utilities/annotations.js create mode 100644 dist/utilities/env.d.ts create mode 100644 dist/utilities/env.js create mode 100644 dist/utilities/xray-json-reporter.d.ts create mode 100644 dist/utilities/xray-json-reporter.js create mode 100644 dist/utilities/xray-reporter.d.ts create mode 100644 dist/utilities/xray-reporter.js create mode 100644 docs/XRAY_INTEGRATION.md create mode 100644 utilities/upload-to-xray.js create mode 100644 utilities/xray-json-reporter.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index a08587f..dd9d9a6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -146,15 +146,15 @@ jobs: command: node utilities/browserstackEvidenceDownload.js when: always - run: - name: Get API token - command: | - echo export token=$(curl -H "Content-Type: application/json" -X POST --data "{ \"client_id\": \"$CLIENT_ID\",\"client_secret\": \"$CLIENT_SECRET\" }" https://xray.cloud.getxray.app/api/v1/authenticate| tr -d '"') >> $BASH_ENV - source $BASH_ENV + name: Build TypeScript utilities + command: npm run build when: always - run: - name: Send Results to XRAY - command: 'curl -H "Content-Type: text/xml" -H "Authorization: Bearer $token" --data @test-output/test-results.xml "https://xray.cloud.getxray.app/api/v1/import/execution/junit?testExecKey=<< pipeline.parameters.testExecKey >>"' + name: Upload Results to Xray (JSON) + command: node utilities/upload-to-xray.js test-results/last-run.json when: always + environment: + TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> - run: name: Add Test Evidence to JIRA command: node utilities/sendTestEvidenceToJira.js @@ -273,15 +273,15 @@ jobs: command: node utilities/browserstackEvidenceDownload.js when: always - run: - name: Get API token - command: | - echo export token=$(curl -H "Content-Type: application/json" -X POST --data "{ \"client_id\": \"$CLIENT_ID\",\"client_secret\": \"$CLIENT_SECRET\" }" https://xray.cloud.getxray.app/api/v1/authenticate| tr -d '"') >> $BASH_ENV - source $BASH_ENV + name: Build TypeScript utilities + command: npm run build when: always - run: - name: Send Results to XRAY - command: 'curl -H "Content-Type: text/xml" -H "Authorization: Bearer $token" --data @test-output/test-results.xml "https://xray.cloud.getxray.app/api/v1/import/execution/junit?testExecKey=<< pipeline.parameters.testExecKey >>"' + name: Upload Results to Xray (JSON) + command: node utilities/upload-to-xray.js test-results/last-run.json when: always + environment: + TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> - run: name: Add Test Evidence to JIRA command: node utilities/sendTestEvidenceToJira.js diff --git a/build/endpoint-schema/auth-endpoints.js b/build/endpoint-schema/auth-endpoints.js new file mode 100644 index 0000000..aa3c6ec --- /dev/null +++ b/build/endpoint-schema/auth-endpoints.js @@ -0,0 +1,53 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.refreshTokenSchema = exports.logoutSchema = exports.loginSchema = void 0; +/** + * Schema for user authentication login + */ +exports.loginSchema = { + url: /\/auth\/login$/, + method: 'POST', + expectedStatus: 200, + requestSchema: { + username: 'string', + password: 'string', + }, + responseSchema: { + userid: 'string', + username: 'string', + emails: 'object', + roles: 'object', + }, + validationFields: ['userid', 'username', 'emails', 'roles'], + requiredFields: [ + 'userid', // Auth endpoints require userid instead of fullName + 'username', // Username is also critical for auth + ], +}; +/** + * Schema for user logout + */ +exports.logoutSchema = { + url: /\/auth\/logout$/, + method: 'POST', + expectedStatus: 200, + validationFields: [ + // Logout typically doesn't return data to validate + ], +}; +/** + * Schema for token refresh + */ +exports.refreshTokenSchema = { + url: /\/auth\/token$/, + method: 'POST', + expectedStatus: 200, + responseSchema: { + userid: 'string', + username: 'string', + }, + validationFields: ['userid', 'username'], + requiredFields: [ + 'userid', // Token refresh must return userid + ], +}; diff --git a/build/endpoint-schema/endpoint-registry.js b/build/endpoint-schema/endpoint-registry.js new file mode 100644 index 0000000..d608347 --- /dev/null +++ b/build/endpoint-schema/endpoint-registry.js @@ -0,0 +1,52 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ENDPOINT_REGISTRY = void 0; +exports.getEndpointSchema = getEndpointSchema; +const profile_endpoints_1 = require("./profile-endpoints"); +const patient_data_endpoints_1 = require("./patient-data-endpoints"); +const auth_endpoints_1 = require("./auth-endpoints"); +// Import other endpoint schemas as they're created +/** + * Centralized endpoint registry for all API validation + * This allows network helpers to work with any endpoint by name + * + * ADDING NEW ENDPOINTS: + * 1. Define the endpoint schema in the appropriate *-endpoints.ts file + * 2. Include validationFields array for data consistency checking + * 3. Add the endpoint to this registry + * 4. The validationFields will automatically be used by NetworkHelper methods + * + * VALIDATION FIELDS: + * - Use dot notation for nested fields (e.g., 'patient.fullName') + * - Include all fields that should be validated for data consistency + * - Different endpoints can have different validation requirements + * - Fields are endpoint-specific and stored in the schema definition + */ +exports.ENDPOINT_REGISTRY = { + // Profile endpoints + 'profile-metadata-get': profile_endpoints_1.getProfileMetadataSchema, + 'profile-metadata-put': profile_endpoints_1.putProfileMetadataSchema, + 'profile-patient-data-get': profile_endpoints_1.getPatientDataSchema, + 'profile-metrics-get': profile_endpoints_1.getMetricsSchema, + 'profile-message-notes-get': profile_endpoints_1.getMessageNotesSchema, + // Patient data endpoints + 'patient-data-get': patient_data_endpoints_1.getPatientDataSchema, + 'patient-data-upload': patient_data_endpoints_1.uploadPatientDataSchema, + // Auth endpoints + 'auth-login': auth_endpoints_1.loginSchema, + 'auth-logout': auth_endpoints_1.logoutSchema, + 'auth-refresh-token': auth_endpoints_1.refreshTokenSchema, + // Add more endpoints as needed... + // 'clinic-get': clinicGetSchema, + // 'clinic-update': clinicUpdateSchema, +}; +/** + * Get endpoint schema by name + */ +function getEndpointSchema(endpointName) { + const schema = exports.ENDPOINT_REGISTRY[endpointName]; + if (!schema) { + throw new Error(`Endpoint schema not found: ${endpointName}`); + } + return schema; +} diff --git a/build/endpoint-schema/patient-data-endpoints.js b/build/endpoint-schema/patient-data-endpoints.js new file mode 100644 index 0000000..2443fb0 --- /dev/null +++ b/build/endpoint-schema/patient-data-endpoints.js @@ -0,0 +1,56 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getPatientSettingsSchema = exports.uploadPatientDataSchema = exports.getPatientDataSchema = void 0; +/** + * Schema for patient data GET endpoint + */ +exports.getPatientDataSchema = { + url: /\/v1\/patients\/[^/]+\/data$/, + method: 'GET', + expectedStatus: 200, + responseSchema: { + data: 'object', + meta: { + count: 'number', + size: 'number', + }, + }, + validationFields: ['data', 'meta.count', 'meta.size'], +}; +/** + * Schema for uploading patient data + */ +exports.uploadPatientDataSchema = { + url: /\/v1\/patients\/[^/]+\/data$/, + method: 'POST', + expectedStatus: 201, + requestSchema: { + data: 'object', + deviceId: 'string', + uploadId: 'string', + }, + responseSchema: { + id: 'string', + success: 'boolean', + }, + validationFields: ['id', 'success'], +}; +/** + * Schema for getting patient settings + */ +exports.getPatientSettingsSchema = { + url: /\/v1\/patients\/[^/]+\/settings$/, + method: 'GET', + expectedStatus: 200, + responseSchema: { + bgTarget: { + low: 'number', + high: 'number', + }, + units: { + bg: 'string', + }, + siteChangeSource: 'string', + }, + validationFields: ['bgTarget.low', 'bgTarget.high', 'units.bg', 'siteChangeSource'], +}; diff --git a/build/endpoint-schema/profile-endpoints.js b/build/endpoint-schema/profile-endpoints.js new file mode 100644 index 0000000..0605a5b --- /dev/null +++ b/build/endpoint-schema/profile-endpoints.js @@ -0,0 +1,107 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getMessageNotesSchema = exports.getMetricsSchema = exports.getPatientDataSchema = exports.putProfileMetadataSchema = exports.getProfileMetadataSchema = void 0; +/** + * Schema for profile metadata GET endpoint + */ +exports.getProfileMetadataSchema = { + url: /\/metadata\/.*\/profile$/, + method: 'GET', + expectedStatus: 200, + responseSchema: { + fullName: 'string', + patient: 'object', + }, + validationFields: [ + 'fullName', + 'patient.fullName', + 'patient.birthday', + 'patient.diagnosisDate', + 'patient.diagnosisType', + 'patient.targetDevices', + 'patient.targetTimezone', + 'patient.about', + 'patient.isOtherPerson', + 'patient.mrn', + 'patient.biologicalSex', + 'email', + 'patient.email', + 'patient.emails', + 'emails', + ], + requiredFields: [ + 'fullName', // Profile endpoint must have fullName + ], +}; +/** + * Schema for profile metadata PUT endpoint + */ +exports.putProfileMetadataSchema = { + url: /\/metadata\/.*\/profile$/, + method: 'PUT', + expectedStatus: 200, + requestSchema: { + fullName: 'string', + patient: 'object', + }, + responseSchema: { + fullName: 'string', + patient: 'object', + }, + validationFields: [ + 'fullName', + 'patient.fullName', + 'patient.birthday', + 'patient.diagnosisDate', + 'patient.diagnosisType', + 'patient.targetDevices', + 'patient.targetTimezone', + 'patient.about', + 'patient.isOtherPerson', + 'patient.mrn', + 'patient.biologicalSex', + 'email', + 'patient.email', + 'patient.emails', + 'emails', + ], + requiredFields: [ + 'fullName', // Profile endpoint must have fullName + ], +}; +/** + * Schema for patient data GET endpoint + */ +exports.getPatientDataSchema = { + url: /\/data\/[^/]+\?.*$/, + method: 'GET', + expectedStatus: 200, + responseSchema: { + // Patient data array - structure will vary + }, + validationFields: [ + // Data array validation fields would go here based on specific data types + ], +}; +/** + * Schema for metrics/analytics endpoint + */ +exports.getMetricsSchema = { + url: /\/metrics\/thisuser\/.*$/, + method: 'GET', + expectedStatus: 200, + validationFields: [ + // Metrics-specific validation fields would go here + ], +}; +/** + * Schema for message notes endpoint + */ +exports.getMessageNotesSchema = { + url: /\/message\/notes\/[^/]+\?.*$/, + method: 'GET', + expectedStatus: 200, // We'll handle 404 as acceptable in the validation logic + validationFields: [ + // Message notes validation fields would go here + ], +}; diff --git a/build/page-objects/LoginPage.js b/build/page-objects/LoginPage.js new file mode 100644 index 0000000..bf30499 --- /dev/null +++ b/build/page-objects/LoginPage.js @@ -0,0 +1,44 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +/** + * @class + * @property {Page} page + * @property {Locator} emailInput + * @property {Locator} nextButton + * @property {Locator} passwordInput + * @property {Locator} loginButton + */ +class LoginPage { + /** + * @param {Page} page + */ + constructor(page) { + this.page = page; + this.emailInput = page.getByRole('textbox', { name: 'Email' }); + this.nextButton = page.getByRole('button', { name: 'Next' }); + this.passwordInput = page.getByRole('textbox', { name: 'Password' }); + this.loginButton = page.getByRole('button', { name: 'Log In' }); + } + /** + * Navigate to the login page + * @returns {Promise} + */ + async goto() { + await this.page.goto(`/`); + } + /** + * Login to the application + * @param {string} email - User's email + * @param {string} password - User's password + * @returns {Promise} + */ + // @step("When the user logs in to the application") + async login(email, password) { + await this.emailInput.fill(email); + await this.nextButton.click(); + await this.passwordInput.fill(password); + await this.loginButton.click(); + await this.page.setViewportSize({ width: 1920, height: 1080 }); + } +} +exports.default = LoginPage; diff --git a/build/page-objects/account/AccountNavigation.js b/build/page-objects/account/AccountNavigation.js new file mode 100644 index 0000000..bfc75bc --- /dev/null +++ b/build/page-objects/account/AccountNavigation.js @@ -0,0 +1,62 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class AccountNav { + constructor(page) { + this.page = page; + this.pages = { + AccountNav: { + name: 'AccountNav', + link: page.locator('button#navigation-menu-trigger'), // Use exact ID to identify menu trigger + verifyURL: '', + verifyElement: page + .locator('button.navigation-menu-option') + .filter({ hasText: 'Private Workspace' }), + }, + PrivateWorkspace: { + name: 'PrivateWorkspace', + link: page + .locator('button.navigation-menu-option') + .filter({ hasText: 'Private Workspace' }), + verifyURL: 'workspaces', + verifyElement: page.getByText('View data for:'), + }, + AccountSettings: { + name: 'AccountSettings', + link: page + .locator('#navigationMenu button.navigation-menu-option') + .filter({ hasText: 'Account Settings' }), + verifyURL: 'account', + verifyElement: page.locator('.profile-subnav-title').getByText('Account'), // Target the specific Account title element + }, + ManageWorkspaces: { + name: 'ManageWorkspaces', + link: page + .locator('#navigationMenu button.navigation-menu-option') + .filter({ hasText: 'Manage Workspaces' }), + verifyURL: 'workspaces', + verifyElement: page.getByText('Welcome To Tidepool'), // Should land back on the workspace selection page + }, + Logout: { + name: 'Logout', + link: page + .locator('#navigationMenu button.navigation-menu-option') + .filter({ hasText: 'Logout' }), + verifyURL: 'login', + verifyElement: page.getByRole('heading', { name: 'Log in to Tidepool' }), + }, + }; + } + /** + * Navigate to a page in the account navigation menu by key. + * Example: await accountNav.navigateTo('AccountSettings'); + */ + async navigateTo(pageKey) { + // Always open the navigation menu first + await this.pages.AccountNav.link.click(); + // Then click the desired page + await this.pages[pageKey].link.click(); + // Wait for the verification element to appear + await this.pages[pageKey].verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + } +} +exports.default = AccountNav; diff --git a/build/page-objects/account/AccountSettingsPage.js b/build/page-objects/account/AccountSettingsPage.js new file mode 100644 index 0000000..a3d10e5 --- /dev/null +++ b/build/page-objects/account/AccountSettingsPage.js @@ -0,0 +1,13 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AccountSettingsPage = void 0; +class AccountSettingsPage { + constructor(page) { + this.page = page; + this.emailInput = page.getByRole('textbox', { name: 'Email' }); + this.saveButton = page.getByRole('button', { name: /save/i }); + this.saveConfirm = page.getByText(/All Changes Saved/i); + } +} +exports.AccountSettingsPage = AccountSettingsPage; +exports.default = AccountSettingsPage; diff --git a/build/page-objects/clinician/ClinicCreationPage.js b/build/page-objects/clinician/ClinicCreationPage.js new file mode 100644 index 0000000..e162e1b --- /dev/null +++ b/build/page-objects/clinician/ClinicCreationPage.js @@ -0,0 +1,84 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class ClinicCreationPage { + constructor(page) { + this.url = '/clinic-details/new'; + this.page = page; + // Page header elements + this.pageHeader = page.getByText('Create your Clinic Workspace'); + this.pageDescription = page.getByText('The information below will be displayed along with your name'); + // Form input fields + this.clinicNameInput = page.getByLabel('Clinic Name'); + this.teamTypeDropdown = page.getByRole('combobox', { name: 'What best describes your team?' }); + this.countryDropdown = page.getByRole('combobox', { name: 'Country' }); + this.stateDropdown = page.getByRole('combobox', { name: 'State' }); + this.addressInput = page.getByLabel('Address'); + this.cityInput = page.getByLabel('City'); + this.zipCodeInput = page.getByLabel('Zip code'); + this.websiteInput = page.getByLabel('Website (optional)'); + // Blood glucose units radio buttons + this.mgdlRadio = page.getByLabel('mg/dL'); + this.mmolRadio = page.getByLabel('mmol/L'); + // Acknowledgement checkbox + this.adminAcknowledgeCheckbox = page.getByRole('checkbox', { + name: 'By creating this clinic, your Tidepool account will become the default administrator', + }); + // Action buttons + this.backButton = page.getByRole('button', { name: 'Back' }); + this.createWorkspaceButton = page.getByRole('button', { name: 'Create Workspace' }); + } + /** + * Navigate to the clinic creation page + */ + async goto() { + await this.page.goto(this.url); + } + /** + * Fill the clinic creation form with required information + * @param clinicName - Name of the clinic + * @param teamType - Type of the team + * @param state - State (for US clinics) + * @param address - Street address + * @param city - City name + * @param zipCode - Zip/Postal code + * @param website - Optional website URL + */ + async fillClinicForm({ clinicName, teamType = 'Provider Practice', state = 'California', address = '123 Test Street', city = 'Test City', zipCode = '12345', website = '', }) { + // Fill in clinic name + await this.clinicNameInput.fill(clinicName); + // Select team type + await this.teamTypeDropdown.selectOption(teamType); + // Select state (US is selected by default) + await this.stateDropdown.selectOption(state); + // Fill in address details + await this.addressInput.fill(address); + await this.cityInput.fill(city); + await this.zipCodeInput.fill(zipCode); + // Fill in optional website if provided + if (website) { + await this.websiteInput.fill(website); + } + } + /** + * Select blood glucose units + * @param unit - "mg/dL" or "mmol/L" + */ + async selectBloodGlucoseUnit(unit) { + if (unit === 'mg/dL') { + await this.mgdlRadio.check(); + } + else { + await this.mmolRadio.check(); + } + } + /** + * Create a clinic by filling the form and submitting + * @param clinicName - Name of the clinic to create (required) + * @param formData - Optional form data (uses defaults if not provided) + */ + async createClinic(clinicName, formData) { + await this.fillClinicForm({ clinicName, ...formData }); + await this.createWorkspaceButton.click(); + } +} +exports.default = ClinicCreationPage; diff --git a/build/page-objects/clinician/ClinicianDashboardPage.js b/build/page-objects/clinician/ClinicianDashboardPage.js new file mode 100644 index 0000000..01edc05 --- /dev/null +++ b/build/page-objects/clinician/ClinicianDashboardPage.js @@ -0,0 +1,79 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class ClinicianDashboardPage { + constructor(page) { + this.url = '/clinic-workspace'; + this.name = 'ClinicianDashboardPage'; // Added name for step decorator context + this.page = page; + // Main page locators + this.addNewPatientButton = page.getByRole('button', { name: 'Add New Patient' }); + this.searchInput = page.getByRole('textbox', { name: 'Search' }); + this.patientListTable = page.getByRole('table', { name: 'peopletablelabel' }); + // Add Patient Dialog locators + this.addPatientDialog = page.getByRole('dialog', { name: /Add New Patient Account/i }); + this.addPatientDialog_fullNameInput = this.addPatientDialog.getByRole('textbox', { + name: 'Full Name', + }); + this.addPatientDialog_birthdateInput = this.addPatientDialog.getByRole('textbox', { + name: 'Birthdate', + }); + this.addPatientDialog_addButton = this.addPatientDialog.getByRole('button', { + name: 'Add Patient', + }); + // Bring Data Dialog locators + this.bringDataDialog = page.getByRole('dialog', { name: /Bring Data into Tidepool/i }); + this.bringDataDialog_doneButton = this.bringDataDialog.getByRole('button', { name: 'Done' }); + } + /** + * Opens the Add Patient dialog and fills in the patient details. + * @param name - The full name of the patient. + * @param birthdate - The birthdate of the patient (e.g., MM/DD/YYYY). + */ + async openAndFillAddPatientDialog(name, birthdate) { + await this.addNewPatientButton.click(); + await this.addPatientDialog.waitFor({ state: 'visible' }); + await this.addPatientDialog_fullNameInput.fill(name); + await this.addPatientDialog_birthdateInput.fill(birthdate); + } + /** + * Clicks the Add Patient button in the dialog to submit the new patient. + */ + async submitAddPatientDialog() { + await this.addPatientDialog_addButton.click(); + } + /** + * Closes the Bring Data into Tidepool dialog by clicking Done. + */ + async closeBringDataDialog() { + await this.bringDataDialog.waitFor({ state: 'visible' }); + await this.bringDataDialog_doneButton.click(); + await this.bringDataDialog.waitFor({ state: 'hidden' }); + } + /** + * Searches for a patient in the list. + * @param name - The name of the patient to search for. + */ + async searchForPatient(name) { + await this.searchInput.fill(name); + // Press Enter to trigger search + await this.searchInput.press('Enter'); + // Wait longer for search to process and results to load + await this.page.waitForTimeout(3000); + } + /** + * Gets the locator for a patient cell in the table by name. + * @param name - The name of the patient. + * @returns Locator for the table cell containing the patient's name. + */ + getPatientCellByName(name) { + // Use exact match to avoid multiple matches with similar names + return this.patientListTable.getByRole('cell', { name, exact: true }); + } + /** + * Waits for the main elements of the Clinic Workspace page to be visible. + */ + async waitForLoadState() { + await this.addNewPatientButton.waitFor({ state: 'visible' }); + } +} +exports.default = ClinicianDashboardPage; diff --git a/build/page-objects/clinician/ClinicianNavigation.js b/build/page-objects/clinician/ClinicianNavigation.js new file mode 100644 index 0000000..7cabb9b --- /dev/null +++ b/build/page-objects/clinician/ClinicianNavigation.js @@ -0,0 +1,119 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class ClinicianNav { + constructor(page) { + this.page = page; + // Define hardcoded workspace configurations (matching PatientNavigation approach) + this.workspaces = { + AdminClinicBase: { + name: 'Admin Clinic (Base)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Admin Clinic (Base) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Admin Clinic (Base)' }), + }, + AdminClinicEnterprise: { + name: 'Admin Clinic (Enterprise)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Admin Clinic (Enterprise) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Admin Clinic (Enterprise)' }), + }, + MemberClinicBase: { + name: 'Member Clinic (Base)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Member Clinic (Base) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Member Clinic (Base)' }), + }, + MemberClinicEnterprise: { + name: 'Member Clinic (Enterprise)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Member Clinic (Enterprise) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Member Clinic (Enterprise)' }), + }, + NonMemberClinicBase: { + name: 'Non-Member Clinic (Base)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Non-Member Clinic (Base) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Non-Member Clinic (Base)' }), + }, + NonMemberClinicEnterprise: { + name: 'Non-Member Clinic (Enterprise)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Non-Member Clinic (Enterprise) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Non-Member Clinic (Enterprise)' }), + }, + PartnerClinicBase: { + name: 'Partner Clinic (Base)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Partner Clinic (Base) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Partner Clinic (Base)' }), + }, + PartnerClinicEnterprise: { + name: 'Partner Clinic (Enterprise)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Partner Clinic (Enterprise) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Partner Clinic (Enterprise)' }), + }, + }; + // Define clinician page navigation (matching PatientNavigation format) + this.pages = { + PatientList: { + name: 'PatientList', + link: page.getByRole('link', { name: 'Patients' }), + verifyURL: 'clinic-workspace/patients', + verifyElement: page.getByRole('heading', { name: 'Patients' }), + }, + WorkspaceSettings: { + name: 'WorkspaceSettings', + link: page.getByRole('link', { name: 'Workspace Settings' }), + verifyURL: 'clinic-workspace/workspace/settings', + verifyElement: page.getByRole('heading', { name: 'Workspace Settings' }), + }, + AddPatient: { + name: 'AddPatient', + link: page.getByRole('button', { name: 'Add Patient' }), + verifyURL: 'clinic-workspace/patients/add', + verifyElement: page.getByRole('heading', { name: 'Add Patient' }), + }, + Profile: { + name: 'Profile', + link: page + .getByRole('button', { name: 'Patient Profile Profile' }) + .or(page.getByRole('tab', { name: 'Profile' })) + .or(page.getByRole('link', { name: 'Profile' })) + .or(page.getByRole('button', { name: 'Profile' })), + verifyURL: 'profile', + verifyElement: page + .getByRole('button', { name: 'Edit' }) + .or(page.getByRole('button', { name: 'Edit Profile' })), + }, + ProfileEdit: { + name: 'ProfileEdit', + link: page + .getByRole('button', { name: 'Edit' }) + .or(page.getByRole('button', { name: 'Edit Profile' })), + verifyURL: 'profile', + verifyElement: page + .getByRole('button', { name: 'Save changes' }) + .or(page.getByRole('button', { name: 'Save Profile' })) + .or(page.getByRole('button', { name: 'Save' })), + }, + }; + } +} +exports.default = ClinicianNav; diff --git a/build/page-objects/clinician/WorkspaceSettingsPage.js b/build/page-objects/clinician/WorkspaceSettingsPage.js new file mode 100644 index 0000000..2dffe7a --- /dev/null +++ b/build/page-objects/clinician/WorkspaceSettingsPage.js @@ -0,0 +1,29 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class ClinicAdminPage { + constructor(page) { + this.url = '/clinic-admin'; + this.name = 'ClinicAdminPage'; // Added name for step decorator context + this.page = page; + this.clinicDetailsHeader = page.getByText('Workspace Settings'); + // Assuming the edit button is specifically associated with the details section + this.editDetailsButton = page.getByRole('button', { name: 'Edit' }); + this.editClinicModal = page.getByRole('dialog'); // General dialog selector + this.editClinicModalTitle = this.editClinicModal.getByRole('heading', { + name: 'Edit Workspace Details', + }); + this.addressInput = this.editClinicModal.getByLabel('Address', { exact: true }); // Use exact label match + this.saveChangesButton = this.editClinicModal.getByRole('button', { name: 'Save Changes' }); + // Assuming the details are within a specific container section related to the header + this.clinicDetailsSection = page.locator('div:has(> span:text-is("Workspace Settings")) + div'); + } + /** + * Waits for essential elements of the Clinic Admin page to be loaded. + */ + async waitForLoadState() { + await this.page.waitForLoadState(); // Wait for base elements like header/footer + await this.clinicDetailsHeader.waitFor({ state: 'visible', timeout: 40000 }); + await this.editDetailsButton.waitFor({ state: 'visible', timeout: 10000 }); + } +} +exports.default = ClinicAdminPage; diff --git a/build/page-objects/clinician/WorkspacesPage.js b/build/page-objects/clinician/WorkspacesPage.js new file mode 100644 index 0000000..38f982f --- /dev/null +++ b/build/page-objects/clinician/WorkspacesPage.js @@ -0,0 +1,36 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const env_1 = __importDefault(require("../../utilities/env")); +class WorkspacesPage { + constructor(page) { + this.url = `${env_1.default.BASE_URL}/workspaces`; + this.page = page; + this.header = page.getByRole('heading', { name: 'Clinic Workspace' }); + this.subHeader = page.getByRole('paragraph', { + name: 'View, share and manage patient data', + }); + this.createClinicButton = page.getByRole('button', { + name: 'Create a New Clinic', + }); + } + async goto() { + await this.page.goto(this.url); + } + async visitFirstClinic() { + await this.page.getByRole('button', { name: 'Go To Workspace' }).first().click(); + } + /** + * Visit a clinic by name + * @param clinicName - The name of the clinic to visit + */ + async visitClinic(clinicName) { + // find child element with text and filter by parent element with class + const child = this.page.getByText(clinicName); + const parent = this.page.locator('.workspace-item-clinic').filter({ has: child }); + await parent.getByRole('button', { name: 'Go To Workspace' }).first().click(); + } +} +exports.default = WorkspacesPage; diff --git a/build/page-objects/clinician/components/navigation-menu.section.js b/build/page-objects/clinician/components/navigation-menu.section.js new file mode 100644 index 0000000..7aa1dda --- /dev/null +++ b/build/page-objects/clinician/components/navigation-menu.section.js @@ -0,0 +1,27 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class NavigationMenu { + constructor(page) { + this.page = page; + this.container = page.locator('div#navigation-menu'); + this.buttons = { + trigger: this.container.locator('#navigation-menu-trigger'), + menu: { + privateWorkspace: this.container.getByRole('button', { + name: 'Private Workspace', + }), + accountSettings: this.container.getByRole('button', { + name: 'Account Settings', + }), + logout: this.container.getByRole('button', { name: 'Logout' }), + }, + }; + } + async open() { + await this.buttons.trigger.click(); + } + async close() { + await this.buttons.trigger.click(); + } +} +exports.default = NavigationMenu; diff --git a/build/page-objects/clinician/components/navigation.section.js b/build/page-objects/clinician/components/navigation.section.js new file mode 100644 index 0000000..176d5ff --- /dev/null +++ b/build/page-objects/clinician/components/navigation.section.js @@ -0,0 +1,22 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const navigation_menu_section_1 = __importDefault(require("./navigation-menu.section")); +class NavigationSection { + constructor(page) { + this.page = page; + this.container = page.locator('div#navPatientHeader'); + this.menu = new navigation_menu_section_1.default(page); + this.buttons = { + viewData: this.container.getByRole('button', { name: 'View Data' }), + patientProfile: this.container.getByRole('button', { + name: 'Patient Profile', + }), + share: this.container.getByRole('button', { name: 'Share' }), + uploadData: this.container.getByRole('button', { name: 'Upload Data' }), + }; + } +} +exports.default = NavigationSection; diff --git a/build/page-objects/patient/BasicsPage.js b/build/page-objects/patient/BasicsPage.js new file mode 100644 index 0000000..5977251 --- /dev/null +++ b/build/page-objects/patient/BasicsPage.js @@ -0,0 +1,143 @@ +"use strict"; +var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) { + var useValue = arguments.length > 2; + for (var i = 0; i < initializers.length; i++) { + value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); + } + return useValue ? value : void 0; +}; +var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) { + function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; } + var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; + var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null; + var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); + var _, done = false; + for (var i = decorators.length - 1; i >= 0; i--) { + var context = {}; + for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; + for (var p in contextIn.access) context.access[p] = contextIn.access[p]; + context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); }; + var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context); + if (kind === "accessor") { + if (result === void 0) continue; + if (result === null || typeof result !== "object") throw new TypeError("Object expected"); + if (_ = accept(result.get)) descriptor.get = _; + if (_ = accept(result.set)) descriptor.set = _; + if (_ = accept(result.init)) initializers.unshift(_); + } + else if (_ = accept(result)) { + if (kind === "field") initializers.unshift(_); + else descriptor[key] = _; + } + } + if (target) Object.defineProperty(target, contextIn.name, descriptor); + done = true; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const base_1 = require("@fixtures/base"); +const PatientNavigation_1 = __importDefault(require("@pom/patient/PatientNavigation")); +const navigation_section_1 = __importDefault(require("@components/navigation.section")); +function createSection(page, selector) { + const parsedSelector = selector === 'tubing-primes' ? 'siteChanges' : selector; + const container = page.locator(`.Calendar-container-${parsedSelector}`); + return { + container, + firstDayOfData: container.locator(`.Calendar-day--${parsedSelector}.Calendar-day`).first(), + calendarDayhover: { + el: container.locator('.Calendar-day--HOVER'), + async text() { + return container.locator('.Calendar-day--HOVER').locator('.Calendar-weekday').textContent(); + }, + }, + }; +} +/** + * helper function to create a stat object with locators for the container, header, hoverBar, and hoverBarLabel + */ +function createStat(page, selector) { + const container = page.locator(`#Stat--${selector}`); + return { + container, + header: container.locator('[class^="Stat--chartTitleText"]'), + hoverBar: container.locator('.HoverBar'), + hoverBarLabel: container.locator('.HoverBarLabel'), + }; +} +// list of sections in the stats sidebar +const statsSideBarSection = [ + 'timeInRange', + 'readingsInRange', + 'averageGlucose', + 'totalInsulin', + 'carbs', + 'standardDev', + 'coefficientOfVariation', + 'sensorUsage', + 'glucoseManagementIndicator', + 'totalInsulin', + 'averageDailyDose', +]; +let PatientDataBasicsPage = (() => { + var _a; + let _instanceExtraInitializers = []; + let _goto_decorators; + return _a = class PatientDataBasicsPage { + constructor(page) { + this.page = __runInitializers(this, _instanceExtraInitializers); + this.page = page; + this.url = '/patients/data/basics'; + this.emailInput = page.getByRole('textbox', { name: 'Email' }); + this.navigationBar = new navigation_section_1.default(page); + this.navigationSubMenu = new PatientNavigation_1.default(page); + this.headerBgReading = page.getByRole('heading', { name: 'BG readings' }); + this.headerBolusing = page.getByRole('heading', { name: 'Bolusing' }); + this.statsSidebar = { + toggleContainer: page.locator('.toggle-container'), + async toggleTo(toState) { + const activeToggleState = await page + .locator(".toggle-container span[class*='TwoOptionToggle--active']") + .innerText(); + if (activeToggleState === 'BGM' && toState === 'CGM') { + await this.toggleContainer.click(); + } + else if (activeToggleState === 'CGM' && toState === 'BGM') { + await this.toggleContainer.click(); + } + }, + ...Object.fromEntries(statsSideBarSection.map(stat => [stat, createStat(page, stat)])), + }; + // charts + this.bgReadingsSection = createSection(page, 'fingersticks'); + this.bolusingSection = createSection(page, 'boluses'); + this.tubingPrimeSection = { + ...createSection(page, 'tubing-primes'), + settings: page.locator('.SiteChangeSelector-option').first(), + settingsOption: { + fillTubing: page.getByLabel('Tubing Fill'), + fillCannula: page.getByLabel('Cannula Fill'), + }, + tubingIcons: page.locator('.Change--tubing').first(), + cannulaIcons: page.locator('.Change--cannula').first(), + filledDay: createSection(page, 'tubing-primes') + .container.locator('.Calendar-day') + .filter({ has: page.locator('.Change-daysSince-text') }) + .first(), + }; + this.basalsSection = createSection(page, 'basals'); + } + async goto() { + await this.page.goto(this.url); + } + }, + (() => { + const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0; + _goto_decorators = [(0, base_1.step)('Navigate to the basics page')]; + __esDecorate(_a, null, _goto_decorators, { kind: "method", name: "goto", static: false, private: false, access: { has: obj => "goto" in obj, get: obj => obj.goto }, metadata: _metadata }, null, _instanceExtraInitializers); + if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata }); + })(), + _a; +})(); +exports.default = PatientDataBasicsPage; diff --git a/build/page-objects/patient/DailyPage.js b/build/page-objects/patient/DailyPage.js new file mode 100644 index 0000000..eb0ad4e --- /dev/null +++ b/build/page-objects/patient/DailyPage.js @@ -0,0 +1,17 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const daily_chart_js_1 = __importDefault(require("@components/daily-chart.js")); +const PatientNavigation_js_1 = __importDefault(require("@pom/patient/PatientNavigation.js")); +const navigation_section_js_1 = __importDefault(require("@components/navigation.section.js")); +class PatientDataDailyPage { + constructor(page) { + this.page = page; + this.navigationBar = new navigation_section_js_1.default(page); + this.navigationSubMenu = new PatientNavigation_js_1.default(page); + this.dailyChart = new daily_chart_js_1.default(page); + } +} +exports.default = PatientDataDailyPage; diff --git a/build/page-objects/patient/PatientNavigation.js b/build/page-objects/patient/PatientNavigation.js new file mode 100644 index 0000000..cec9e3c --- /dev/null +++ b/build/page-objects/patient/PatientNavigation.js @@ -0,0 +1,100 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class PatientNav { + // currentDate: Locator; + constructor(page) { + this.page = page; + this.pages = { + ViewData: { + name: 'ViewData', + link: page.getByRole('button', { name: 'View Data View' }), + verifyURL: 'data', + verifyElement: page.locator('div.patient-data-subnav-inner'), + }, + Basics: { + name: 'Basics', + link: page.getByRole('link', { name: 'Basics' }), + verifyURL: 'data/basics', + verifyElement: page.locator('.js-basics.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + ChartDateRange: { + name: 'ChartDateRange', + link: page + .locator('button svg, .css-15vjjnj svg, [aria-label*="calendar"], [title*="calendar"]') + .first(), // Calendar icon in blue navigation bar + verifyURL: '', + verifyElement: page.locator('#printDateRangePickerInner').locator('*').first(), // Any content inside the dialog + closeButton: page.getByRole('button', { name: 'close dialog' }), + }, + Daily: { + name: 'Daily', + link: page.getByRole('link', { name: 'Daily' }), + verifyURL: 'data/daily', + verifyElement: page.locator('.js-daily.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + ChartDate: { + name: 'ChartDate', + link: page.locator('#tidelineLabel .css-15vjjnj svg'), // Using the same calendar icon selector + verifyURL: '', + verifyElement: page.getByRole('heading', { name: 'Chart Date' }), + closeButton: page.getByRole('button', { name: 'close dialog' }), + }, + BGLog: { + name: 'BGLog', + link: page.getByRole('link', { name: 'BG Log' }), + verifyURL: 'data/bglog', + verifyElement: page.locator('.js-bgLog.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + Trends: { + name: 'Trends', + link: page.getByRole('link', { name: 'Trends' }), + verifyURL: 'data/trends', + verifyElement: page.locator('.js-trends.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + Devices: { + name: 'Devices', + link: page.getByRole('link', { name: 'Devices' }), + verifyURL: 'data/devices', + verifyElement: page.locator('.js-settings.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + Print: { + name: 'Print', + link: page.getByRole('link', { name: 'Print PDF report Print' }), // Print link from the snapshot + verifyURL: '', + verifyElement: page.getByRole('heading', { name: 'Print Report' }), // Assuming modal title + closeButton: page.getByRole('button', { name: 'close dialog' }), + }, + Profile: { + name: 'Profile', + link: page.getByRole('button', { name: 'Profile Profile' }), + verifyURL: '', + verifyElement: page.getByRole('button', { name: 'Edit' }), // Edit button is visible on profile page + }, + ProfileEdit: { + name: 'ProfileEdit', + link: page.getByRole('button', { name: 'Edit' }), + verifyURL: 'profile', + verifyElement: page.getByRole('button', { name: 'Save changes' }), // Save changes button appears when in edit mode + }, + Share: { + name: 'Share', + link: page.getByRole('button', { name: 'Share Share' }), + verifyURL: 'share', + verifyElement: page.getByRole('heading', { name: 'Access Management' }), + }, + ShareData: { + name: 'ShareData', + link: page.getByRole('button', { name: 'Share Data' }), + verifyURL: 'share/invite', + verifyElement: page.getByRole('heading', { name: 'Share your data' }), + }, + UploadData: { + name: 'UploadData', + link: page.getByRole('button', { name: 'Upload Data Upload' }), + verifyURL: 'upload', + verifyElement: page.getByRole('heading', { name: 'Upload Data' }), + }, + }; + } +} +exports.default = PatientNav; diff --git a/build/page-objects/patient/ProfilePage.js b/build/page-objects/patient/ProfilePage.js new file mode 100644 index 0000000..ef565d8 --- /dev/null +++ b/build/page-objects/patient/ProfilePage.js @@ -0,0 +1,115 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ProfilePage = void 0; +class ProfilePage { + constructor(page) { + this.page = page; + this.fieldLocators = { + fullName: this.page.getByRole('textbox', { name: 'Full name' }), + birthDate: this.page.getByRole('textbox', { name: 'Date of birth' }), + mrn: this.page.getByRole('textbox', { name: 'MRN' }), + diagnosisDate: this.page.getByRole('textbox', { name: 'Date of diagnosis' }), + clinicalNotes: this.page.getByRole('textbox', { name: 'Anything you would like to share' }), + email: this.page.getByRole('textbox', { name: /email/i }), + }; + } + // Generic fill method for text fields + async fillField(field, value) { + const locator = this.fieldLocators[field]; + if (!locator) + throw new Error(`No locator defined for field: ${field}`); + if (await locator.isVisible({ timeout: 3000 }).catch(() => false)) { + await locator.fill(value); + } + else { + throw new Error(`Field '${field}' not found or not visible`); + } + } + // Select a diagnosis type from the dropdown + async selectDiagnosisType(index) { + const diagnosisCombo = this.page.getByRole('combobox', { name: 'Diagnosed as' }); + if (await diagnosisCombo.isVisible({ timeout: 3000 })) { + await diagnosisCombo.selectOption({ index }); + } + } + // Get the current diagnosis index from the dropdown (needed for setting a new diagnosis) + async getCurrentDiagnosisIndex() { + const diagnosisCombo = this.page.getByRole('combobox', { name: 'Diagnosed as' }); + if (await diagnosisCombo.isVisible({ timeout: 3000 })) { + const currentValue = await diagnosisCombo.inputValue(); + const options = await diagnosisCombo.locator('option').all(); + // Find current index by checking option values + for (let i = 0; i < options.length; i++) { + const optionValue = await options[i].getAttribute('value'); + if (optionValue === currentValue) { + return i; + } + } + } + return 1; // Default to 1 if not found + } + // For backwards compatibility, keep these as wrappers (optional) + async fillFullName(name) { + return this.fillField('fullName', name); + } + async fillBirthDate(date) { + return this.fillField('birthDate', date); + } + async fillMRN(mrn) { + return this.fillField('mrn', mrn); + } + async fillDiagnosisDate(date) { + return this.fillField('diagnosisDate', date); + } + async fillClinicalNotes(notes) { + return this.fillField('clinicalNotes', notes); + } + async fillEmail(email) { + return this.fillField('email', email); + } + async saveProfile() { + // Save button locators + const saveButtons = [ + this.page.getByRole('button', { name: 'Save changes' }), + this.page.getByRole('button', { name: 'Save Profile' }), + this.page.getByRole('button', { name: 'Save' }), + ]; + // Wait for the PUT request to complete after clicking save + const saveProfilePromise = this.page.waitForResponse(response => response.url().includes('/metadata/') && + response.url().includes('/profile') && + response.request().method() === 'PUT'); + let clicked = false; + for (const btn of saveButtons) { + if (await btn.isVisible({ timeout: 5000 }).catch(() => false)) { + await btn.click(); + clicked = true; + break; + } + } + if (!clicked) + throw new Error('No save button found'); + // Wait for the PUT request to complete (with timeout) + try { + await saveProfilePromise; + } + catch (error) { + console.log('āš ļø PUT request timeout - continuing anyway'); + } + } + /** + * Checks if the edit button is displayed and validates against expected state + * @param shouldBeVisible - Boolean indicating whether the edit button should be visible + * @throws Error if the actual visibility doesn't match the expected state + */ + async editButtonDisplays(shouldBeVisible) { + const editButton = this.page.getByRole('button', { name: 'Edit' }); + const isEditButtonVisible = await editButton.isVisible({ timeout: 3000 }).catch(() => false); + if (shouldBeVisible && !isEditButtonVisible) { + throw new Error('Edit button should be visible but was not found'); + } + else if (!shouldBeVisible && isEditButtonVisible) { + throw new Error('Edit button should not be visible for this user - security violation!'); + } + } +} +exports.ProfilePage = ProfilePage; diff --git a/build/page-objects/patient/components/daily-chart.js b/build/page-objects/patient/components/daily-chart.js new file mode 100644 index 0000000..5eee722 --- /dev/null +++ b/build/page-objects/patient/components/daily-chart.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class DailyChartSection { + constructor(page) { + this.page = page; + this.container = page.locator('div.patient-data-content'); + this.dayLabel = this.container.locator('text.d3-day-label').filter({ visible: true }); + this.newNote = this.container.locator('image.newNoteIcon'); + this.buttons = { + refresh: this.container.getByRole('button', { name: 'Refresh' }), + }; + } +} +exports.default = DailyChartSection; diff --git a/build/playwright.config.js b/build/playwright.config.js new file mode 100644 index 0000000..2e08ea5 --- /dev/null +++ b/build/playwright.config.js @@ -0,0 +1,113 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const test_1 = require("@playwright/test"); +const node_path_1 = __importDefault(require("node:path")); +const env_1 = __importDefault(require("./utilities/env")); +const xrayOptions = { + embedAnnotationsAsProperties: true, + textContentAnnotations: ['test_description', 'testrun_comment'], + embedAttachmentsAsProperty: 'testrun_evidence', + outputFile: 'test-output/test-results.xml', +}; +// Helper to detect BrowserStack run +const isBrowserStack = Boolean(process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESS_KEY); +function buildBrowserStackEndpoint(testName) { + const caps = { + browser: 'chrome', + browser_version: 'latest', + os: 'os x', + os_version: 'catalina', + name: testName, + build: process.env.CI_BUILD_NUMBER || 'local-run', + 'browserstack.username': process.env.BROWSERSTACK_USERNAME, + 'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY, + }; + return `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify(caps))}`; +} +exports.default = (0, test_1.defineConfig)({ + testDir: './tests', + outputDir: './test-results', // Custom output directory + globalSetup: require.resolve(node_path_1.default.join(__dirname, 'tests/global-setup')), + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + timeout: 60000, + expect: { + toHaveScreenshot: { maxDiffPixelRatio: 0.2 }, + }, + reporter: [ + ['html', { open: 'never', outputFolder: 'playwright-report' }], + ['json', { outputFile: 'test-results/last-run.json' }], + ['junit', xrayOptions], + ['./utilities/xray-json-reporter.ts'], + ], + use: { + baseURL: env_1.default.BASE_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + // Custom test attachment naming + testIdAttribute: 'data-testid', + }, + projects: [ + { + name: 'chromium-personal', + testMatch: '**/personal/**/*.spec.ts', + use: { + ...test_1.devices['Desktop Chrome'], + storageState: 'tests/.auth/personal.json', + headless: false, + }, + }, + { + name: 'chromium-claimed', + testMatch: '**/claimed/**/*.spec.ts', + use: { + ...test_1.devices['Desktop Chrome'], + storageState: 'tests/.auth/claimed.json', + headless: false, + }, + }, + { + name: 'chromium-clinician', + testMatch: '**/clinician/**/*.spec.ts', + use: { + ...test_1.devices['Desktop Chrome'], + storageState: 'tests/.auth/clinician.json', + headless: false, + }, + }, + ...(isBrowserStack + ? [ + { + name: 'bs-chrome-personal', + testMatch: '**/patient/**/*.spec.ts', + use: { + storageState: 'tests/.auth/personal.json', + connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Personal Patient Tests') }, + }, + }, + { + name: 'bs-chrome-claimed', + testMatch: '**/claimed/**/*.spec.ts', + use: { + storageState: 'tests/.auth/claimed.json', + connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Claimed Patient Tests') }, + }, + }, + { + name: 'bs-chrome-clinician', + testMatch: '**/clinician/**/*.spec.ts', + use: { + storageState: 'tests/.auth/clinician.json', + connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Clinician Tests') }, + }, + }, + ] + : []), + ], +}); diff --git a/build/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js b/build/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js new file mode 100644 index 0000000..ba00295 --- /dev/null +++ b/build/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js @@ -0,0 +1,148 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const base_1 = require("../../fixtures/base"); +const patient_helpers_1 = require("../../fixtures/patient-helpers"); +const account_helpers_1 = require("../../fixtures/account-helpers"); +const clinic_helpers_1 = require("../../fixtures/clinic-helpers"); +const network_helpers_1 = require("../../fixtures/network-helpers"); +const test_tags_1 = require("../../fixtures/test-tags"); +const AccountSettingsPage_1 = require("../../../page-objects/account/AccountSettingsPage"); +const ProfilePage_1 = require("../../../page-objects/patient/ProfilePage"); +const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; +const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; +base_1.test.describe('Claimed Account Settings edit (Full Name only) updates Profile endpoint and visually updates for user, clinic, and shared member', () => { + base_1.test.setTimeout(120000); // 2 minute timeout for multi-phase test + let api; + let putCapture; + let newName; // Declare at test level scope + (0, base_1.test)('should allow navigation to account settings, edit full name, and verify profile update for claimed, shared, and clinician users', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.PATIENT, + test_tags_1.TEST_TAGS.CLINICIAN, // Added clinician tag + test_tags_1.TEST_TAGS.CLAIMED, + test_tags_1.TEST_TAGS.SHARED_MEMBER, // Added shared member tag + test_tags_1.TEST_TAGS.API, + test_tags_1.TEST_TAGS.UI, + test_tags_1.TEST_TAGS.HIGH, + test_tags_1.TEST_TAGS.API_PROFILE, + ]), + }, async ({ page }) => { + // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== + // Step 1: Log in to clinician account and setup network capture + await base_1.test.step('Given claimed account has been logged in', async () => { + api = (0, network_helpers_1.createNetworkHelper)(page); + await api.startCapture(); + await page.goto('/data'); + await patient_helpers_1.test.patient.setup(page); + }); + // Step 2: Navigate to account settings + await base_1.test.step('When user navigates to account settings', async () => { + await account_helpers_1.test.account.navigateTo('AccountSettings', page); + }); + // Step 3: GET response is pulled and validated + await base_1.test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Create new acccount settings page for the following test + const accountSettingsPage = new AccountSettingsPage_1.AccountSettingsPage(page); + // Step 4: Change the Full Name field to a new value + await base_1.test.step('When user updates the Full Name field', async () => { + newName = `Claimed User Updated ${Math.floor(Math.random() * 10000)}`; // Remove let declaration + const nameInput = page.getByRole('textbox', { name: /full name/i }); + await nameInput.fill(newName); + }); + // Step 5: Tap the Save button + await base_1.test.step('When user taps the save button', async () => { + await accountSettingsPage.saveButton.click(); + }); + // Step 6: Confirm save changes message displays + await base_1.test.step('Then the save changes message displays', async () => { + await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); + }); + // Step 7: Validate PUT request and save value + await base_1.test.stepNoScreenshot('Then PUT request is validated and name is set to new value', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + putCapture = api + .getCaptures() + .find((req) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) + throw new Error('No PUT /profile request captured'); + if (!putCapture.requestBody || + !putCapture.requestBody.fullName || + putCapture.requestBody.fullName !== newName) { + throw new Error(`PUT request did not set fullName to ${newName}`); + } + }); + // Step 8: Navigate to Profile page + await base_1.test.step('When user navigates to Profile page', async () => { + await patient_helpers_1.test.patient.navigateTo('Profile', page); + }); + // Step 9: Confirm GET request matches the saved PUT request + await base_1.test.stepNoScreenshot('Then GET request matches the saved PUT request', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + // Get all captures and find the LATEST GET request (after the PUT) + const allCaptures = api.getCaptures(); + const putIndex = allCaptures.findIndex(req => req === putCapture); + // Find GET requests that occurred AFTER the PUT request + const laterGetCaptures = allCaptures + .slice(putIndex + 1) + .filter((req) => req.method === 'GET' && req.url.includes('/profile')); + if (laterGetCaptures.length === 0) { + throw new Error('No GET /profile request captured after the PUT request'); + } + // Use the most recent GET request + const getCapture = laterGetCaptures[laterGetCaptures.length - 1]; + if (!getCapture.responseBody || + getCapture.responseBody.fullName !== putCapture.requestBody.fullName) { + console.log('GET response fullName:', getCapture.responseBody.fullName); + console.log('PUT request fullName:', putCapture.requestBody.fullName); + console.log('Total captures:', allCaptures.length); + console.log('PUT index:', putIndex); + console.log('Later GET captures found:', laterGetCaptures.length); + throw new Error('GET response fullName does not match PUT request fullName'); + } + }); + // ========== PHASE 2: SHARED USER VIEWS PROFILE ========== + // Step 10: Switch to shared user authentication and go directly to Profile + await base_1.test.step('When shared user views claimed user profile', async () => { + await account_helpers_1.test.account.switchUser('shared', page); + await page.goto('/data'); + await patient_helpers_1.test.patient.setup(page); + // Wait a moment for the page to stabilize after user switch + await page.waitForTimeout(500); + // Navigate directly to Profile in the same step to avoid redundancy + await patient_helpers_1.test.patient.navigateTo('Profile', page); + }); + // Step 11: Verify Edit button is not present for shared users + await base_1.test.step('Then Edit button should not be present for shared patients', async () => { + const profilePage = new ProfilePage_1.ProfilePage(page); + await profilePage.editButtonDisplays(false); + }); + // Step 12: Validate shared user sees updated profile data + await base_1.test.stepNoScreenshot('Then shared user sees view-only claimed profile data with matching data', async () => { + await api.compareEndpointResponse('profile-metadata-get', putCapture); + }); + // ========== PHASE 3: CLINICIAN VIEWS PROFILE ========== + // Step 13: Switch to clinician user authentication + await base_1.test.step('When clinician accesses patient workspace', async () => { + await account_helpers_1.test.account.switchUser('clinician', page); + await page.goto('/'); + await clinic_helpers_1.test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); + }); + // Step 14: Access the specific claimed patient that was modified by the producer test + await base_1.test.step('When user accesses the claimed patient modified by producer test', async () => { + await clinic_helpers_1.test.clinician.findAndAccessPatientByPartialName(CLAIMED_PATIENT_SEARCH, page); + // Navigate directly to Profile in the same step to avoid redundancy + await clinic_helpers_1.test.clinician.navigateTo('Profile', page); + }); + // Step 15: Verify Edit button is not present for claimed patients viewed by clinicians + await base_1.test.step('Then Edit button should not be present for claimed patients', async () => { + const profilePage = new ProfilePage_1.ProfilePage(page); + await profilePage.editButtonDisplays(false); + }); + // Step 16: Validate clinician sees updated profile data + await base_1.test.stepNoScreenshot('Then clinician sees claimed profile data with matching data and no save access', async () => { + await api.compareEndpointResponse('profile-metadata-get', putCapture); + }); + }); +}); diff --git a/build/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js b/build/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js new file mode 100644 index 0000000..7847f31 --- /dev/null +++ b/build/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js @@ -0,0 +1,159 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +const base_1 = require("../../fixtures/base"); +const patient_helpers_1 = require("../../fixtures/patient-helpers"); +const clinic_helpers_1 = require("../../fixtures/clinic-helpers"); +const account_helpers_1 = require("../../fixtures/account-helpers"); +const network_helpers_1 = require("../../fixtures/network-helpers"); +const test_tags_1 = require("../../fixtures/test-tags"); +const ProfilePage_1 = require("../../../page-objects/patient/ProfilePage"); +const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; +const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; +base_1.test.describe('Comprehensive Profile Access Test: Edit as Claimed, View as Shared and Clinician', () => { + (0, base_1.test)('should edit claimed profile then verify view-only access for shared and clinician users', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.PATIENT, // User Type (required) + test_tags_1.TEST_TAGS.CLINICIAN, // User Type (required) + test_tags_1.TEST_TAGS.CLAIMED, + test_tags_1.TEST_TAGS.SHARED_MEMBER, + test_tags_1.TEST_TAGS.API, // Test Type (required) + test_tags_1.TEST_TAGS.UI, // Test Type (required) + test_tags_1.TEST_TAGS.HIGH, // Priority (required) + test_tags_1.TEST_TAGS.API_PROFILE, // Feature (optional) + ]), + }, async ({ page }) => { + let api; + let producerPutCapture; + // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== + // Step 1: Claimed account has been logged in + await base_1.test.step('Given claimed account has been logged in', async () => { + api = (0, network_helpers_1.createNetworkHelper)(page); + await api.startCapture(); + await page.goto('/data'); + await patient_helpers_1.test.patient.setup(page); + }); + // Step 2: User navigates to Profile page + await base_1.test.step('When user navigates to Profile page', async () => { + await patient_helpers_1.test.patient.navigateTo('Profile', page); + }); + // Step 3: GET response is pulled and validated + await base_1.test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Step 4: Confirm edit button and click it + await base_1.test.step('When user selects Edit button', async () => { + await patient_helpers_1.test.patient.navigateTo('ProfileEdit', page); + }); + // Initialize ProfilePage for steps 4 and 5 + const profilePage = new ProfilePage_1.ProfilePage(page); + // Step 5: Change profile fields (confirmed user access) + await base_1.test.step('When user updates profile fields', async () => { + const testRunId = Math.floor(Math.random() * 10000); + const updatedName = `Claimed User Updated ${testRunId}`; + const birthYear = 1985 + (testRunId % 10); + const diagnosisYear = birthYear + 20; + const birthDate = `01/15/${birthYear}`; + const diagnosisDate = `03/10/${diagnosisYear}`; + // Generate random 15-letter string for clinical notes + const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); + // Get current diagnosis index and calculate next one (1-7, wrapping) + const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); + let nextDiagnosisIndex = currentDiagnosisIndex + 1; + if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { + nextDiagnosisIndex = 1; + } + // Update fields using ProfilePage methods + await profilePage.fillFullName(updatedName); + await profilePage.fillBirthDate(birthDate); + await profilePage.fillDiagnosisDate(diagnosisDate); + await profilePage.selectDiagnosisType(nextDiagnosisIndex); + await profilePage.fillClinicalNotes(randomString); + }); + // Step 6: Save profile edit + await base_1.test.step('When user saves profile changes', async () => { + await profilePage.saveProfile(); + }); + // Step 7: PUT response is validated and saved for comparison + await base_1.test.stepNoScreenshot('Then profile endpoint responds with PUT request consistent with schema', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putSchema = await Promise.resolve().then(() => __importStar(require('../../../endpoint-schema/profile-endpoints'))); + const schema = putSchema.putProfileMetadataSchema; + producerPutCapture = api.getLatestCaptureMatching(schema.method, schema.url); + }); + //= ========= SHARED MEMEBER VIEWS PROFILE ========== + // Step 8: Switch to shared user authentication + await base_1.test.step('When shared user views claimed user profile', async () => { + await account_helpers_1.test.account.switchUser('shared', page); + await page.goto('/data'); + await patient_helpers_1.test.patient.navigateTo('ViewData', page); + }); + // Step 9: Navigate to profile page + await base_1.test.step('When user navigates to Profile page', async () => { + await patient_helpers_1.test.patient.navigateTo('Profile', page); + }); + // Step 10: Confirm edit button is not present + await base_1.test.step('Then Edit button should not be present for shared patients', async () => { + await profilePage.editButtonDisplays(false); + }); + // Step 11: Validate GET response and compare it against the + await base_1.test.stepNoScreenshot('Then shared user sees view-only claimed profile data with matching data', async () => { + await api.compareEndpointResponse('profile-metadata-get', producerPutCapture); + }); + // ========== CLINICIAN VIEWS PROFILE ========== + // Step 12: Switch to clinician authentication and navigate to patient profile + await base_1.test.step('When clinician accesses patient workspace', async () => { + await account_helpers_1.test.account.switchUser('clinician', page); + await page.goto('/'); + await clinic_helpers_1.test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); + }); + // Step 13: Access the specific claimed patient that was modified by the producer test + await base_1.test.step('When user accesses the claimed patient modified by producer test', async () => { + await clinic_helpers_1.test.clinician.findAndAccessPatientByPartialName(CLAIMED_PATIENT_SEARCH, page); + }); + // Step 14: Navigate to profile + await base_1.test.step('When user navigates to Profile page', async () => { + await clinic_helpers_1.test.clinician.navigateTo('Profile', page); + }); + // Step 15: Confirm edit button is not present + await base_1.test.step('Then Edit button should not be present for claimed patients', async () => { + await profilePage.editButtonDisplays(false); + }); + // Step 16: Validate GET response and confirm appropriate permissions + await base_1.test.stepNoScreenshot('Then clinician sees claimed profile data with matching data and no save access', async () => { + await api.compareEndpointResponse('profile-metadata-get', producerPutCapture); + }); + }); +}); diff --git a/build/tests/claimed/API-User/claimed-email-edit.spec.js b/build/tests/claimed/API-User/claimed-email-edit.spec.js new file mode 100644 index 0000000..4076621 --- /dev/null +++ b/build/tests/claimed/API-User/claimed-email-edit.spec.js @@ -0,0 +1,95 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const base_1 = require("../../fixtures/base"); +const patient_helpers_1 = require("../../fixtures/patient-helpers"); +const account_helpers_1 = require("../../fixtures/account-helpers"); +const network_helpers_1 = require("../../fixtures/network-helpers"); +const test_tags_1 = require("../../fixtures/test-tags"); +const AccountSettingsPage_1 = require("../../../page-objects/account/AccountSettingsPage"); +base_1.test.describe('Clinician Account Settings Access', () => { + // API Test cases require this to capture network activity + let api; + (0, base_1.test)('should allow navigation to account settings and capture GET response', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.PATIENT, + test_tags_1.TEST_TAGS.CLAIMED, + test_tags_1.TEST_TAGS.API, + test_tags_1.TEST_TAGS.UI, + test_tags_1.TEST_TAGS.HIGH, + test_tags_1.TEST_TAGS.API_USER, + ]), + }, async ({ page }) => { + // Step 1: Log in to clinician account and setup network capture + await base_1.test.step('Given clinician has been logged in', async () => { + api = (0, network_helpers_1.createNetworkHelper)(page); + await api.startCapture(); + await page.goto('/data'); + await patient_helpers_1.test.patient.setup(page); + }); + // Step 2: Navigate to account settings + await base_1.test.step('When user navigates to account settings', async () => { + await account_helpers_1.test.account.navigateTo('AccountSettings', page); + }); + // Step 3: Validate profile GET response + await base_1.test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema ', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Setup for Account Settings page and previous email for reset + const accountSettingsPage = new AccountSettingsPage_1.AccountSettingsPage(page); + let originalEmail = ''; + // Step 4: Read and change email field to temporary value + await base_1.test.step('When user updates the email field', async () => { + originalEmail = await accountSettingsPage.emailInput.inputValue(); + await accountSettingsPage.emailInput.fill('qa+TempEdit@tidepool.org'); + }); + // Step 5: Tap the save button + await base_1.test.step('When user taps the save button', async () => { + await accountSettingsPage.saveButton.click(); + }); + // Step 6: Confirm save changes message displays + await base_1.test.step('Then the save changes message displays', async () => { + await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); + }); + // Step 7: Validate PUT request and email value + await base_1.test.stepNoScreenshot('Then PUT request is validated and email is set to new value', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putCapture = api + .getCaptures() + .find((req) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) + throw new Error('No PUT /profile request captured'); + if (!putCapture.requestBody || + !putCapture.requestBody.email || + putCapture.requestBody.email !== 'qa+TempEdit@tidepool.org') { + throw new Error('PUT request did not set email to qa+TempEdit@tidepool.org'); + } + }); + // Step 8: Change email field to temporary value + await base_1.test.step('When user sets the email field to the previous value', async () => { + await accountSettingsPage.emailInput.fill(originalEmail); + }); + // Step 9: Tap the save button + await base_1.test.step('When user taps the save button', async () => { + await accountSettingsPage.saveButton.click(); + }); + // Step 10: Confirm save changes message displays + await base_1.test.step('Then the save changes message displays', async () => { + await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); + }); + // Step 7: Validate PUT request and email value + await base_1.test.stepNoScreenshot('Then PUT request is validated and email is set to new value', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putCapture = api + .getCaptures() + .find((req) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) + throw new Error('No PUT /profile request captured'); + if (!putCapture.requestBody || + !putCapture.requestBody.email || + putCapture.requestBody.email !== originalEmail) { + throw new Error('PUT request did not set email to originalEmail'); + } + }); + await api.stopCapture(); + }); +}); diff --git a/build/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js b/build/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js new file mode 100644 index 0000000..d6f79c7 --- /dev/null +++ b/build/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js @@ -0,0 +1,91 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const clinic_helpers_1 = require("../../fixtures/clinic-helpers"); +const network_helpers_1 = require("../../fixtures/network-helpers"); +const test_tags_1 = require("../../fixtures/test-tags"); +const ProfilePage_1 = require("../../../page-objects/patient/ProfilePage"); +clinic_helpers_1.test.describe('Custodial patients are allowed access and modification of profile details', () => { + // Define the workspace and patient at top level + const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; + const CUSTODIAL_PATIENT_SEARCH = 'Custodial Patient'; + // API Test cases require this to capture network activity + let api; + (0, clinic_helpers_1.test)('should allow navigation to profile details and edit profile fields', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.CLINICIAN, // User Type (required) + test_tags_1.TEST_TAGS.API, // Test Type (required) + test_tags_1.TEST_TAGS.UI, // Test Type (required) + test_tags_1.TEST_TAGS.HIGH, // Priority (required) + test_tags_1.TEST_TAGS.API_PROFILE, // Feature (optional) + ]), + }, async ({ page }, testInfo) => { + // Step 1: Log in to clinician account and setup network capture + await clinic_helpers_1.test.step('Given clinician has been logged in', async () => { + api = (0, network_helpers_1.createNetworkHelper)(page); + await api.startCapture(); + await clinic_helpers_1.test.clinician.setup(page); + }); + // Step 2: Navigate to workspace + await clinic_helpers_1.test.step('When user navigates to desired workspace', async () => { + await clinic_helpers_1.test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); + }); + // Step 3: Access custodial patient + await clinic_helpers_1.test.step('When user accesses a custodial patient summary', async () => { + await clinic_helpers_1.test.clinician.findAndAccessPatientByPartialName(CUSTODIAL_PATIENT_SEARCH, page); + }); + // Step 4: Navigate to profile + await clinic_helpers_1.test.step('When user navigates to Profile page', async () => { + await clinic_helpers_1.test.clinician.navigateTo('Profile', page); + }); + // Step 5: Capture GET response + await clinic_helpers_1.test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Step 6: Open Edit Profile + await clinic_helpers_1.test.step('When user selects Edit button', async () => { + await clinic_helpers_1.test.clinician.navigateTo('ProfileEdit', page); + }); + // Create Profile page for following steps + const profilePage = new ProfilePage_1.ProfilePage(page); + // Step 7: Change profile fields (custodial access) + await clinic_helpers_1.test.step('When user updates profile fields', async () => { + // Generate completely unique values for this custodial test run + const randomSeed = Math.random(); + const randomId = Math.floor(randomSeed * 10000); + const updatedName = `Custodial Patient Updated ${Math.floor(randomId * 10000)}`; + const birthYear = 1980 + (randomId % 15); + const diagnosisYear = birthYear + 25; + const birthDate = `05/20/${birthYear}`; + const diagnosisDate = `08/15/${diagnosisYear}`; + // Generate random 15-digit MRN + const randomMRN = Array.from({ length: 15 }, () => Math.floor(Math.random() * 10).toString()).join(''); + // Generate random 15-letter string for clinical notes + const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); + // Generate unique email + const email = `webuiautomation+custodialEdit${randomId}@tidepool.org`; + // Get current diagnosis index and calculate next one (1-7, wrapping) + const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); + let nextDiagnosisIndex = currentDiagnosisIndex + 1; + if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { + nextDiagnosisIndex = 1; + } + // Update fields using ProfilePage methods + await profilePage.fillFullName(updatedName); + await profilePage.fillBirthDate(birthDate); + await profilePage.fillMRN(randomMRN); + await profilePage.fillDiagnosisDate(diagnosisDate); + await profilePage.selectDiagnosisType(nextDiagnosisIndex); + await profilePage.fillEmail(email); + await profilePage.fillClinicalNotes(randomString); + }); + // Step 8: Save profile edit + await clinic_helpers_1.test.step('When user saves profile changes', async () => { + await profilePage.saveProfile(); + }); + // Step 9: Check profile PUT response + await clinic_helpers_1.test.step('Then profile endpoint responds with PUT request consistent with schema [no-screenshot]', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + }); + await api.stopCapture(); + }); +}); diff --git a/build/tests/clinician/add-patient.spec.js b/build/tests/clinician/add-patient.spec.js new file mode 100644 index 0000000..595caf8 --- /dev/null +++ b/build/tests/clinician/add-patient.spec.js @@ -0,0 +1,38 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const base_1 = require("@fixtures/base"); +const ClinicianDashboardPage_1 = __importDefault(require("@pom/clinician/ClinicianDashboardPage")); +const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); +base_1.test.describe('Add new patient', () => { + // Use a unique patient name for each test run to avoid collisions + const patientName = `Test Patient Playwright ${Date.now()}`; + const patientBirthdate = '01/01/1990'; + base_1.test.beforeEach(async () => { + await base_1.test.step('Given user has been logged in and navigated to base URL', async () => { }); + }); + (0, base_1.test)('should successfully add a new patient', async ({ page }) => { + const workspacesPage = new WorkspacesPage_1.default(page); + const clinicWorkspacePage = new ClinicianDashboardPage_1.default(page); + await base_1.test.step('Given the user is on the workspaces page', async () => { + await workspacesPage.goto(); + await workspacesPage.header.waitFor({ state: 'visible' }); + }); + await base_1.test.step('When user selects the first workspace', async () => { + await workspacesPage.visitFirstClinic(); + await clinicWorkspacePage.waitForLoadState(); // Wait for clinic page elements + }); + await base_1.test.step('When user adds a new patient via dialog', async () => { + await clinicWorkspacePage.openAndFillAddPatientDialog(patientName, patientBirthdate); + await clinicWorkspacePage.submitAddPatientDialog(); + await clinicWorkspacePage.closeBringDataDialog(); + }); + await base_1.test.step('Then the new patient should appear in the patient list', async () => { + await clinicWorkspacePage.searchForPatient(patientName); + const patientCell = clinicWorkspacePage.getPatientCellByName(patientName); + await (0, base_1.expect)(patientCell).toBeVisible(); + }); + }); +}); diff --git a/build/tests/clinician/create-clinic-workspace.spec.js b/build/tests/clinician/create-clinic-workspace.spec.js new file mode 100644 index 0000000..c6fd99f --- /dev/null +++ b/build/tests/clinician/create-clinic-workspace.spec.js @@ -0,0 +1,86 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const base_1 = require("@fixtures/base"); +const ClinicCreationPage_1 = __importDefault(require("@pom/clinician/ClinicCreationPage")); +const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); +const node_crypto_1 = require("node:crypto"); +base_1.test.describe('Create clinic workspace', () => { + const uniqueSuffix = (0, node_crypto_1.randomUUID)().substring(0, 8); + const clinicName = `Test Clinic ${uniqueSuffix}`; + let workspacesPage; + let clinicCreationPage; + base_1.test.beforeEach(async ({ page }) => { + workspacesPage = new WorkspacesPage_1.default(page); + clinicCreationPage = new ClinicCreationPage_1.default(page); + }); + (0, base_1.test)('should successfully create a new clinic workspace', async ({ page }) => { + await base_1.test.step('Given user is on the workspaces page', async () => { + await workspacesPage.goto(); + await (0, base_1.expect)(workspacesPage.header).toBeVisible(); + await (0, base_1.expect)(workspacesPage.createClinicButton).toBeVisible(); + }); + await base_1.test.step("When user clicks on the 'Create a New Clinic' button", async () => { + await workspacesPage.createClinicButton.click(); + // Wait for the clinic details page to load + await (0, base_1.expect)(page).toHaveURL(/clinic-details\/new/); + await (0, base_1.expect)(clinicCreationPage.pageHeader).toBeVisible(); + }); + await base_1.test.step('When user fills in all the required clinic information', async () => { + // Fill the clinic form with test data + await clinicCreationPage.fillClinicForm({ + clinicName, + teamType: 'Provider Practice', + state: 'California', + address: '123 Test Street', + city: 'Test City', + zipCode: '12345', + }); + // Verify blood glucose units (mg/dL is pre-selected) + await (0, base_1.expect)(clinicCreationPage.mgdlRadio).toBeChecked(); + // Verify the admin acknowledgment checkbox is checked + await (0, base_1.expect)(clinicCreationPage.adminAcknowledgeCheckbox).toBeChecked(); + // Verify Create Workspace button is enabled + await (0, base_1.expect)(clinicCreationPage.createWorkspaceButton).toBeEnabled(); + }); + await base_1.test.step("When user clicks on the 'Create Workspace' button", async () => { + await clinicCreationPage.createWorkspaceButton.click(); + // Wait for redirect to workspaces page + await (0, base_1.expect)(page).toHaveURL('/workspaces'); + }); + await base_1.test.step('Then user should see the new clinic in the list and a success message', async () => { + // Verify success message is shown + const successMessage = page.getByText(`"${clinicName}" clinic created`); + await (0, base_1.expect)(successMessage).toBeVisible(); + // Verify the new clinic appears in the list + const clinicHeaderLocator = page.getByRole('heading', { name: clinicName }); + await (0, base_1.expect)(clinicHeaderLocator).toBeVisible(); + // Verify the clinic has the necessary action buttons + const clinicContainer = page + .locator('.workspace-item-clinic') + .filter({ has: clinicHeaderLocator }); + await (0, base_1.expect)(clinicContainer.getByRole('button', { name: 'Leave Clinic' })).toBeVisible(); + await (0, base_1.expect)(clinicContainer.getByRole('button', { name: 'Go To Workspace' })).toBeVisible(); + }); + }); + (0, base_1.test)('should create a new clinic with the simplified createClinic method', async ({ page }) => { + // Navigate to the workspaces page + await page.goto('/workspaces'); + await (0, base_1.expect)(workspacesPage.header).toBeVisible(); + // Click the "Create a New Clinic" button + await workspacesPage.createClinicButton.click(); + await (0, base_1.expect)(page).toHaveURL(/clinic-details\/new/); + // Use the simplified method to create a clinic in one step + await clinicCreationPage.createClinic(clinicName); + // Verify we're back on the workspaces page + await (0, base_1.expect)(page).toHaveURL('/workspaces'); + // Verify the clinic was created + const successMessage = page.getByText(`"${clinicName}" clinic created`); + await (0, base_1.expect)(successMessage).toBeVisible(); + // Verify the clinic appears in the list + const clinicHeaderLocator = page.getByRole('heading', { name: clinicName }); + await (0, base_1.expect)(clinicHeaderLocator).toBeVisible(); + }); +}); diff --git a/build/tests/clinician/edit-clinic-address.spec.js b/build/tests/clinician/edit-clinic-address.spec.js new file mode 100644 index 0000000..0f038c1 --- /dev/null +++ b/build/tests/clinician/edit-clinic-address.spec.js @@ -0,0 +1,47 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const base_1 = require("@fixtures/base"); +const WorkspaceSettingsPage_1 = __importDefault(require("@pom/clinician/WorkspaceSettingsPage")); +const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); +base_1.test.describe('Edit clinic address', () => { + const newAddress = `123 Test Street ${Date.now()}`; // Unique address for test run + let clinicAdminPage; + let workspacesPage; + base_1.test.beforeEach(async ({ page }) => { + clinicAdminPage = new WorkspaceSettingsPage_1.default(page); + workspacesPage = new WorkspacesPage_1.default(page); + await base_1.test.step('Given user has navigated to the Clinic Admin page', async () => { + await workspacesPage.goto(); + await workspacesPage.visitFirstClinic(); + await page.goto('/clinic-admin'); + await clinicAdminPage.waitForLoadState(); // Wait for clinic admin page elements + await clinicAdminPage.clinicDetailsHeader.waitFor({ state: 'visible' }); + }); + }); + (0, base_1.test)('should successfully edit the clinic address', async ({ page }) => { + await base_1.test.step('When user clicks the "Edit" button for workspace details', async () => { + await clinicAdminPage.editDetailsButton.click(); + await clinicAdminPage.editClinicModal.waitFor({ state: 'visible' }); + }); + await base_1.test.step('Then user sees the modal for Editing workspace details', async () => { + await (0, base_1.expect)(clinicAdminPage.editClinicModalTitle).toBeVisible(); + await (0, base_1.expect)(clinicAdminPage.addressInput).toBeVisible(); + }); + await base_1.test.step('When user changes the address', async () => { + await clinicAdminPage.addressInput.fill(newAddress); + }); + await base_1.test.step('When user clicks on "Save changes"', async () => { + await clinicAdminPage.saveChangesButton.click(); + await clinicAdminPage.editClinicModal.waitFor({ state: 'hidden' }); // Wait for modal to close + }); + await base_1.test.step('Then user sees the updated address on the page', async () => { + // Wait for the details section to potentially update + await page.waitForTimeout(1000); // Small wait for potential DOM update + const detailsText = clinicAdminPage.clinicDetailsSection; + await (0, base_1.expect)(detailsText).toContainText(newAddress); + }); + }); +}); diff --git a/build/tests/clinician/filter-patient.spec.js b/build/tests/clinician/filter-patient.spec.js new file mode 100644 index 0000000..5032ef2 --- /dev/null +++ b/build/tests/clinician/filter-patient.spec.js @@ -0,0 +1,70 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const base_1 = require("@fixtures/base"); +const ClinicianDashboardPage_1 = __importDefault(require("@pom/clinician/ClinicianDashboardPage")); +const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); +base_1.test.describe('Filter patients in clinic', () => { + // Use unique patient names for each test run + const timestamp = Date.now(); + const patientName1 = `Filter Patient A ${timestamp}`; + const patientName2 = `Filter Patient B ${timestamp}`; + const patientBirthdate = '01/01/1995'; // Shared birthdate for simplicity + let workspacesPage; + let clinicWorkspacePage; + base_1.test.beforeEach(async ({ page }) => { + workspacesPage = new WorkspacesPage_1.default(page); + clinicWorkspacePage = new ClinicianDashboardPage_1.default(page); + await base_1.test.step('Given user has been logged in and navigated to base URL', async () => { + await workspacesPage.goto(); + await page.waitForURL(workspacesPage.url); + await workspacesPage.header.waitFor({ state: 'visible' }); + }); + await base_1.test.step('Given the user is on the first clinic workspace', async () => { + await workspacesPage.visitFirstClinic(); + await clinicWorkspacePage.waitForLoadState(); // Wait for clinic page elements + }); + await base_1.test.step('Given two patients exist', async () => { + // Add first patient + await clinicWorkspacePage.openAndFillAddPatientDialog(patientName1, patientBirthdate); + await clinicWorkspacePage.submitAddPatientDialog(); + await clinicWorkspacePage.closeBringDataDialog(); + // Ensure the first patient is added before adding the second + await (0, base_1.expect)(clinicWorkspacePage.getPatientCellByName(patientName1)).toBeVisible({ + timeout: 10000, + }); + // Add second patient + await clinicWorkspacePage.openAndFillAddPatientDialog(patientName2, patientBirthdate); + await clinicWorkspacePage.submitAddPatientDialog(); + await clinicWorkspacePage.closeBringDataDialog(); + // Ensure the second patient is also added + await (0, base_1.expect)(clinicWorkspacePage.getPatientCellByName(patientName2)).toBeVisible({ + timeout: 10000, + }); + }); + }); + (0, base_1.test)('should successfully filter patients by name', async () => { + await base_1.test.step("When user filters by the first patient's name", async () => { + await clinicWorkspacePage.searchForPatient(patientName1); + }); + await base_1.test.step('Then only the first patient should be visible', async () => { + const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); + const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); + await (0, base_1.expect)(patientCell1).toBeVisible(); + await (0, base_1.expect)(patientCell2).not.toBeVisible(); + }); + await base_1.test.step('When user clears the filter', async () => { + // Assuming a method like clearPatientSearch exists or searchForPatient('') clears + await clinicWorkspacePage.searchForPatient(''); // Clear search by searching for empty string + // Or potentially: await clinicWorkspacePage.clearPatientSearch(); + }); + await base_1.test.step('Then both patients should be visible again', async () => { + const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); + const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); + await (0, base_1.expect)(patientCell1).toBeVisible(); + await (0, base_1.expect)(patientCell2).toBeVisible(); + }); + }); +}); diff --git a/build/tests/fixtures/account-helpers.js b/build/tests/fixtures/account-helpers.js new file mode 100644 index 0000000..4532eef --- /dev/null +++ b/build/tests/fixtures/account-helpers.js @@ -0,0 +1,123 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.test = void 0; +const base_1 = require("@fixtures/base"); +const AccountNavigation_1 = __importDefault(require("@pom/account/AccountNavigation")); +/** + * Switch user authentication context by loading different storageState + * @param userType - The user type corresponding to the storageState file (e.g., 'shared', 'clinician', 'claimed') + * @param page - The Playwright page instance + */ +async function switchUser(userType, page) { + try { + // Import fs dynamically + const fs = await Promise.resolve().then(() => __importStar(require('node:fs'))); + // Load the specified user's storage state + const storageStatePath = `tests/.auth/${userType}.json`; + const storageState = JSON.parse(fs.readFileSync(storageStatePath, 'utf-8')); + // Clear existing cookies first + await page.context().clearCookies(); + // Set cookies from the new user's storage state + if (storageState.cookies) { + await page.context().addCookies(storageState.cookies); + } + // Set localStorage from the new user's storage state + if (storageState.origins) { + for (const origin of storageState.origins) { + await page.addInitScript(originData => { + if (originData.localStorage) { + for (const item of originData.localStorage) { + localStorage.setItem(item.name, item.value); + } + } + }, origin); + } + } + console.log(`āœ… Successfully switched to ${userType} user authentication`); + } + catch (error) { + throw new Error(`Failed to switch to ${userType} user: ${error}`); + } +} +/** + * Core navigation function that handles account navigation consistently + */ +async function navigateTo(targetPage, page) { + const nav = new AccountNavigation_1.default(page); + const pageConfig = nav.pages[targetPage]; + try { + // Single page check at start + if (page.isClosed()) + return; + // Quick DOM ready check only + await page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => { }); + // Open navigation menu if needed (only for non-AccountNav targets) + if (targetPage !== 'AccountNav') { + const menuVisible = await nav.pages.AccountNav.verifyElement + .isVisible({ timeout: 1000 }) + .catch(() => false); + if (!menuVisible) { + await nav.pages.AccountNav.link.click(); + await nav.pages.AccountNav.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + } + } + // Handle logout specially + if (targetPage === 'Logout') { + await pageConfig.link.click(); + await page + .waitForURL(/.*login.*/, { waitUntil: 'domcontentloaded', timeout: 5000 }) + .catch(() => { }); + } + else { + // Standard navigation - click and verify + await pageConfig.link.click(); + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + } + } + catch (error) { + if (!page.isClosed()) + throw error; + } +} +const test = base_1.test; +exports.test = test; +test.account = { + navigateTo, + switchUser, +}; diff --git a/build/tests/fixtures/base.js b/build/tests/fixtures/base.js new file mode 100644 index 0000000..b21e7bc --- /dev/null +++ b/build/tests/fixtures/base.js @@ -0,0 +1,257 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.expect = exports.test = void 0; +exports.step = step; +const test_1 = require("@playwright/test"); +const fs = __importStar(require("node:fs")); +const path = __importStar(require("node:path")); +// Define the test type with custom fixtures +exports.test = test_1.test.extend({ + page: async ({ page }, use, testInfo) => { + const modifiedTestInfo = testInfo; + modifiedTestInfo.snapshotSuffix = ''; + modifiedTestInfo.snapshotPath = name => `${testInfo.file}-snapshots/${name}`; + // Make testInfo globally available for network helpers + globalThis.testInfo = testInfo; + try { + await use(page); + } + finally { + // Clean up after test + delete globalThis.testInfo; + } + }, + timeLogger: [ + async ({ page }, use, testInfo) => { + testInfo.annotations.push({ + type: 'Start', + description: new Date().toISOString(), + }); + await use(page); + testInfo.annotations.push({ + type: 'End', + description: new Date().toISOString(), + }); + }, + { auto: true }, + ], + timeStepLogger: [ + async ({ page }, use, testInfo) => { + const startTime = Date.now(); + console.time(`[test] ${testInfo.title}`); + await use(page); + console.timeEnd(`[test] ${testInfo.title}`); + const endTime = Date.now(); + const duration = endTime - startTime; + testInfo.annotations.push({ + type: 'Duration', + description: `${duration}ms`, + }); + testInfo.annotations.push({ + type: 'End', + description: new Date().toISOString(), + }); + }, + { auto: true }, + ], + stepTimer: [ + async ({ page }, use, testInfo) => { + const originalStep = exports.test.step; + const stepTimings = new Map(); + // Create a new step function with the same interface as the original + const newStep = function newStepWrapper(name, fn) { + return originalStep.call(this, name, async (stepInfo) => { + const startTime = Date.now(); + console.time(`[step] ${name}`); + const result = await fn(stepInfo); + console.timeEnd(`[step] ${name}`); + const endTime = Date.now(); + const duration = endTime - startTime; + stepTimings.set(name, duration); + testInfo.annotations.push({ + type: `Step Duration: ${name}`, + description: `${duration}ms`, + }); + return result; + }); + }; + // Add the skip method to match the original test.step interface + newStep.skip = function skipStep(name, fn) { + return originalStep.skip.call(this, name, fn); + }; + // Replace the original step with our enhanced version + exports.test.step = newStep; + await use(page); + // Restore original test.step + exports.test.step = originalStep; + }, + { auto: true }, + ], + stepScreenshoter: [ + async ({ page }, use, testInfo) => { + const originalStep = exports.test.step; + let stepCounter = 0; + // Create a safe directory name based on test info + const testDirName = path.basename(testInfo.file, '.spec.ts').replace(/[^a-z0-9]/gi, '-'); + const screenshotDir = path.join('test-results', testDirName); + // Store current step name for network helpers + let currentStepName = ''; + // Make step counter accessible globally for network helper + globalThis.__stepCounter = { + get: () => stepCounter, + increment: () => ++stepCounter, + getDirectory: () => screenshotDir, + getCurrentStepName: () => currentStepName, + setCurrentStepName: (name) => { + currentStepName = name; + }, + }; + // Clean up existing screenshots from previous runs + try { + await fs.promises.access(screenshotDir); + await fs.promises.rm(screenshotDir, { recursive: true, force: true }); + } + catch { + // Directory doesn't exist, no need to clean up + } + // Create a new step function that takes screenshots after completion and attaches them to the report + const newStep = function newStepScreenshot(name, fn) { + return originalStep.call(this, name, async (stepInfo) => { + // Set current step name for network helpers (clean name without [no-screenshot]) + const stepCounterObj = globalThis.__stepCounter; + if (stepCounterObj) { + const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); + stepCounterObj.setCurrentStepName(cleanName); + } + const result = await fn(stepInfo); + // Skip screenshot if step name contains [no-screenshot] + if (name.includes('[no-screenshot]')) { + return result; + } + // Take screenshot after step completion + stepCounter += 1; + try { + if (!page.isClosed()) { + // Use clean name for filename (without [no-screenshot]) + const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); + const screenshotName = `step-${stepCounter.toString().padStart(2, '0')}-${cleanName.toLowerCase().replace(/[^a-z0-9]/g, '-')}.png`; + // Take screenshot directly to buffer (no local file) + const screenshot = await page.screenshot({ + fullPage: true, + }); + // Attach to Playwright report AND force test-results folder creation + if (testInfo && typeof testInfo.attach === 'function') { + await testInfo.attach(screenshotName, { + body: screenshot, + contentType: 'image/png', + }); + // Also save to test-results for organized viewing (single source) + const testResultsDir = path.join(testInfo.outputDir, 'attachments'); + await fs.promises.mkdir(testResultsDir, { recursive: true }); + const screenshotPath = path.join(testResultsDir, screenshotName); + await fs.promises.writeFile(screenshotPath, screenshot); + } + } + } + catch (error) { } + return result; + }); + }; + // Add the skip method to match the original test.step interface + newStep.skip = function skipStepScreenshot(name, fn) { + return originalStep.skip.call(this, name, fn); + }; + // Add a custom stepNoScreenshot function for API validation steps + const stepNoScreenshot = function stepNoScreenshot(name, fn) { + return originalStep.call(this, name, async (stepInfo) => { + // Set current step name for network helpers (clean name) + const stepCounterObj = globalThis.__stepCounter; + if (stepCounterObj) { + stepCounterObj.setCurrentStepName(name); + } + const result = await fn(stepInfo); + // No screenshot taken for this step type + // console.log(`ā­ļø API step completed without screenshot: ${name}`); + return result; + }); + }; + // Replace the original step with our enhanced version + exports.test.step = newStep; + // Add the no-screenshot step function to the test object + exports.test.stepNoScreenshot = stepNoScreenshot; + await use(page); + // Restore original test.step + exports.test.step = originalStep; + }, + { auto: true }, + ], + exceptionLogger: [ + async ({ page }, use, testInfo) => { + const errors = []; + page.on('pageerror', (error) => { + errors.push(error); + }); + await use(page); + if (errors.length > 0) { + await testInfo.attach('frontend-exceptions', { + body: errors.map(error => `${error.message}\n${error.stack}`).join('\n---------\n'), + }); + throw new Error('Some frontend exceptions occurred'); + } + }, + { auto: true }, + ], +}); +var test_2 = require("@playwright/test"); +Object.defineProperty(exports, "expect", { enumerable: true, get: function () { return test_2.expect; } }); +/** + * Decorator function for wrapping POM methods in a test.step. + * + * Use it without a step name `@step()`. + * + * Or with a step name `@step("Search something")`. + * + * @param stepName - The name of the test step. + * @returns A decorator function that can be used to decorate test methods. + */ +function step(stepName) { + return function decorator(target, context) { + return function replacementMethod(...args) { + const name = `${stepName || context.name} (${this.name})`; + return exports.test.step(name, async () => await target.call(this, ...args)); + }; + }; +} diff --git a/build/tests/fixtures/clinic-helpers.js b/build/tests/fixtures/clinic-helpers.js new file mode 100644 index 0000000..17b2e56 --- /dev/null +++ b/build/tests/fixtures/clinic-helpers.js @@ -0,0 +1,280 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.test = void 0; +const base_1 = require("@fixtures/base"); +const ClinicianNavigation_1 = __importDefault(require("../../page-objects/clinician/ClinicianNavigation")); +const ClinicianDashboardPage_1 = __importDefault(require("../../page-objects/clinician/ClinicianDashboardPage")); +const AccountNavigation_1 = __importDefault(require("../../page-objects/account/AccountNavigation")); +/** + * Initialize clinician navigation helpers after login + */ +async function setupClinicianSession(page) { + // Wait for clinician navigation to be available + const nav = new ClinicianNavigation_1.default(page); + // Navigate to login and setup clinic session if needed + if (!page.url().includes('clinic-workspace')) { + await page.goto('/login'); + // Add any necessary login steps here + } + console.log('šŸ„ Clinic session setup complete'); + return nav; +} +/** + * Navigate to workspace selection page + */ +async function navigateToWorkspaceSelection(page) { + const accountNav = new AccountNavigation_1.default(page); + // Open the account navigation menu first + await accountNav.pages.AccountNav.link.click(); + // Then click the ManageWorkspaces option + await accountNav.pages.ManageWorkspaces.link.click(); + // Verify we're on the workspace selection page using the known verification element + await accountNav.pages.ManageWorkspaces.verifyElement.waitFor({ + state: 'visible', + timeout: 5000, + }); + // console.log('āœ… Navigated to workspace selection page'); +} +/** + * Navigate to a specific workspace using hardcoded workspace key + */ +async function navigateToWorkspace(workspaceKey, page) { + const clinicianNav = new ClinicianNavigation_1.default(page); + // First navigate to workspace selection if not already there + if (!page.url().includes('workspaces')) { + await navigateToWorkspaceSelection(page); + } + // Click on the specific workspace using the page object locator + await clinicianNav.workspaces[workspaceKey].link.click(); + // Verify we're in the correct workspace using URL verification + await page.waitForURL(new RegExp(clinicianNav.workspaces[workspaceKey].verifyURL), { + timeout: 5000, + }); + // console.log(`āœ… Successfully navigated to workspace: ${clinicianNav.workspaces[workspaceKey].name}`); +} +/** + * Core navigation function that handles workspace prerequisites and page navigation + */ +async function navigateTo(targetPage, page, workspaceKey) { + const clinicianNav = new ClinicianNavigation_1.default(page); + const pageConfig = clinicianNav.pages[targetPage]; + // Ensure we're in a workspace context (but don't auto-switch if already in one) + const isInWorkspaceContext = page.url().includes('clinic-workspace') || + page.url().includes('/patients/') || + page.url().includes('/profile'); + if (!isInWorkspaceContext) { + const defaultWorkspace = workspaceKey || 'AdminClinicBase'; + await navigateToWorkspace(defaultWorkspace, page); + } + else if (workspaceKey) { + // Only switch if specifically requested and we can verify we're in wrong workspace + const currentUrl = page.url(); + const targetWorkspacePattern = clinicianNav.workspaces[workspaceKey].verifyURL; + if (!currentUrl.includes(targetWorkspacePattern)) { + await navigateToWorkspace(workspaceKey, page); + } + } + // Handle page-specific prerequisites + if (targetPage === 'AddPatient') { + // AddPatient might need to be on PatientList first + if (!page.url().includes('patients')) { + await clinicianNav.pages.PatientList.link.click(); + await clinicianNav.pages.PatientList.verifyElement.waitFor({ + state: 'visible', + timeout: 5000, + }); + } + } + // Perform the actual navigation + try { + await pageConfig.link.click(); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`Failed to click ${targetPage}: ${errorMessage}`); + throw error; + } + // Verify navigation succeeded + try { + if (pageConfig.verifyURL) { + await page.waitForURL(`**/*${pageConfig.verifyURL}*`, { timeout: 5000 }); + } + if (pageConfig.verifyElement) { + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + } + // console.log(`āœ… Navigated to page: ${targetPage}`); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + // console.log(`Navigation verification failed for ${targetPage}: ${errorMessage}`); + } +} +/** + * Execute test logic across multiple workspaces + */ +async function executeAcrossWorkspaces(workspaceConfigs, action, page) { + for (const config of workspaceConfigs) { + console.log(`šŸ”„ Executing across workspace: ${config.workspaceKey}`); + // Navigate to the workspace + await navigateToWorkspace(config.workspaceKey, page); + // Execute the action + await action(config); + // Navigate back to workspace selection for next iteration + if (workspaceConfigs.indexOf(config) < workspaceConfigs.length - 1) { + await navigateToWorkspaceSelection(page); + } + } +} +/** + * Find and access any patient whose name contains the search term (optimized version) + * @param searchTerm - Partial name to search for (e.g., "Custodial") + * @param page - The Playwright page object + * @returns The full name of the patient that was accessed + */ +async function findAndAccessPatientByPartialName(searchTerm, page) { + const dashboard = new ClinicianDashboardPage_1.default(page); + // If empty search term, find any available patient + if (!searchTerm || searchTerm.trim() === '') { + return findAndAccessAnyPatient(page); + } + // Strategy 1: Fill search field THEN click Show All (proven fastest method) + try { + await dashboard.searchInput.fill(searchTerm); + await page.waitForTimeout(500); + const showAllButton = page + .getByRole('button', { name: 'Show All' }) + .or(page.getByRole('button', { name: 'Show all' })) + .or(page.getByText('Show All')) + .or(page.getByText('Show all')); + if (await showAllButton.isVisible({ timeout: 1000 })) { + await showAllButton.click(); + await page.waitForTimeout(1000); + const searchResultCells = await dashboard.patientListTable.getByRole('cell').all(); + if (searchResultCells.length > 0) { + for (const cell of searchResultCells) { + const cellText = await cell.textContent(); + if (cellText && cellText.toLowerCase().includes(searchTerm.toLowerCase())) { + await cell.click(); + await page.waitForTimeout(600); + return cellText.trim(); + } + } + } + } + else { + await dashboard.searchInput.press('Enter'); + await page.waitForTimeout(1000); + const searchResultCells = await dashboard.patientListTable.getByRole('cell').all(); + if (searchResultCells.length > 0) { + for (const cell of searchResultCells) { + const cellText = await cell.textContent(); + if (cellText && cellText.toLowerCase().includes(searchTerm.toLowerCase())) { + await cell.click(); + await page.waitForTimeout(600); + return cellText.trim(); + } + } + } + } + } + catch (error) { + // Silent fallback to any patient + } + // Strategy 2: Fallback to any available patient if specific search fails + try { + return await findAndAccessAnyPatient(page); + } + catch (fallbackError) { + throw new Error(`No patient found containing "${searchTerm}" and no fallback patients available`); + } +} +/** + * Find and access any available patient (fastest option) + * @param page - The Playwright page object + * @returns The full name of the first patient that was accessed + */ +async function findAndAccessAnyPatient(page) { + const dashboard = new ClinicianDashboardPage_1.default(page); + try { + // Clear search to show all patients + await dashboard.searchInput.click(); + await dashboard.searchInput.fill(' '); + await page.waitForTimeout(500); + await dashboard.searchInput.fill(''); + await page.waitForTimeout(1500); + let allCells = await dashboard.patientListTable.getByRole('cell').all(); + // If no cells, try pressing Enter on empty search + if (allCells.length === 0) { + await dashboard.searchInput.press('Enter'); + await page.waitForTimeout(1500); + allCells = await dashboard.patientListTable.getByRole('cell').all(); + } + // Find the first cell that looks like a patient name + for (const cell of allCells) { + const cellText = await cell.textContent(); + if (cellText && cellText.trim().length > 3 && cellText.includes(' ')) { + await cell.click(); + await page.waitForTimeout(800); + return cellText.trim(); + } + } + throw new Error('No patient names found in table'); + } + catch (error) { + throw new Error(`Failed to find any patient: ${error}`); + } +} +/** + * Access a specific patient by name and navigate to their summary page + * @param patientName - The name of the patient to access + * @param page - The Playwright page object + */ +async function accessPatient(patientName, page) { + const dashboard = new ClinicianDashboardPage_1.default(page); + console.log(`šŸ” Searching for patient: ${patientName}`); + // Try optimized search first + await dashboard.searchForPatient(patientName); + await page.waitForTimeout(1000); // Reduced wait time + // Check if search worked + const patientCell = dashboard.getPatientCellByName(patientName); + const isVisible = await patientCell.isVisible({ timeout: 2000 }); + if (isVisible) { + console.log(`šŸ‘¤ Found patient via search: ${patientName}`); + await patientCell.click(); + await page.waitForTimeout(1000); + console.log(`āœ… Successfully accessed patient summary for: ${patientName}`); + return; + } + // If search failed, fall back to show all + find + console.log(`šŸ”„ Search failed, trying show all approach...`); + const showAllButton = page.getByRole('button', { name: 'Show All' }); + if (await showAllButton.isVisible({ timeout: 1000 })) { + await showAllButton.click(); + await page.waitForTimeout(1500); + } + // Try again after showing all + const isVisibleAfterShowAll = await patientCell.isVisible({ timeout: 2000 }); + if (isVisibleAfterShowAll) { + await patientCell.click(); + await page.waitForTimeout(1000); + // console.log(`āœ… Successfully accessed patient summary for: ${patientName}`); + return; + } + // If still not found, throw error + throw new Error(`Patient "${patientName}" not found in current workspace`); +} +const test = base_1.test; +exports.test = test; +test.clinician = { + navigateTo, + navigateToWorkspace, + navigateToWorkspaceSelection, + executeAcrossWorkspaces, + accessPatient, + findAndAccessPatientByPartialName, + findAndAccessAnyPatient, + setup: setupClinicianSession, +}; diff --git a/build/tests/fixtures/network-helpers.js b/build/tests/fixtures/network-helpers.js new file mode 100644 index 0000000..d5a0ebb --- /dev/null +++ b/build/tests/fixtures/network-helpers.js @@ -0,0 +1,480 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NetworkHelper = void 0; +exports.createNetworkHelper = createNetworkHelper; +const fs = __importStar(require("node:fs")); +const path = __importStar(require("node:path")); +const endpoint_registry_1 = require("../../endpoint-schema/endpoint-registry"); +const ENDPOINTS = { + profile: /\/data\/[^\/]+$/, // GET requests for patient data + profileUpdate: /\/data\/[^\/]+$/, // PUT requests for patient data updates + profileMetrics: /\/metrics\/thisuser\//, + profileMessage: /\/message\/notes\//, +}; +/** + * Simple network helper for API validation + */ +class NetworkHelper { + constructor(page) { + this.captures = []; + this.isCapturing = false; + this.page = page; + } + async startCapture() { + if (this.isCapturing) + return; + // Only intercept API requests we care about to avoid interfering with other requests + const apiPatterns = [ + '**/data/**', + '**/metrics/**', + '**/message/**', + '**/auth/**', + '**/v1/**', + '**/metadata/**', + '**/user/**', + '**/users/**', + '**/profile/**', + ]; + for (const pattern of apiPatterns) { + await this.page.route(pattern, async (route) => { + const request = route.request(); + try { + const response = await route.fetch(); + let requestBody; + let responseBody; + try { + requestBody = request.postDataJSON(); + } + catch { + requestBody = request.postData(); + } + try { + responseBody = await response.json(); + } + catch { + responseBody = await response.text(); + } + this.captures.push({ + url: request.url(), + method: request.method(), + requestBody, + responseBody, + statusCode: response.status(), + timestamp: Date.now(), + }); + await route.fulfill({ response }); + } + catch (error) { + // If there's an error, continue the request without handling + try { + await route.continue(); + } + catch { + // Route might already be handled, ignore + } + } + }); + } + this.isCapturing = true; + } + async stopCapture() { + if (!this.isCapturing) + return; + // Remove all API route handlers + const apiPatterns = ['**/data/**', '**/metrics/**', '**/message/**', '**/auth/**', '**/v1/**']; + for (const pattern of apiPatterns) { + await this.page.unroute(pattern); + } + this.isCapturing = false; + } + async waitForEndpoint(endpointName, method, timeout = 30000) { + const pattern = ENDPOINTS[endpointName]; + if (!pattern) { + throw new Error(`Unknown endpoint: ${endpointName}`); + } + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + const matches = this.captures.filter(capture => pattern.test(capture.url) && capture.method.toLowerCase() === method.toLowerCase()); + if (matches.length > 0) { + return matches[matches.length - 1]; // Return latest match + } + await this.page.waitForTimeout(100); + } + throw new Error(`${method} request to ${endpointName} not found within ${timeout}ms`); + } + getCaptures() { + return [...this.captures]; + } + /** + * Simple helper to validate endpoint requests by URL pattern and method + */ + validateEndpointRequests(urlPattern, method) { + return this.captures.filter(c => c.url.includes(urlPattern) && c.method === method); + } + /** + * Save all captures to a JSON file + */ + async saveCapturesTo(filename, testInfo) { + const logDir = path.join(process.cwd(), 'log'); + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + // Create capture data + const captureData = { + timestamp: new Date().toISOString(), + totalCaptures: this.captures.length, + captures: this.captures, + }; + // Use Playwright's automatic attachment instead of manual file writing + if (testInfo && typeof testInfo.attach === 'function') { + await testInfo.attach(filename, { + body: JSON.stringify(captureData, null, 2), + contentType: 'application/json', + }); + console.log(`šŸ“„ Network captures attached to Playwright report: ${filename}`); + } + else { + console.log(`šŸ“„ Network captures ready (${this.captures.length} captures)`); + } + } + /** + * Print a summary of all captures to console + */ + printCaptureSummary() { + console.log(`\nšŸ“Š Network Capture Summary (${this.captures.length} total requests):`); + console.log('='.repeat(60)); + this.captures.forEach((capture, index) => { + const timestamp = new Date(capture.timestamp).toLocaleTimeString(); + console.log(`${index + 1}. ${capture.method} ${capture.statusCode} - ${capture.url}`); + console.log(` Time: ${timestamp}`); + if (capture.requestBody) { + console.log(` Request: ${JSON.stringify(capture.requestBody).substring(0, 100)}...`); + } + console.log(''); + }); + } + /** + * Get captures filtered by status code + */ + getCapturesByStatus(statusCode) { + return this.captures.filter(c => c.statusCode === statusCode); + } + /** + * Get the most recent capture matching method and URL pattern + */ + getLatestCaptureMatching(method, urlPattern) { + const matches = this.captures + .filter(c => c.method === method && urlPattern.test(c.url)) + .sort((a, b) => b.timestamp - a.timestamp); + return matches.length > 0 ? matches[0] : null; + } + /** + * Get all captures for a specific endpoint + */ + getCapturesForEndpoint(endpointName) { + const pattern = ENDPOINTS[endpointName]; + if (!pattern) { + throw new Error(`Unknown endpoint: ${endpointName}`); + } + return this.captures.filter(c => pattern.test(c.url)); + } + /** + * Get all captures + */ + getAllCaptures() { + return [...this.captures]; + } + /** + * Save API response as JSON attachment and to organized test-results folder + */ + async saveApiResponse(response, endpoint, method, fileName, testInfo) { + const responseData = { + _request: { + method, + endpoint, + }, + ...response, + }; + const jsonContent = JSON.stringify(responseData, null, 2); + // Attach to Playwright report AND save to organized test-results folder + if (testInfo && typeof testInfo.attach === 'function') { + await testInfo.attach(fileName, { + body: jsonContent, + contentType: 'application/json', + }); + // Also save to test-results for organized viewing (like screenshots) + const testResultsDir = path.join(testInfo.outputDir, 'attachments'); + await fs.promises.mkdir(testResultsDir, { recursive: true }); + const jsonPath = path.join(testResultsDir, fileName); + await fs.promises.writeFile(jsonPath, jsonContent, 'utf8'); + } + } + /** + * Validate and save API response for any endpoint defined in the endpoint registry + * @param endpointName - The endpoint name from the registry (e.g., 'profile-metadata-get') + * @returns The captured network request or null if not found + */ + async validateEndpointResponse(endpointName) { + const schema = (0, endpoint_registry_1.getEndpointSchema)(endpointName); + const request = this.getLatestCaptureMatching(schema.method, schema.url); + if (request?.responseBody) { + // Access the shared step counter from the stepScreenshoter fixture + const stepCounterObj = globalThis.__stepCounter; + if (stepCounterObj) { + const stepNumber = stepCounterObj.increment(); + const currentStepName = stepCounterObj.getCurrentStepName(); + // Create consistent filename with step number and step name (like screenshots) + const stepNameForFile = currentStepName + ? currentStepName.toLowerCase().replace(/[^a-z0-9]/g, '-') + : endpointName.replace(/[^a-z0-9]/gi, '-'); + const fileName = `step-${stepNumber.toString().padStart(2, '0')}-${stepNameForFile}-response.json`; + await this.saveApiResponse(request.responseBody, request.url, schema.method, fileName, globalThis.testInfo); + } + } + return request; + } + /** + * Save network capture for producer/consumer test patterns + * @param endpointName - The endpoint to save + * @param testName - Name of the test (used for file naming) + * @returns The saved network capture or null + */ + async saveForDependentTests(endpointName, testName) { + const schema = (0, endpoint_registry_1.getEndpointSchema)(endpointName); + const capture = this.getLatestCaptureMatching(schema.method, schema.url); + if (capture) { + // Create step-based filename for better organization + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const stepName = testName.toLowerCase().replace(/[^a-z0-9]/g, '-'); + const fileName = `step-api-${stepName}-${endpointName.replace(/[^a-z0-9]/gi, '-')}-${timestamp}.json`; + console.log(`āœ… Saved ${endpointName} response for dependent tests`); + // Use Playwright's automatic attachment instead of file system + const { testInfo } = globalThis; + if (testInfo && typeof testInfo.attach === 'function') { + await testInfo.attach(fileName, { + body: JSON.stringify(capture, null, 2), + contentType: 'application/json', + }); + } + return capture; + } + return null; + } + /** + * Load producer test data for consumer tests + * @param testName - Name of the producer test (used for file naming) + * @returns The loaded network capture or null + */ + loadFromProducerTest(testName) { + const filePath = path.join(process.cwd(), 'log', 'test-data-pipeline', `${testName}-response.json`); + if (fs.existsSync(filePath)) { + const fileContent = fs.readFileSync(filePath, 'utf-8'); + const capture = JSON.parse(fileContent); + console.log(`āœ… Loaded ${testName} response from producer test`); + return capture; + } + throw new Error(`Producer test data not found at: ${filePath}. Please run ${testName} test first.`); + } + /** + * Validate data consistency between producer and consumer responses + * @param producerCapture - The producer test network capture + * @param consumerCapture - The consumer test network capture + * @param fieldsToValidate - Array of field paths to validate (e.g., ['fullName', 'patient.birthday']) + * @param requiredFields - Array of fields that must exist and match (defaults to common required fields) + */ + validateDataConsistency(producerCapture, consumerCapture, fieldsToValidate, requiredFields = ['fullName']) { + // Use provided fields or fall back to a basic set for backward compatibility + const defaultFields = ['fullName', 'patient.fullName', 'patient.birthday', 'email']; + const fieldsToCheck = fieldsToValidate || defaultFields; + const producerData = producerCapture.responseBody; + const consumerData = consumerCapture.responseBody; + if (!producerData || !consumerData) { + throw new Error('Missing response data for consistency validation'); + } + console.log('šŸ” Validating data consistency:'); + // Only log full data in development mode + if (process.env.VERBOSE_VALIDATION) { + console.log('Producer:', JSON.stringify(producerData, null, 2)); + console.log('Consumer:', JSON.stringify(consumerData, null, 2)); + } + else { + console.log('Producer fullName:', producerData.fullName); + console.log('Consumer fullName:', consumerData.fullName); + } + // Validate each specified field + for (const fieldPath of fieldsToCheck) { + const producerValue = this.getNestedValue(producerData, fieldPath); + const consumerValue = this.getNestedValue(consumerData, fieldPath); + // Check if this field is marked as required + const isRequired = requiredFields.includes(fieldPath); + if (isRequired) { + if (producerValue === undefined || producerValue === null) { + throw new Error(`Required field ${fieldPath} is missing in producer data`); + } + if (consumerValue === undefined || consumerValue === null) { + throw new Error(`Required field ${fieldPath} is missing in consumer data`); + } + } + // For optional fields: only validate if the field exists in producer data + // If it exists in producer, it must also exist in consumer with same value + if (producerValue !== undefined && producerValue !== null) { + // Handle array comparison + if (Array.isArray(producerValue) && Array.isArray(consumerValue)) { + if (JSON.stringify(producerValue) !== JSON.stringify(consumerValue)) { + throw new Error(`${fieldPath} mismatch - Expected: ${JSON.stringify(producerValue)}, Got: ${JSON.stringify(consumerValue)}`); + } + } + else if (producerValue !== consumerValue) { + throw new Error(`${fieldPath} mismatch - Expected: ${producerValue}, Got: ${consumerValue}`); + } + } + // If producer value doesn't exist, consumer doesn't need to have it either (optional field) + } + console.log('āœ… Data consistency validated: consumer data reflects producer changes'); + } + /** + * Helper method to get nested object values using dot notation + * @param obj - The object to search + * @param path - The dot-notation path (e.g., 'patient.birthday') + * @returns The value at the path or undefined + */ + getNestedValue(obj, path) { + return path.split('.').reduce((current, key) => current?.[key], obj); + } + /** + * Validate producer-consumer data consistency for profile endpoints + * @param producerEndpointName - The PUT endpoint name (e.g., 'profile-metadata-put') + * @param consumerEndpointName - The GET endpoint name (e.g., 'profile-metadata-get') + * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) + * @throws Error if validation fails + */ + async validateProducerConsumerData(producerEndpointName, consumerEndpointName, fieldsToValidate) { + const producerSchema = (0, endpoint_registry_1.getEndpointSchema)(producerEndpointName); + const consumerSchema = (0, endpoint_registry_1.getEndpointSchema)(consumerEndpointName); + // Use provided fields, or consumer endpoint validation fields, or producer endpoint validation fields + const validationFields = fieldsToValidate || + consumerSchema.validationFields || + producerSchema.validationFields || ['fullName', 'email']; + // Use consumer endpoint required fields, or producer endpoint required fields, or default + const requiredFields = consumerSchema.requiredFields || + producerSchema.requiredFields || ['fullName']; + const producerCapture = this.getLatestCaptureMatching(producerSchema.method, producerSchema.url); + const consumerCapture = this.getLatestCaptureMatching(consumerSchema.method, consumerSchema.url); + if (!producerCapture) { + throw new Error(`No ${producerEndpointName} capture found for producer validation`); + } + if (!consumerCapture) { + throw new Error(`No ${consumerEndpointName} capture found for consumer validation`); + } + this.validateDataConsistency(producerCapture, consumerCapture, validationFields, requiredFields); + } + /** + * Private method to validate endpoint response without generating JSON file + * @param endpointName - The endpoint name from the registry + * @returns The captured network request or null if not found + */ + validateEndpointResponseSilent(endpointName) { + const schema = (0, endpoint_registry_1.getEndpointSchema)(endpointName); + const request = this.getLatestCaptureMatching(schema.method, schema.url); + return request; + } + /** + * Complete validation workflow for a user viewing profile data + * Validates both API schema and data consistency in one call + * @param consumerEndpointName - The GET endpoint name + * @param producerCapture - The stored PUT capture from the producer + * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) + */ + async compareEndpointResponse(consumerEndpointName, producerCapture, fieldsToValidate) { + // Get the endpoint schema to determine validation fields + const consumerSchema = (0, endpoint_registry_1.getEndpointSchema)(consumerEndpointName); + // Use provided fields, or endpoint-specific fields, or fall back to basic fields + const validationFields = fieldsToValidate || + consumerSchema.validationFields || ['fullName', 'patient.fullName', 'email']; + // Use endpoint-specific required fields, or default to fullName for backward compatibility + const requiredFields = consumerSchema.requiredFields || ['fullName']; + // Validate GET response schema without generating JSON file + const consumerCapture = this.validateEndpointResponseSilent(consumerEndpointName); + if (!consumerCapture) { + throw new Error(`No compare endpoint found`); + } + if (!producerCapture) { + throw new Error('No base endpoint found'); + } + // Generate comparison JSON file similar to validateEndpointResponse + const stepCounterObj = globalThis.__stepCounter; + if (stepCounterObj) { + // Increment for JSON file naming (this is correct behavior) + const stepNumber = stepCounterObj.increment(); + const currentStepName = stepCounterObj.getCurrentStepName(); + // Create comparison data object + const comparisonData = { + _comparison: { + description: `Data consistency comparison for ${consumerEndpointName}`, + timestamp: new Date().toISOString(), + fieldsValidated: validationFields, + requiredFields, + }, + original: { + url: producerCapture.url, + method: producerCapture.method, + timestamp: producerCapture.timestamp, + responseBody: producerCapture.responseBody, + }, + new: { + url: consumerCapture.url, + method: consumerCapture.method, + timestamp: consumerCapture.timestamp, + responseBody: consumerCapture.responseBody, + }, + }; + // Create consistent filename with step number and step name (like screenshots) + const stepNameForFile = currentStepName + ? currentStepName.toLowerCase().replace(/[^a-z0-9]/g, '-') + : consumerEndpointName.replace(/[^a-z0-9]/gi, '-'); + const fileName = `step-${stepNumber.toString().padStart(2, '0')}-${stepNameForFile}-comparison.json`; + // Save the comparison data using the unified approach + const { testInfo } = globalThis; + await this.saveApiResponse(comparisonData, consumerCapture.url, consumerCapture.method, fileName, testInfo); + } + // Validate data consistency using the determined validation fields and required fields + this.validateDataConsistency(producerCapture, consumerCapture, validationFields, requiredFields); + } +} +exports.NetworkHelper = NetworkHelper; +function createNetworkHelper(page) { + return new NetworkHelper(page); +} diff --git a/build/tests/fixtures/patient-helpers.js b/build/tests/fixtures/patient-helpers.js new file mode 100644 index 0000000..0b68151 --- /dev/null +++ b/build/tests/fixtures/patient-helpers.js @@ -0,0 +1,483 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.test = void 0; +const base_1 = require("@fixtures/base"); +const PatientNavigation_1 = __importDefault(require("@pom/patient/PatientNavigation")); +const env_1 = __importDefault(require("../../utilities/env")); +/** + * Initialize patient navigation helpers after login + */ +async function setupPatientSession(page) { + // Wait for patient navigation to be available + const nav = new PatientNavigation_1.default(page); + await Promise.all([ + nav.pages.ViewData.link.waitFor({ state: 'visible' }), + nav.pages.Profile.link.waitFor({ state: 'visible' }), + ]); + return nav; +} +/** + * Close any open modal dialogs that might block navigation + */ +async function closeOpenDialogs(page) { + try { + if (page.isClosed()) + return; + // Simple and fast: just press Escape twice to close any modals + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); + } + catch (error) { + // Ignore errors in dialog closing - they're not critical + } +} +/** + * Check if we're in a context where patient navigation is supported + */ +async function isInPatientContext(nav, page) { + try { + // Check if any patient navigation elements are visible + const patientElements = [nav.pages.ViewData.link, nav.pages.Profile.link, nav.pages.Share.link]; + for (const element of patientElements) { + if (await element.isVisible({ timeout: 1000 })) { + return true; + } + } + return false; + } + catch { + return false; + } +} +/** + * Get current page state by checking URL and visible elements + */ +async function getCurrentPageState(nav, page) { + const url = page.url(); + // Check each page in order of specificity + for (const [pageName, pageConfig] of Object.entries(nav.pages)) { + try { + if (pageConfig.verifyURL && url.includes(pageConfig.verifyURL)) { + if (pageConfig.verifyElement && + (await pageConfig.verifyElement.isVisible({ timeout: 1000 }))) { + return pageName; + } + } + } + catch { + // Continue checking other pages + } + } + return 'unknown'; +} +/** + * Navigation strategies for different page types + */ +const navigationStrategies = { + // Basic page navigation + default: [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'check-patient-context', + condition: async (state) => !(await isInPatientContext(state.nav, state.page)), + action: async (state) => { + console.log('Not in patient context, navigating to /data URL to reset'); + // Navigate to /data endpoint specifically, not just base URL + await state.page.goto(`${env_1.default.BASE_URL}/data`); + await state.page.waitForLoadState('domcontentloaded'); + // Wait for patient navigation to be available + await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); + console.log('Successfully reset to patient context via /data URL'); + }, + }, + { + name: 'wait-for-loading', + action: async (state) => { + const loading = state.page.getByText('Loading...', { exact: true }); + try { + await loading.waitFor({ state: 'hidden', timeout: 3000 }); + } + catch { + // Loading might not be visible + } + }, + }, + { + name: 'navigate-click', + action: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + await pageConfig.link.click({ timeout: 5000 }); + }, + }, + { + name: 'verify-navigation', + verify: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + if (pageConfig.verifyElement) { + try { + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + return true; + } + catch { + return false; + } + } + return true; + }, + }, + ], + // Profile page - handle account settings conflict + Profile: [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'check-patient-context', + condition: async (state) => !(await isInPatientContext(state.nav, state.page)), + action: async (state) => { + console.log('Not in patient context, navigating to /data URL to reset'); + // Navigate to /data endpoint specifically, not just base URL + await state.page.goto(`${env_1.default.BASE_URL}/data`); + await state.page.waitForLoadState('domcontentloaded'); + // Wait for patient navigation to be available + await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); + console.log('Successfully reset to patient context via /data URL'); + }, + }, + { + name: 'handle-account-settings-conflict', + condition: async (state) => state.page.url().includes('/profile') && + (await state.page + .getByRole('heading', { name: /account/i }) + .or(state.page.getByText('Account Settings')) + .or(state.page.getByText('Account')) + .or(state.page.locator('.profile-subnav-title').getByText('Account')) + .isVisible() + .catch(() => false)), + action: async (state) => { + console.log('On account settings page, redirecting to base URL first'); + await state.page.goto(env_1.default.BASE_URL); + await state.page.waitForTimeout(500); + }, + }, + { + name: 'navigate-click', + action: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + await pageConfig.link.click({ timeout: 5000 }); + }, + }, + { + name: 'verify-navigation', + verify: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + if (pageConfig.verifyElement) { + try { + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + return true; + } + catch { + return false; + } + } + return true; + }, + }, + ], + // Modal dialogs + modal: [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'navigate-click', + action: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + await pageConfig.link.click({ timeout: 5000 }); + }, + }, + { + name: 'wait-for-modal', + action: async (state) => { + await state.page.waitForTimeout(500); + }, + }, + ], + // Data pages that need ViewData prerequisite + 'data-page': [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'ensure-data-view', + condition: async (state) => !state.page.url().includes('/data/'), + action: async (state) => { + await state.nav.pages.ViewData.link.click(); + await state.nav.pages.ViewData.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + }, + }, + { + name: 'navigate-click', + action: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + await pageConfig.link.click({ timeout: 5000 }); + }, + }, + { + name: 'verify-navigation', + verify: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + if (pageConfig.verifyElement) { + try { + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + return true; + } + catch { + return false; + } + } + return true; + }, + }, + ], + // ShareData requires Share main page to be accessible first + ShareData: [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'check-patient-context', + condition: async (state) => !(await isInPatientContext(state.nav, state.page)), + action: async (state) => { + console.log('Not in patient context, navigating to /data URL to reset'); + await state.page.goto(`${env_1.default.BASE_URL}/data`); + await state.page.waitForLoadState('domcontentloaded'); + await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); + console.log('Successfully reset to patient context via /data URL'); + }, + }, + { + name: 'ensure-share-dependency', + action: async (state) => { + // First ensure Share main page is accessible + try { + await state.nav.pages.Share.link.waitFor({ state: 'visible', timeout: 3000 }); + console.log('Share dependency met - Share button is accessible'); + } + catch { + console.log('Share dependency not met - performing URL reset to /data'); + await state.page.goto(`${env_1.default.BASE_URL}/data`); + await state.page.waitForLoadState('domcontentloaded'); + await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); + console.log('URL reset completed, Share dependency should now be available'); + } + }, + }, + { + name: 'navigate-to-share-first', + action: async (state) => { + // Navigate to Share main page first to establish context + try { + await state.nav.pages.Share.link.click({ timeout: 3000 }); + await state.nav.pages.Share.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + console.log('Successfully navigated to Share main page'); + } + catch { + console.log('Could not reach Share main page, staying in current state'); + } + }, + }, + { + name: 'navigate-to-sharedata', + action: async (state) => { + // Now try to navigate to ShareData sub-page + try { + await state.nav.pages.ShareData.link.click({ timeout: 5000 }); + console.log('Successfully clicked ShareData button'); + } + catch { + console.log('ShareData button not available - this is expected and OK'); + } + }, + }, + { + name: 'verify-navigation', + verify: async (state) => { + // Try to verify ShareData first, fall back to Share if not available + try { + await state.nav.pages.ShareData.verifyElement.waitFor({ + state: 'visible', + timeout: 3000, + }); + console.log('āœ… ShareData page verified'); + return true; + } + catch { + try { + await state.nav.pages.Share.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + console.log('āœ… Share main page verified (ShareData not available - this is OK)'); + return true; + } + catch { + console.log('Neither ShareData nor Share page could be verified'); + return false; + } + } + }, + }, + ], +}; +/** + * Page type mappings to determine which strategy to use + */ +const pageStrategies = { + ViewData: 'default', + Basics: 'data-page', + Daily: 'data-page', + BGLog: 'data-page', + Trends: 'data-page', + Devices: 'data-page', + Profile: 'Profile', + ProfileEdit: 'default', // TODO: Add prerequisite logic + Share: 'default', + ShareData: 'ShareData', // Uses dependency-aware strategy + UploadData: 'default', + ChartDateRange: 'modal', + ChartDate: 'modal', + Print: 'modal', +}; +/** + * Execute navigation strategy + */ +async function executeNavigationStrategy(state) { + const strategyName = pageStrategies[state.targetPage] || 'default'; + const strategy = navigationStrategies[strategyName]; + console.log(`Executing ${strategyName} strategy for ${state.targetPage}`); + for (const step of strategy) { + try { + // Check condition if present + if (step.condition && !(await step.condition(state))) { + console.log(`Skipping step ${step.name} - condition not met`); + continue; + } + console.log(`Executing step: ${step.name}`); + // Execute action if present + if (step.action) { + await step.action(state); + } + // Verify if present + if (step.verify && !(await step.verify(state))) { + console.log(`Step ${step.name} verification failed`); + return false; + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`Step ${step.name} failed:`, errorMessage); + return false; + } + } + return true; +} +/** + * New scalable navigation function using state machine approach + */ +async function navigateTo(targetPage, page) { + if (page.isClosed()) { + console.log(`Page is closed, cannot navigate to ${targetPage}`); + return; + } + const nav = new PatientNavigation_1.default(page); + const currentPage = await getCurrentPageState(nav, page); + const state = { + currentPage, + targetPage, + nav, + page, + }; + console.log(`Navigating from ${currentPage} to ${targetPage}`); + // Execute primary navigation strategy + const success = await executeNavigationStrategy(state); + if (!success) { + console.log(`Primary navigation failed, trying fallback strategies`); + // Fallback strategy - go to base URL and try again + if (targetPage === 'Profile') { + try { + console.log('Profile fallback: going to base URL and trying again'); + await page.goto(env_1.default.BASE_URL); + await page.waitForTimeout(500); + await nav.pages[targetPage].link.click({ timeout: 3000 }); + console.log(`Successfully navigated to ${targetPage} via fallback`); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`Profile fallback failed: ${errorMessage}`); + throw error; + } + } + else if (nav.pages[targetPage].verifyURL) { + // Generic URL fallback for pages with backup URLs + try { + let fallbackURL = env_1.default.BASE_URL; + // For sub-pages that might not be available, fall back to the main page + if (targetPage === 'ShareData') { + fallbackURL = `${env_1.default.BASE_URL}/share`; // Fall back to main Share page + } + else if (targetPage === 'ProfileEdit') { + fallbackURL = `${env_1.default.BASE_URL}/profile`; // Fall back to main Profile page + } + else if (['Basics', 'Daily', 'BGLog', 'Trends', 'Devices'].includes(targetPage)) { + fallbackURL = `${env_1.default.BASE_URL}/data`; // Fall back to main ViewData page + } + else if (nav.pages[targetPage].verifyURL) { + fallbackURL = `${env_1.default.BASE_URL}/${nav.pages[targetPage].verifyURL}`; + } + await page.goto(fallbackURL); + console.log(`Used backup URL for ${targetPage}: ${fallbackURL}`); + // For sub-pages that fall back to main pages, verify the main page elements + let { verifyElement } = nav.pages[targetPage]; + if (targetPage === 'ShareData') { + verifyElement = nav.pages.Share.verifyElement; // Verify main Share page instead + } + else if (targetPage === 'ProfileEdit') { + verifyElement = nav.pages.Profile.verifyElement; // Verify main Profile page instead + } + else if (['Basics', 'Daily', 'BGLog', 'Trends', 'Devices'].includes(targetPage)) { + verifyElement = nav.pages.ViewData.verifyElement; // Verify main ViewData page instead + } + // Wait for the fallback page to actually load and verify we're there + if (verifyElement) { + await verifyElement.waitFor({ + state: 'visible', + timeout: 10000, + }); + console.log(`āœ… Backup URL navigation to ${targetPage} verified successfully (using fallback verification)`); + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`Backup URL failed: ${errorMessage}`); + throw error; + } + } + else { + throw new Error(`Navigation to ${targetPage} failed and no fallback available`); + } + } +} +const test = base_1.test; +exports.test = test; +test.patient = { + navigateTo, + setup: setupPatientSession, +}; diff --git a/build/tests/fixtures/test-tags.js b/build/tests/fixtures/test-tags.js new file mode 100644 index 0000000..a2f7ec6 --- /dev/null +++ b/build/tests/fixtures/test-tags.js @@ -0,0 +1,98 @@ +"use strict"; +/** + * Test Tags Fixture + * + * Simple tag definitions for test organization and Xray integration. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.TAG_CATEGORIES = exports.TEST_TAGS = void 0; +exports.validateRequiredTags = validateRequiredTags; +exports.createValidatedTags = createValidatedTags; +exports.TEST_TAGS = { + /** + * Generate a Jira-related tag for linking tests to Jira tickets. + * Usage: TEST_TAGS.RELATED('JIRA-1234') => '@jira(JIRA-1234)' + */ + RELATED: (jiraId) => { + // Accepts formats like ABC-1234 or JIRA-1234 + const jiraPattern = /^[A-Z][A-Z0-9]+-\d+$/; + if (!jiraPattern.test(jiraId)) { + throw new Error(`Invalid Jira ID: ${jiraId}. Must match pattern ABC-1234.`); + } + return `@jira(${jiraId})`; + }, + // Backend Services + BACK_SHORELINE: '@back-shoreline', + BACK_CLINIC: '@back-clinic', + BACK_HIGHWATER: '@back-highwater', + BACK_HYDROPHONE: '@back-hydrophone', + BACK_PLATFORM: '@back-platform', + BACK_SEAGULL: '@back-seagull', + BACK_TIDEWHISPERER: '@back-tidewhisperer', + BACK_MESSAGEAPI: '@back-messageapi', + BACK_JELLYFISH: '@back-jellyfish', + BACK_GATEKEEPER: '@back-gatekeeper', + BACK_EXPORT: '@back-export', + BACK_KEYCLOAK: '@back-keycloak', + // User Types + PATIENT: '@patient', + CLINICIAN: '@clinician', + // User-Subtypes + CUSTODIAL: '@custodial', + SHARED_MEMBER: '@shared_member', + PERSONAL: '@personal', + CLAIMED: '@claimed', + // Test Types + API: '@api', + UI: '@ui', + SMOKE: '@smoke', + REGRESSION: '@regression', + // Priority + CRITICAL: '@critical', + HIGH: '@high', + MEDIUM: '@medium', + LOW: '@low', + // Endpoint API Testing + API_PROFILE: '@api_profile', + API_USER: '@api_user', +}; +// Tag Categories for Validation +exports.TAG_CATEGORIES = { + USER_TYPES: [exports.TEST_TAGS.PATIENT, exports.TEST_TAGS.CLINICIAN], + TEST_TYPES: [exports.TEST_TAGS.API, exports.TEST_TAGS.UI, exports.TEST_TAGS.SMOKE, exports.TEST_TAGS.REGRESSION], + PRIORITIES: [exports.TEST_TAGS.CRITICAL, exports.TEST_TAGS.HIGH, exports.TEST_TAGS.MEDIUM, exports.TEST_TAGS.LOW], +}; +/** + * Validates that tags include at least one from each required category + * @param tags Array of tags to validate + * @returns Object with validation results + */ +function validateRequiredTags(tags) { + const hasUserType = tags.some(tag => exports.TAG_CATEGORIES.USER_TYPES.includes(tag)); + const hasTestType = tags.some(tag => exports.TAG_CATEGORIES.TEST_TYPES.includes(tag)); + const hasPriority = tags.some(tag => exports.TAG_CATEGORIES.PRIORITIES.includes(tag)); + const isValid = hasUserType && hasTestType && hasPriority; + const missing = []; + if (!hasUserType) + missing.push('User Type'); + if (!hasTestType) + missing.push('Test Type'); + if (!hasPriority) + missing.push('Priority'); + return { + isValid, + missing, + message: isValid ? 'All required tags present' : `Missing required tags: ${missing.join(', ')}`, + }; +} +/** + * Helper function to create tags with validation + * Throws error if required tags are missing + */ +function createValidatedTags(tags) { + const validation = validateRequiredTags(tags); + if (!validation.isValid) { + throw new Error(`Test tags validation failed: ${validation.message}`); + } + return tags; +} diff --git a/build/tests/global-setup.js b/build/tests/global-setup.js new file mode 100644 index 0000000..2550db3 --- /dev/null +++ b/build/tests/global-setup.js @@ -0,0 +1,47 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = globalSetup; +const test_1 = require("@playwright/test"); +const LoginPage_1 = __importDefault(require("@pom/LoginPage")); +const node_fs_1 = __importDefault(require("node:fs")); +const node_path_1 = __importDefault(require("node:path")); +const env_1 = __importDefault(require("../utilities/env")); +async function loginUserType(role) { + const browser = await test_1.chromium.launch(); + const context = await browser.newContext({ + baseURL: process.env.BASE_URL, + }); + const page = await context.newPage(); + await page.goto(env_1.default.BASE_URL); + const loginPage = new LoginPage_1.default(page); + if (role === 'personal') { + await loginPage.login(env_1.default.PERSONAL_USERNAME, env_1.default.PERSONAL_PASSWORD); + await page.waitForURL('**/data'); + } + else if (role === 'claimed') { + await loginPage.login(env_1.default.CLAIMED_USERNAME, env_1.default.CLAIMED_PASSWORD); + await page.waitForURL('**/data'); + } + else if (role === 'shared') { + await loginPage.login(env_1.default.SHARED_USERNAME, env_1.default.SHARED_PASSWORD); + await page.waitForURL('**/data'); + } + else { + await loginPage.login(env_1.default.CLINICIAN_USERNAME, env_1.default.CLINICIAN_PASSWORD); + await page.waitForURL('**/workspaces'); + } + const authDir = node_path_1.default.resolve(process.cwd(), 'tests', '.auth'); + await node_fs_1.default.promises.mkdir(authDir, { recursive: true }); + const filePath = node_path_1.default.join(authDir, `${role}.json`); + await context.storageState({ path: filePath }); + await browser.close(); +} +async function globalSetup(_config) { + await loginUserType('personal'); + await loginUserType('claimed'); + await loginUserType('shared'); + await loginUserType('clinician'); +} diff --git a/build/tests/personal/AP-Profile/edit-personal-profile-API.spec.js b/build/tests/personal/AP-Profile/edit-personal-profile-API.spec.js new file mode 100644 index 0000000..45bc9b2 --- /dev/null +++ b/build/tests/personal/AP-Profile/edit-personal-profile-API.spec.js @@ -0,0 +1,75 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const patient_helpers_1 = require("../../fixtures/patient-helpers"); +const network_helpers_1 = require("../../fixtures/network-helpers"); +const test_tags_1 = require("../../fixtures/test-tags"); +const ProfilePage_1 = require("../../../page-objects/patient/ProfilePage"); +patient_helpers_1.test.describe('Personal Accounts allow access and modification of profile details', () => { + // API Test cases require this to capture network activity + let api; + (0, patient_helpers_1.test)('should allow navigation to profile details and edit profile fields', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.PATIENT, // User Type (required) + test_tags_1.TEST_TAGS.PERSONAL, // User Subtype (required) + test_tags_1.TEST_TAGS.API, // Test Type (required) + test_tags_1.TEST_TAGS.UI, // Test Type (required) + test_tags_1.TEST_TAGS.HIGH, // Priority (required) + test_tags_1.TEST_TAGS.API_PROFILE, // Feature (optional) + ]), + }, async ({ page }) => { + // Step 1: Log in to personal account and setup network capture + await patient_helpers_1.test.step('Given personal account has been logged in', async () => { + api = (0, network_helpers_1.createNetworkHelper)(page); + await api.startCapture(); + await page.goto('/data'); + await patient_helpers_1.test.patient.setup(page); + // Step 2: Navigate to profile + await patient_helpers_1.test.step('When user navigates to Profile page', async () => { + await patient_helpers_1.test.patient.navigateTo('Profile', page); + }); + // Step 3: Check profile GET response + await patient_helpers_1.test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Step 4: Open Edit Profile + await patient_helpers_1.test.step('When user selects Edit button', async () => { + await patient_helpers_1.test.patient.navigateTo('ProfileEdit', page); + }); + // Initialize ProfilePage for steps 4 and 5 + const profilePage = new ProfilePage_1.ProfilePage(page); + // Step 5: Change profile fields (confirmed user access) + await patient_helpers_1.test.step('When user updates profile fields', async () => { + // Generate completely unique values for this confirmed user test run + const testRunId = Math.floor(Math.random() * 10000); + const updatedName = `Personal Patient Updated ${testRunId}`; + const birthYear = 1985 + (testRunId % 10); + const diagnosisYear = birthYear + 20; + const birthDate = `01/15/${birthYear}`; + const diagnosisDate = `03/10/${diagnosisYear}`; + // Generate random 15-letter string for clinical notes + const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); + // Get current diagnosis index and calculate next one (1-7, wrapping) + const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); + let nextDiagnosisIndex = currentDiagnosisIndex + 1; + if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { + nextDiagnosisIndex = 1; + } + // Update fields using ProfilePage methods + await profilePage.fillFullName(updatedName); + await profilePage.fillBirthDate(birthDate); + await profilePage.fillDiagnosisDate(diagnosisDate); + await profilePage.selectDiagnosisType(nextDiagnosisIndex); + await profilePage.fillClinicalNotes(randomString); + }); + // Step 6: Save profile edit + await patient_helpers_1.test.step('When user saves profile changes', async () => { + await profilePage.saveProfile(); + }); + // Step 7: Check profile PUT response + await patient_helpers_1.test.stepNoScreenshot('Then profile endpoint responds with PUT request consistent with schema', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + }); + await api.stopCapture(); + }); + }); +}); diff --git a/build/tests/personal/basic-functionality.spec.js b/build/tests/personal/basic-functionality.spec.js new file mode 100644 index 0000000..48e40fa --- /dev/null +++ b/build/tests/personal/basic-functionality.spec.js @@ -0,0 +1,240 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +// @ts-check +const base_1 = require("@fixtures/base"); +const BasicsPage_1 = __importDefault(require("@pom/patient/BasicsPage")); +const DailyPage_1 = __importDefault(require("@pom/patient/DailyPage")); +base_1.test.describe('Patient Data Navigation and Visualization', () => { + base_1.test.beforeEach(async ({ page }) => { + await base_1.test.step('Given user has been logged in', async () => { + const basicsPage = new BasicsPage_1.default(page); + await basicsPage.goto(); + // await page.getByText("Loading").waitFor({ state: "detached", timeout: 10000 }); + }); + }); + // BG readings dashboard functionality + (0, base_1.test)('should display daily chart when selecting a date from basics page', async ({ page }) => { + const basicsPage = new BasicsPage_1.default(page); + const dailyPage = new DailyPage_1.default(page); + let selectedDateText; + await base_1.test.step('When the navigation bar is visible', async () => { + await basicsPage.navigationBar.buttons.viewData.waitFor({ + state: 'visible', + }); + }); + await base_1.test.step('When the user clicks on the most recent day', async () => { + const recentDayElement = basicsPage.bgReadingsSection.firstDayOfData; + await recentDayElement.waitFor({ state: 'visible' }); + await recentDayElement.hover(); + selectedDateText = await basicsPage.bgReadingsSection.calendarDayhover.text(); + await basicsPage.bgReadingsSection.calendarDayhover.el.click(); + }); + await base_1.test.step('Then the daily chart is visible and correctly rendered', async () => { + const chartContainer = dailyPage.dailyChart.container; + await chartContainer.waitFor({ state: 'visible' }); + if (!selectedDateText) { + throw new Error('Selected date text is null'); + } + // Verify the selected date matches the displayed date + // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); + // Capture chart screenshot for visual regression + await (0, base_1.expect)(chartContainer).toHaveScreenshot('daily-chart-1.png'); + }); + }); + // Bolus dashboard functionality + (0, base_1.test)('should display bolus dashboard when selecting a date from basics page', async ({ page, }) => { + const basicsPage = new BasicsPage_1.default(page); + const dailyPage = new DailyPage_1.default(page); + let selectedDateText; + await base_1.test.step('When the navigation bar is visible', async () => { + await basicsPage.navigationBar.buttons.viewData.waitFor({ + state: 'visible', + }); + }); + await base_1.test.step('When the user clicks on the most recent day', async () => { + const recentDayElement = basicsPage.bolusingSection.firstDayOfData; + await recentDayElement.waitFor({ state: 'visible' }); + await recentDayElement.hover(); + selectedDateText = await basicsPage.bolusingSection.calendarDayhover.text(); + await basicsPage.bolusingSection.calendarDayhover.el.click(); + }); + await base_1.test.step('Then the daily chart is visible and correctly rendered', async () => { + const chartContainer = dailyPage.dailyChart.container; + await chartContainer.waitFor({ state: 'visible' }); + if (!selectedDateText) { + throw new Error('Selected date text is null'); + } + // Verify the selected date matches the displayed date + // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); + // Capture chart screenshot for visual regression + await (0, base_1.expect)(chartContainer).toHaveScreenshot('daily-chart-2.png'); + }); + }); + // Infusion Site Changes dashboard functionality + (0, base_1.test)('should display Infusion site changes dashboard when selecting a date from basics page', async ({ page, }) => { + const basicsPage = new BasicsPage_1.default(page); + const dailyPage = new DailyPage_1.default(page); + let selectedDateText; + await base_1.test.step('When the infusion site changes dashboard is visible', async () => { + // Verify dashboard title and initial state + // await expect(basicsPage.tubingPrimeSection.title).toBeVisible(); + // await expect(basicsPage.tubingPrimeSection.description).toHaveText( + // "We are using Fill Cannula to visualize your infusion site changes." + // ); + }); + await base_1.test.step('When testing Fill Cannula functionality', async () => { + // Verify radio button options + await basicsPage.tubingPrimeSection.settingsOption.fillCannula.waitFor({ + state: 'visible', + timeout: 60000, + }); + await (0, base_1.expect)(basicsPage.tubingPrimeSection.settingsOption.fillCannula).toBeVisible(); + await (0, base_1.expect)(basicsPage.tubingPrimeSection.settingsOption.fillTubing).toBeVisible(); + // Select Fill Cannula and verify highlighted days + await basicsPage.tubingPrimeSection.settingsOption.fillCannula.click(); + // // Verify duration indicator is visible + // await expect( + // basicsPage.tubingPrimeSection.durationIndicator + // ).toContainText("4 days"); + // Verify cannula icons are visible and tubing icons are not + await (0, base_1.expect)(basicsPage.tubingPrimeSection.cannulaIcons).toBeAttached(); + await (0, base_1.expect)(basicsPage.tubingPrimeSection.tubingIcons).not.toBeAttached(); + // Select a highlighted day + const highlightedDay = basicsPage.tubingPrimeSection.filledDay; + await highlightedDay.hover(); + selectedDateText = await basicsPage.tubingPrimeSection.calendarDayhover.text(); + await basicsPage.tubingPrimeSection.calendarDayhover.el.click(); + }); + await base_1.test.step('Then the daily chart shows correct cannula fill date', async () => { + const chartContainer = dailyPage.dailyChart.container; + await chartContainer.waitFor({ state: 'visible' }); + if (!selectedDateText) { + throw new Error('Selected date text is null'); + } + // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); + await (0, base_1.expect)(chartContainer).toHaveScreenshot('daily-chart-cannula.png'); + }); + // Return to basics page and test Fill Tubing Option + await base_1.test.step('When testing Fill Tubing functionality', async () => { + // Navigate back to basics + await base_1.test.step('When the navigation bar is visible', async () => { + await basicsPage.navigationBar.buttons.viewData.waitFor({ + state: 'visible', + }); + }); + // await basicsPage.navigationSubMenu.links.basics.click(); + await basicsPage.tubingPrimeSection.settings.waitFor({ + state: 'visible', + }); + // Click settings and select Fill Tubing + await basicsPage.tubingPrimeSection.settings.click(); + await basicsPage.tubingPrimeSection.settingsOption.fillTubing.click(); + // Verify filled tubing day is visible and cannula day is not + await (0, base_1.expect)(basicsPage.tubingPrimeSection.tubingIcons).toBeAttached(); + await (0, base_1.expect)(basicsPage.tubingPrimeSection.cannulaIcons).not.toBeAttached(); + // Click on the most recent day with tubing fill + const tubingDay = basicsPage.tubingPrimeSection.filledDay; + await tubingDay.hover(); + selectedDateText = await basicsPage.tubingPrimeSection.calendarDayhover.text(); + await basicsPage.tubingPrimeSection.calendarDayhover.el.click(); + }); + await base_1.test.step('Then the daily chart shows correct tubing fill date', async () => { + const chartContainer = dailyPage.dailyChart.container; + await chartContainer.waitFor({ state: 'visible' }); + if (!selectedDateText) { + throw new Error('Selected date text is null'); + } + // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); + await (0, base_1.expect)(chartContainer).toHaveScreenshot('daily-chart-tubing.png'); + }); + }); + // TODO: Previous test doesn't test values. Should we? :) + // Readings in range functionality + (0, base_1.test)('The hover over elements in sidebar shows correct values', async ({ page }) => { + // Stats for BGM + const expectedHeadersReadingInRange = [ + { header: 'Readings Below Range', value: 3 }, + { header: 'Readings Below Range', value: 0 }, + { header: 'Readings In Range', value: 71 }, + { header: 'Readings Above Range', value: 24 }, + { header: 'Readings Above Range', value: 2 }, + ]; + const basicsPage = new BasicsPage_1.default(page); + await base_1.test.step('When the navigation bar is visible', async () => { + await basicsPage.navigationBar.buttons.viewData.waitFor({ + state: 'visible', + }); + }); + // Other BGM tooltip functionality + await basicsPage.statsSidebar.toggleTo('BGM'); + for (let i = 0; i < 5; i += 1) { + const bar = basicsPage.statsSidebar.readingsInRange.hoverBar.nth(i); + const barLabel = basicsPage.statsSidebar.readingsInRange.hoverBarLabel.nth(i); + await base_1.test.step('When the user hovers over the Avg. Daily Readings In Range chart', async () => { + await bar.hover(); + }); + await base_1.test.step('Then the correct header is visible', async () => { + await base_1.expect + .soft(basicsPage.statsSidebar.readingsInRange.header) + .toContainText(expectedHeadersReadingInRange[i].header); + }); + await base_1.test.step('Then the correct value is visible', async () => { + await base_1.expect + .soft(barLabel) + .toContainText(expectedHeadersReadingInRange[i].value.toString()); + }); + } + // Stats for CGM + // Time in range functionality + const expectedHeadersTimeInRange = [ + { header: 'Time Below Range', value: 0.1 }, + { header: 'Time Below Range', value: 1 }, + { header: 'Time In Range', value: 90 }, + { header: 'Time Above Range', value: 9 }, + { header: 'Time Above Range', value: 0.3 }, + ]; + await basicsPage.statsSidebar.toggleTo('CGM'); + for (let i = 0; i < expectedHeadersTimeInRange.length; i += 1) { + const bar = basicsPage.statsSidebar.timeInRange.hoverBar.nth(i); + const barLabel = basicsPage.statsSidebar.timeInRange.hoverBarLabel.nth(i); + await base_1.test.step('When the user hovers over the Avg. Daily Time In Range chart', async () => { + await bar.hover(); + }); + await base_1.test.step('Then the correct header is visible', async () => { + await base_1.expect + .soft(basicsPage.statsSidebar.timeInRange.header) + .toContainText(expectedHeadersTimeInRange[i].header); + }); + await base_1.test.step('Then the correct value is visible', async () => { + await base_1.expect.soft(barLabel).toContainText(expectedHeadersTimeInRange[i].value.toString()); + }); + } + }); + // Other CGM tooltip functionality + (0, base_1.test)('other CGM tooltip functionality', async ({ page }) => { + const basicsPage = new BasicsPage_1.default(page); + await basicsPage.statsSidebar.toggleTo('CGM'); + const expectedHeadersTimeInRange = [ + { header: 'Basal Insulin', value: 14.7, percentage: 44 }, + { header: 'Bolus Insulin', value: 18.8, percentage: 56 }, + ]; + for (let i = 0; i < expectedHeadersTimeInRange.length; i += 1) { + const bar = basicsPage.statsSidebar.totalInsulin.hoverBar.nth(i); + const barLabel = basicsPage.statsSidebar.totalInsulin.hoverBarLabel.nth(i); + await base_1.test.step('When the user hovers over the Avg. Daily Total Insulin chart', async () => { + await bar.hover(); + }); + await base_1.test.step('Then the correct header is visible', async () => { + await base_1.expect + .soft(basicsPage.statsSidebar.timeInRange.header) + .toContainText(expectedHeadersTimeInRange[i].header); + }); + await base_1.test.step('Then the correct value is visible', async () => { + await base_1.expect.soft(barLabel).toContainText(expectedHeadersTimeInRange[i].value.toString()); + }); + } + }); +}); diff --git a/build/tests/personal/login.spec.js b/build/tests/personal/login.spec.js new file mode 100644 index 0000000..9855597 --- /dev/null +++ b/build/tests/personal/login.spec.js @@ -0,0 +1,66 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +// @ts-check +const base_1 = require("@fixtures/base"); +const LoginPage_1 = __importDefault(require("page-objects/LoginPage")); +const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); +const env_1 = __importDefault(require("../../utilities/env")); +// make sure we don't have any cookies or origins +base_1.test.use({ storageState: { cookies: [], origins: [] } }); +// Possible testcases: https://tidepool.atlassian.net/jira/software/c/projects/WEB/issues/?jql=project%20%3D%20%22WEB%22%20AND%20type%20%3D%20Test%20AND%20textfields%20~%20%22login%22%20ORDER%20BY%20created%20DESC +base_1.test.describe('Login into application', () => { + (0, base_1.test)('should work with valid credentials for clinician with multiple clinics', async ({ page, }) => { + const loginPage = new LoginPage_1.default(page); + await base_1.test.step('When user is logged into application', async () => { + await loginPage.goto(); + await loginPage.login(env_1.default.CLINICIAN_USERNAME, env_1.default.CLINICIAN_PASSWORD); + }); + await base_1.test.step('Then the user is redirected to workspaces page', async () => { + const workspacesPage = new WorkspacesPage_1.default(page); + await page.waitForURL(workspacesPage.url); + await (0, base_1.expect)(workspacesPage.header).toBeVisible(); + }); + }); + (0, base_1.test)('should show error message with invalid credentials', async ({ page }) => { + const loginPage = new LoginPage_1.default(page); + await base_1.test.step('When user attempts to login with invalid credentials', async () => { + await loginPage.goto(); + // Enter email + await page.fill('#username', 'invalid@email.com'); + await page.click('#kc-login'); + }); + await base_1.test.step('Then error message should be displayed', async () => { + // Wait for the error message to appear + await (0, base_1.expect)(page.locator('#input-error-username')).toBeVisible(); + await (0, base_1.expect)(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); + }); + }); + (0, base_1.test)('should validate email format', async ({ page }) => { + const loginPage = new LoginPage_1.default(page); + await base_1.test.step('When user attempts to login with invalid email format', async () => { + await loginPage.goto(); + // Enter invalid email format + await page.fill('#username', 'invalidemail'); + await page.click('#kc-login'); + }); + await base_1.test.step('Then email validation error should be displayed', async () => { + // Check for email validation error message + await (0, base_1.expect)(page.locator('#input-error-username')).toBeVisible(); + await (0, base_1.expect)(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); + }); + }); + (0, base_1.test)('should show error message with invalid credentials 1', async ({ page }) => { + const loginPage = new LoginPage_1.default(page); + await base_1.test.step('When user is logged into application', async () => { + await loginPage.goto(); + await loginPage.login(env_1.default.CLINICIAN_USERNAME, `${env_1.default.CLINICIAN_PASSWORD}1`); + }); + await base_1.test.step('Then error message should be displayed', async () => { + await (0, base_1.expect)(page.locator('#input-error')).toBeVisible(); + await (0, base_1.expect)(page.locator('#input-error')).toContainText('Invalid password.'); + }); + }); +}); diff --git a/build/utilities/annotations.js b/build/utilities/annotations.js new file mode 100644 index 0000000..528cbcc --- /dev/null +++ b/build/utilities/annotations.js @@ -0,0 +1,24 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = addTestAnnotations; +/** + * Add test annotations to the test info for JIRA integration + */ +function addTestAnnotations(testInfo, annotations) { + testInfo.annotations.push({ + type: 'test_key', + description: annotations.testKey, + }); + testInfo.annotations.push({ + type: 'test_summary', + description: annotations.testSummary, + }); + testInfo.annotations.push({ + type: 'requirements', + description: annotations.requirements, + }); + testInfo.annotations.push({ + type: 'test_description', + description: annotations.testDescription, + }); +} diff --git a/build/utilities/env.js b/build/utilities/env.js new file mode 100644 index 0000000..1c8b960 --- /dev/null +++ b/build/utilities/env.js @@ -0,0 +1,42 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const dotenv_1 = __importDefault(require("dotenv")); +const zod_1 = __importDefault(require("zod")); +dotenv_1.default.config(); +const envSchema = zod_1.default.object({ + BROWSERSTACK_USERNAME: zod_1.default.string().optional(), + BROWSERSTACK_ACCESS_KEY: zod_1.default.string().optional(), + PERSONAL_USERNAME: zod_1.default.string(), + PERSONAL_PASSWORD: zod_1.default.string(), + CLAIMED_USERNAME: zod_1.default.string(), + CLAIMED_PASSWORD: zod_1.default.string(), + SHARED_USERNAME: zod_1.default.string(), + SHARED_PASSWORD: zod_1.default.string(), + CLINICIAN_USERNAME: zod_1.default.string(), + CLINICIAN_PASSWORD: zod_1.default.string(), + TARGET_ENV: zod_1.default.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production', 'prd', 'int']), + XRAY_CLIENT_ID: zod_1.default.string().optional(), + XRAY_CLIENT_SECRET: zod_1.default.string().optional(), +}); +const env = envSchema.safeParse(process.env); +if (!env.success) { + console.error('āŒ Invalid environment variables:\n', env.error.format()); + throw new Error('Invalid environment variables. Check your .env file.'); +} +const URL_MAP = { + qa1: 'https://qa1.development.tidepool.org', + qa2: 'https://qa2.development.tidepool.org', + qa3: 'https://qa3.development.tidepool.org', + qa4: 'https://qa4.development.tidepool.org', + qa5: 'https://qa5.development.tidepool.org', + production: 'https://app.tidepool.org', + prd: 'https://app.tidepool.org', // Alias for production + int: 'https://int.development.tidepool.org', // Integration environment +}; +exports.default = { + ...env.data, + BASE_URL: URL_MAP[env.data.TARGET_ENV], +}; diff --git a/build/utilities/xray-json-reporter.js b/build/utilities/xray-json-reporter.js new file mode 100644 index 0000000..c6d7c4a --- /dev/null +++ b/build/utilities/xray-json-reporter.js @@ -0,0 +1,268 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const node_fs_1 = __importDefault(require("node:fs")); +const node_path_1 = __importDefault(require("node:path")); +const env_1 = __importDefault(require("./env")); +/** + * Unified Xray JSON Reporter for Playwright + * Maps rich Playwright test data to Xray's JSON format with step-by-step evidence + */ +class XrayJsonReporter { + constructor() { + this.styles = { + success: 'āœ…', + error: 'āŒ', + info: 'ā„¹ļø', + warning: 'ā›”ļø', + upload: 'šŸš€', + test: '🧪', + separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + }; + this.startTime = ''; + this.endTime = ''; + } + /** + * Authenticates with Xray API using client credentials + */ + async authenticateWithXray() { + try { + console.log(`${this.styles.info} Authenticating with Xray...`); + const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: env_1.default.XRAY_CLIENT_ID, + client_secret: env_1.default.XRAY_CLIENT_SECRET, + }), + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + } + const token = await response.text(); + console.log(`${this.styles.success} Successfully authenticated with Xray`); + return token.replace(/"/g, ''); // Remove quotes from token + } + catch (error) { + console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); + throw error; + } + } + /** + * Converts file to base64 string for Xray evidence + */ + async fileToBase64(filePath) { + try { + const fileBuffer = node_fs_1.default.readFileSync(filePath); + return fileBuffer.toString('base64'); + } + catch (error) { + console.warn(`${this.styles.warning} Could not read file ${filePath}:`, error); + return ''; + } + } + /** + * Extracts step information from test annotations + */ + async extractSteps(annotations, attachments) { + const steps = []; + const stepAnnotations = annotations.filter(ann => ann.type.startsWith('Step Duration:')); + for (const stepAnn of stepAnnotations) { + const stepName = stepAnn.type.replace('Step Duration: ', ''); + const duration = stepAnn.description; + // Find associated step attachments + const stepAttachments = attachments.filter(att => att.name.toLowerCase().includes(stepName.toLowerCase().substring(0, 20))); + const step = { + action: stepName, + data: `Duration: ${duration}`, + result: stepName.includes('Then') ? stepName : undefined, + status: 'PASS', // Will be updated based on test result + evidences: [] + }; + // Add evidence for this step + for (const attachment of stepAttachments) { + if (attachment.path && node_fs_1.default.existsSync(attachment.path)) { + step.evidences?.push({ + data: await this.fileToBase64(attachment.path), + filename: node_path_1.default.basename(attachment.path), + contentType: attachment.contentType || 'application/octet-stream' + }); + } + } + steps.push(step); + } + return steps; + } + /** + * Maps Playwright test result to Xray test format + */ + async mapPlaywrightTestToXray(testCase, testResult) { + const tags = testCase.tags || []; + const annotations = testResult.annotations || []; + const attachments = testResult.attachments || []; + // Extract steps from annotations + const steps = await this.extractSteps(annotations, attachments); + // Mark failed steps if test failed + if (testResult.status !== 'passed' && steps.length > 0) { + steps[steps.length - 1].status = 'FAIL'; + steps[steps.length - 1].actualResult = testResult.error?.message || 'Test failed'; + } + // Collect test-level evidence (screenshots, videos) + const testEvidences = []; + for (const attachment of attachments) { + if (attachment.path && node_fs_1.default.existsSync(attachment.path)) { + // Add main test evidence (final screenshots, videos, etc.) + if (attachment.name.includes('screenshot') || attachment.name.includes('video')) { + testEvidences.push({ + data: await this.fileToBase64(attachment.path), + filename: attachment.name, + contentType: attachment.contentType || 'application/octet-stream' + }); + } + } + } + const xrayTest = { + testInfo: { + summary: testCase.title, + type: 'Generic', + projectKey: 'XT', // Could be made configurable + labels: tags + }, + status: testResult.status === 'passed' ? 'PASS' : + testResult.status === 'skipped' ? 'PENDING' : 'FAIL', + comment: testResult.error?.message, + evidences: testEvidences, + steps: steps.length > 0 ? steps : undefined + }; + return xrayTest; + } + /** + * Converts Playwright JSON results to Xray format + */ + async convertPlaywrightJsonToXray(playwrightJsonPath) { + const jsonContent = node_fs_1.default.readFileSync(playwrightJsonPath, 'utf8'); + const playwrightResult = JSON.parse(jsonContent); + const tests = []; + // Process all test suites + for (const suite of playwrightResult.suites || []) { + await this.processSuite(suite, tests); + } + const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; + const targetEnv = process.env.TARGET_ENV || 'qa1'; + const xrayResult = { + info: { + summary: `Playwright Test Execution - ${new Date().toISOString()}`, + description: `Automated test execution for ${targetEnv} environment`, + version: '1.0', + testExecutionKey: testExecKey !== 'none' ? testExecKey : undefined, + startDate: playwrightResult.stats?.startTime || new Date().toISOString(), + finishDate: new Date(new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + + (playwrightResult.stats?.duration || 0)).toISOString(), + testEnvironments: [targetEnv] + }, + tests + }; + return xrayResult; + } + /** + * Recursively processes test suites + */ + async processSuite(suite, tests) { + // Process specs in this suite + for (const spec of suite.specs || []) { + for (const test of spec.tests || []) { + for (const result of test.results || []) { + const xrayTest = await this.mapPlaywrightTestToXray(spec, result); + tests.push(xrayTest); + } + } + } + // Process nested suites + for (const nestedSuite of suite.suites || []) { + await this.processSuite(nestedSuite, tests); + } + } + /** + * Uploads Xray execution result to Xray + */ + async uploadToXray(xrayResult) { + try { + console.log(`${this.styles.info} Uploading test execution to Xray...`); + const token = await this.authenticateWithXray(); + const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(xrayResult), + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + } + const result = await response.json(); + console.log(`${this.styles.success} Successfully uploaded to Xray. Execution Key: ${result.key}`); + } + catch (error) { + console.error(`${this.styles.error} Failed to upload to Xray:`, error); + throw error; + } + } + /** + * Main method to process and upload results + */ + async processAndUpload(playwrightJsonPath) { + if (!(env_1.default.XRAY_CLIENT_ID && env_1.default.XRAY_CLIENT_SECRET)) { + console.log(`${this.styles.warning} No Xray credentials found, skipping upload to JIRA Xray`); + return; + } + try { + console.log(`${this.styles.info} Processing Playwright results...`); + const xrayResult = await this.convertPlaywrightJsonToXray(playwrightJsonPath); + // Save converted result for debugging + node_fs_1.default.writeFileSync('test-results/xray-execution.json', JSON.stringify(xrayResult, null, 2)); + await this.uploadToXray(xrayResult); + console.log(`${this.styles.upload} Xray upload completed successfully`); + } + catch (error) { + console.error(`${this.styles.error} Failed to process and upload:`, error); + throw error; + } + } + /** + * Reporter lifecycle methods for direct Playwright integration + */ + onBegin(_config, suite) { + this.startTime = new Date().toISOString(); + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); + console.log(`${this.styles.separator}\n`); + } + onTestBegin(test, _result) { + console.log(`${this.styles.test} Starting: ${test.title}`); + } + onTestEnd(test, result) { + const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; + console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); + } + async onEnd(result) { + this.endTime = new Date().toISOString(); + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.info} Test Run Summary:`); + console.log(`Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`); + console.log(`Duration: ${result.duration}ms`); + console.log(`${this.styles.separator}\n`); + // Auto-upload if JSON results are available + const jsonPath = 'test-results/last-run.json'; + if (node_fs_1.default.existsSync(jsonPath)) { + await this.processAndUpload(jsonPath); + } + } +} +exports.default = XrayJsonReporter; diff --git a/build/utilities/xray-reporter.js b/build/utilities/xray-reporter.js new file mode 100644 index 0000000..0532c49 --- /dev/null +++ b/build/utilities/xray-reporter.js @@ -0,0 +1,134 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const node_fs_1 = __importDefault(require("node:fs")); +const env_1 = __importDefault(require("./env")); +/** + * Reporter class for uploading test results to Xray + */ +class XRayReporter { + constructor() { + this.styles = { + success: 'āœ…', + error: 'āŒ', + info: 'ā„¹ļø', + warning: 'ā›”ļø', + upload: 'šŸš€', + test: '🧪', + separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + }; + } + /** + * Authenticates with Xray API using client credentials + * @returns {Promise} The authentication token + * @throws {Error} If authentication fails + */ + async authenticateWithXray() { + try { + console.log(`${this.styles.info} Authenticating with Xray...`); + const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: env_1.default.XRAY_CLIENT_ID, + client_secret: env_1.default.XRAY_CLIENT_SECRET, + }), + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}, ${response.body}`); + } + const data = await response.json(); + console.log(`${this.styles.success} Successfully authenticated with Xray`); + return data.token; + } + catch (error) { + console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); + throw error; + } + } + /** + * Uploads test results to Xray + * @param {string} token - The authentication token + * @param {string} xmlContent - The JUnit XML content to upload + * @returns {Promise} + * @throws {Error} If upload fails + */ + async uploadTestResults(token, xmlContent) { + try { + console.log(`${this.styles.info} Uploading test results to Xray...`); + const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution/junit?projectKey=XT&testPlanKey=XT-380', { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + Authorization: `Bearer ${token}`, + }, + body: xmlContent, + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + } + console.log(`${this.styles.success} Successfully uploaded test results to Xray`); + } + catch (error) { + console.error(`${this.styles.error} Failed to upload test results to Xray:`, error); + throw error; + } + } + /** + * Called when test run begins + * @param suite - Test suite object containing all tests + */ + onBegin(_config, suite) { + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); + console.log(`${this.styles.separator}\n`); + } + /** + * Called when a test begins + * @param test - Test case object + */ + onTestBegin(test, _result) { + console.log(`${this.styles.test} Starting: ${test.title}`); + } + /** + * Called when a test ends + * @param {Object} test - Test case object + * @param {Object} result - Test result object containing status and other details + */ + onTestEnd(test, result) { + const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; + console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); + } + /** + * Called when all tests have finished + * @param result - Full test run result object containing status and duration + */ + async onEnd(result) { + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.info} Test Run Summary:`); + console.log(`Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`); + console.log(`Duration: ${result.duration}ms`); + console.log(`${this.styles.separator}\n`); + if (!(env_1.default.XRAY_CLIENT_ID || env_1.default.XRAY_CLIENT_SECRET)) { + console.log(`${this.styles.warning} No Xray client ID or secret found, skipping upload to JIRA Xray`); + return; + } + try { + console.log(`${this.styles.info} Reading test results file...`); + const testResults = node_fs_1.default.readFileSync('./test-results/test-results.xml', 'utf8'); + const token = await this.authenticateWithXray(); + await this.uploadTestResults(token, testResults); + console.log(`${this.styles.upload} Successfully uploaded test results to Xray`); + } + catch (error) { + console.error(`${this.styles.error} Failed to process test results:`, error); + } + console.log(`${this.styles.separator}\n`); + } +} +exports.default = XRayReporter; diff --git a/dist/endpoint-schema/auth-endpoints.d.ts b/dist/endpoint-schema/auth-endpoints.d.ts new file mode 100644 index 0000000..a130449 --- /dev/null +++ b/dist/endpoint-schema/auth-endpoints.d.ts @@ -0,0 +1,13 @@ +import { EndpointSchema } from './profile-endpoints'; +/** + * Schema for user authentication login + */ +export declare const loginSchema: EndpointSchema; +/** + * Schema for user logout + */ +export declare const logoutSchema: EndpointSchema; +/** + * Schema for token refresh + */ +export declare const refreshTokenSchema: EndpointSchema; diff --git a/dist/endpoint-schema/auth-endpoints.js b/dist/endpoint-schema/auth-endpoints.js new file mode 100644 index 0000000..8eff4cc --- /dev/null +++ b/dist/endpoint-schema/auth-endpoints.js @@ -0,0 +1,50 @@ +/** + * Schema for user authentication login + */ +export const loginSchema = { + url: /\/auth\/login$/, + method: 'POST', + expectedStatus: 200, + requestSchema: { + username: 'string', + password: 'string', + }, + responseSchema: { + userid: 'string', + username: 'string', + emails: 'object', + roles: 'object', + }, + validationFields: ['userid', 'username', 'emails', 'roles'], + requiredFields: [ + 'userid', // Auth endpoints require userid instead of fullName + 'username', // Username is also critical for auth + ], +}; +/** + * Schema for user logout + */ +export const logoutSchema = { + url: /\/auth\/logout$/, + method: 'POST', + expectedStatus: 200, + validationFields: [ + // Logout typically doesn't return data to validate + ], +}; +/** + * Schema for token refresh + */ +export const refreshTokenSchema = { + url: /\/auth\/token$/, + method: 'POST', + expectedStatus: 200, + responseSchema: { + userid: 'string', + username: 'string', + }, + validationFields: ['userid', 'username'], + requiredFields: [ + 'userid', // Token refresh must return userid + ], +}; diff --git a/dist/endpoint-schema/endpoint-registry.d.ts b/dist/endpoint-schema/endpoint-registry.d.ts new file mode 100644 index 0000000..f29522f --- /dev/null +++ b/dist/endpoint-schema/endpoint-registry.d.ts @@ -0,0 +1,34 @@ +import { EndpointSchema } from './profile-endpoints'; +/** + * Centralized endpoint registry for all API validation + * This allows network helpers to work with any endpoint by name + * + * ADDING NEW ENDPOINTS: + * 1. Define the endpoint schema in the appropriate *-endpoints.ts file + * 2. Include validationFields array for data consistency checking + * 3. Add the endpoint to this registry + * 4. The validationFields will automatically be used by NetworkHelper methods + * + * VALIDATION FIELDS: + * - Use dot notation for nested fields (e.g., 'patient.fullName') + * - Include all fields that should be validated for data consistency + * - Different endpoints can have different validation requirements + * - Fields are endpoint-specific and stored in the schema definition + */ +export declare const ENDPOINT_REGISTRY: { + readonly 'profile-metadata-get': EndpointSchema; + readonly 'profile-metadata-put': EndpointSchema; + readonly 'profile-patient-data-get': EndpointSchema; + readonly 'profile-metrics-get': EndpointSchema; + readonly 'profile-message-notes-get': EndpointSchema; + readonly 'patient-data-get': EndpointSchema; + readonly 'patient-data-upload': EndpointSchema; + readonly 'auth-login': EndpointSchema; + readonly 'auth-logout': EndpointSchema; + readonly 'auth-refresh-token': EndpointSchema; +}; +export type EndpointName = keyof typeof ENDPOINT_REGISTRY; +/** + * Get endpoint schema by name + */ +export declare function getEndpointSchema(endpointName: EndpointName): EndpointSchema; diff --git a/dist/endpoint-schema/endpoint-registry.js b/dist/endpoint-schema/endpoint-registry.js new file mode 100644 index 0000000..6e64934 --- /dev/null +++ b/dist/endpoint-schema/endpoint-registry.js @@ -0,0 +1,48 @@ +import { getProfileMetadataSchema, putProfileMetadataSchema, getPatientDataSchema as profileGetPatientDataSchema, getMetricsSchema as profileGetMetricsSchema, getMessageNotesSchema as profileGetMessageNotesSchema, } from './profile-endpoints'; +import { getPatientDataSchema, uploadPatientDataSchema } from './patient-data-endpoints'; +import { loginSchema, logoutSchema, refreshTokenSchema } from './auth-endpoints'; +// Import other endpoint schemas as they're created +/** + * Centralized endpoint registry for all API validation + * This allows network helpers to work with any endpoint by name + * + * ADDING NEW ENDPOINTS: + * 1. Define the endpoint schema in the appropriate *-endpoints.ts file + * 2. Include validationFields array for data consistency checking + * 3. Add the endpoint to this registry + * 4. The validationFields will automatically be used by NetworkHelper methods + * + * VALIDATION FIELDS: + * - Use dot notation for nested fields (e.g., 'patient.fullName') + * - Include all fields that should be validated for data consistency + * - Different endpoints can have different validation requirements + * - Fields are endpoint-specific and stored in the schema definition + */ +export const ENDPOINT_REGISTRY = { + // Profile endpoints + 'profile-metadata-get': getProfileMetadataSchema, + 'profile-metadata-put': putProfileMetadataSchema, + 'profile-patient-data-get': profileGetPatientDataSchema, + 'profile-metrics-get': profileGetMetricsSchema, + 'profile-message-notes-get': profileGetMessageNotesSchema, + // Patient data endpoints + 'patient-data-get': getPatientDataSchema, + 'patient-data-upload': uploadPatientDataSchema, + // Auth endpoints + 'auth-login': loginSchema, + 'auth-logout': logoutSchema, + 'auth-refresh-token': refreshTokenSchema, + // Add more endpoints as needed... + // 'clinic-get': clinicGetSchema, + // 'clinic-update': clinicUpdateSchema, +}; +/** + * Get endpoint schema by name + */ +export function getEndpointSchema(endpointName) { + const schema = ENDPOINT_REGISTRY[endpointName]; + if (!schema) { + throw new Error(`Endpoint schema not found: ${endpointName}`); + } + return schema; +} diff --git a/dist/endpoint-schema/patient-data-endpoints.d.ts b/dist/endpoint-schema/patient-data-endpoints.d.ts new file mode 100644 index 0000000..5562b5b --- /dev/null +++ b/dist/endpoint-schema/patient-data-endpoints.d.ts @@ -0,0 +1,13 @@ +import { EndpointSchema } from './profile-endpoints'; +/** + * Schema for patient data GET endpoint + */ +export declare const getPatientDataSchema: EndpointSchema; +/** + * Schema for uploading patient data + */ +export declare const uploadPatientDataSchema: EndpointSchema; +/** + * Schema for getting patient settings + */ +export declare const getPatientSettingsSchema: EndpointSchema; diff --git a/dist/endpoint-schema/patient-data-endpoints.js b/dist/endpoint-schema/patient-data-endpoints.js new file mode 100644 index 0000000..fa48d94 --- /dev/null +++ b/dist/endpoint-schema/patient-data-endpoints.js @@ -0,0 +1,53 @@ +/** + * Schema for patient data GET endpoint + */ +export const getPatientDataSchema = { + url: /\/v1\/patients\/[^/]+\/data$/, + method: 'GET', + expectedStatus: 200, + responseSchema: { + data: 'object', + meta: { + count: 'number', + size: 'number', + }, + }, + validationFields: ['data', 'meta.count', 'meta.size'], +}; +/** + * Schema for uploading patient data + */ +export const uploadPatientDataSchema = { + url: /\/v1\/patients\/[^/]+\/data$/, + method: 'POST', + expectedStatus: 201, + requestSchema: { + data: 'object', + deviceId: 'string', + uploadId: 'string', + }, + responseSchema: { + id: 'string', + success: 'boolean', + }, + validationFields: ['id', 'success'], +}; +/** + * Schema for getting patient settings + */ +export const getPatientSettingsSchema = { + url: /\/v1\/patients\/[^/]+\/settings$/, + method: 'GET', + expectedStatus: 200, + responseSchema: { + bgTarget: { + low: 'number', + high: 'number', + }, + units: { + bg: 'string', + }, + siteChangeSource: 'string', + }, + validationFields: ['bgTarget.low', 'bgTarget.high', 'units.bg', 'siteChangeSource'], +}; diff --git a/dist/endpoint-schema/profile-endpoints.d.ts b/dist/endpoint-schema/profile-endpoints.d.ts new file mode 100644 index 0000000..d1d3739 --- /dev/null +++ b/dist/endpoint-schema/profile-endpoints.d.ts @@ -0,0 +1,32 @@ +/** + * Schema definition for API endpoints + */ +export interface EndpointSchema { + url: string | RegExp; + method: string; + expectedStatus?: number; + responseSchema?: any; + requestSchema?: any; + validationFields?: string[]; + requiredFields?: string[]; +} +/** + * Schema for profile metadata GET endpoint + */ +export declare const getProfileMetadataSchema: EndpointSchema; +/** + * Schema for profile metadata PUT endpoint + */ +export declare const putProfileMetadataSchema: EndpointSchema; +/** + * Schema for patient data GET endpoint + */ +export declare const getPatientDataSchema: EndpointSchema; +/** + * Schema for metrics/analytics endpoint + */ +export declare const getMetricsSchema: EndpointSchema; +/** + * Schema for message notes endpoint + */ +export declare const getMessageNotesSchema: EndpointSchema; diff --git a/dist/endpoint-schema/profile-endpoints.js b/dist/endpoint-schema/profile-endpoints.js new file mode 100644 index 0000000..3e2101c --- /dev/null +++ b/dist/endpoint-schema/profile-endpoints.js @@ -0,0 +1,104 @@ +/** + * Schema for profile metadata GET endpoint + */ +export const getProfileMetadataSchema = { + url: /\/metadata\/.*\/profile$/, + method: 'GET', + expectedStatus: 200, + responseSchema: { + fullName: 'string', + patient: 'object', + }, + validationFields: [ + 'fullName', + 'patient.fullName', + 'patient.birthday', + 'patient.diagnosisDate', + 'patient.diagnosisType', + 'patient.targetDevices', + 'patient.targetTimezone', + 'patient.about', + 'patient.isOtherPerson', + 'patient.mrn', + 'patient.biologicalSex', + 'email', + 'patient.email', + 'patient.emails', + 'emails', + ], + requiredFields: [ + 'fullName', // Profile endpoint must have fullName + ], +}; +/** + * Schema for profile metadata PUT endpoint + */ +export const putProfileMetadataSchema = { + url: /\/metadata\/.*\/profile$/, + method: 'PUT', + expectedStatus: 200, + requestSchema: { + fullName: 'string', + patient: 'object', + }, + responseSchema: { + fullName: 'string', + patient: 'object', + }, + validationFields: [ + 'fullName', + 'patient.fullName', + 'patient.birthday', + 'patient.diagnosisDate', + 'patient.diagnosisType', + 'patient.targetDevices', + 'patient.targetTimezone', + 'patient.about', + 'patient.isOtherPerson', + 'patient.mrn', + 'patient.biologicalSex', + 'email', + 'patient.email', + 'patient.emails', + 'emails', + ], + requiredFields: [ + 'fullName', // Profile endpoint must have fullName + ], +}; +/** + * Schema for patient data GET endpoint + */ +export const getPatientDataSchema = { + url: /\/data\/[^/]+\?.*$/, + method: 'GET', + expectedStatus: 200, + responseSchema: { + // Patient data array - structure will vary + }, + validationFields: [ + // Data array validation fields would go here based on specific data types + ], +}; +/** + * Schema for metrics/analytics endpoint + */ +export const getMetricsSchema = { + url: /\/metrics\/thisuser\/.*$/, + method: 'GET', + expectedStatus: 200, + validationFields: [ + // Metrics-specific validation fields would go here + ], +}; +/** + * Schema for message notes endpoint + */ +export const getMessageNotesSchema = { + url: /\/message\/notes\/[^/]+\?.*$/, + method: 'GET', + expectedStatus: 200, // We'll handle 404 as acceptable in the validation logic + validationFields: [ + // Message notes validation fields would go here + ], +}; diff --git a/dist/page-objects/LoginPage.d.ts b/dist/page-objects/LoginPage.d.ts new file mode 100644 index 0000000..8a0e079 --- /dev/null +++ b/dist/page-objects/LoginPage.d.ts @@ -0,0 +1,32 @@ +import { Locator, Page } from '@playwright/test'; +/** + * @class + * @property {Page} page + * @property {Locator} emailInput + * @property {Locator} nextButton + * @property {Locator} passwordInput + * @property {Locator} loginButton + */ +export default class LoginPage { + page: Page; + emailInput: Locator; + nextButton: Locator; + passwordInput: Locator; + loginButton: Locator; + /** + * @param {Page} page + */ + constructor(page: Page); + /** + * Navigate to the login page + * @returns {Promise} + */ + goto(): Promise; + /** + * Login to the application + * @param {string} email - User's email + * @param {string} password - User's password + * @returns {Promise} + */ + login(email: string, password: string): Promise; +} diff --git a/dist/page-objects/LoginPage.js b/dist/page-objects/LoginPage.js new file mode 100644 index 0000000..0d3b7c3 --- /dev/null +++ b/dist/page-objects/LoginPage.js @@ -0,0 +1,41 @@ +/** + * @class + * @property {Page} page + * @property {Locator} emailInput + * @property {Locator} nextButton + * @property {Locator} passwordInput + * @property {Locator} loginButton + */ +export default class LoginPage { + /** + * @param {Page} page + */ + constructor(page) { + this.page = page; + this.emailInput = page.getByRole('textbox', { name: 'Email' }); + this.nextButton = page.getByRole('button', { name: 'Next' }); + this.passwordInput = page.getByRole('textbox', { name: 'Password' }); + this.loginButton = page.getByRole('button', { name: 'Log In' }); + } + /** + * Navigate to the login page + * @returns {Promise} + */ + async goto() { + await this.page.goto(`/`); + } + /** + * Login to the application + * @param {string} email - User's email + * @param {string} password - User's password + * @returns {Promise} + */ + // @step("When the user logs in to the application") + async login(email, password) { + await this.emailInput.fill(email); + await this.nextButton.click(); + await this.passwordInput.fill(password); + await this.loginButton.click(); + await this.page.setViewportSize({ width: 1920, height: 1080 }); + } +} diff --git a/dist/page-objects/account/AccountNavigation.d.ts b/dist/page-objects/account/AccountNavigation.d.ts new file mode 100644 index 0000000..eabf680 --- /dev/null +++ b/dist/page-objects/account/AccountNavigation.d.ts @@ -0,0 +1,18 @@ +import { Locator, Page } from '@playwright/test'; +export interface AccountNavVerify { + name: string; + link: Locator; + verifyURL: string; + verifyElement: Locator; + closeButton?: Locator; +} +export default class AccountNav { + readonly page: Page; + readonly pages: Record<'AccountNav' | 'PrivateWorkspace' | 'AccountSettings' | 'ManageWorkspaces' | 'Logout', AccountNavVerify>; + constructor(page: Page); + /** + * Navigate to a page in the account navigation menu by key. + * Example: await accountNav.navigateTo('AccountSettings'); + */ + navigateTo(pageKey: keyof AccountNav['pages']): Promise; +} diff --git a/dist/page-objects/account/AccountNavigation.js b/dist/page-objects/account/AccountNavigation.js new file mode 100644 index 0000000..ef4dfe6 --- /dev/null +++ b/dist/page-objects/account/AccountNavigation.js @@ -0,0 +1,59 @@ +export default class AccountNav { + constructor(page) { + this.page = page; + this.pages = { + AccountNav: { + name: 'AccountNav', + link: page.locator('button#navigation-menu-trigger'), // Use exact ID to identify menu trigger + verifyURL: '', + verifyElement: page + .locator('button.navigation-menu-option') + .filter({ hasText: 'Private Workspace' }), + }, + PrivateWorkspace: { + name: 'PrivateWorkspace', + link: page + .locator('button.navigation-menu-option') + .filter({ hasText: 'Private Workspace' }), + verifyURL: 'workspaces', + verifyElement: page.getByText('View data for:'), + }, + AccountSettings: { + name: 'AccountSettings', + link: page + .locator('#navigationMenu button.navigation-menu-option') + .filter({ hasText: 'Account Settings' }), + verifyURL: 'account', + verifyElement: page.locator('.profile-subnav-title').getByText('Account'), // Target the specific Account title element + }, + ManageWorkspaces: { + name: 'ManageWorkspaces', + link: page + .locator('#navigationMenu button.navigation-menu-option') + .filter({ hasText: 'Manage Workspaces' }), + verifyURL: 'workspaces', + verifyElement: page.getByText('Welcome To Tidepool'), // Should land back on the workspace selection page + }, + Logout: { + name: 'Logout', + link: page + .locator('#navigationMenu button.navigation-menu-option') + .filter({ hasText: 'Logout' }), + verifyURL: 'login', + verifyElement: page.getByRole('heading', { name: 'Log in to Tidepool' }), + }, + }; + } + /** + * Navigate to a page in the account navigation menu by key. + * Example: await accountNav.navigateTo('AccountSettings'); + */ + async navigateTo(pageKey) { + // Always open the navigation menu first + await this.pages.AccountNav.link.click(); + // Then click the desired page + await this.pages[pageKey].link.click(); + // Wait for the verification element to appear + await this.pages[pageKey].verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + } +} diff --git a/dist/page-objects/account/AccountSettingsPage.d.ts b/dist/page-objects/account/AccountSettingsPage.d.ts new file mode 100644 index 0000000..6250bf8 --- /dev/null +++ b/dist/page-objects/account/AccountSettingsPage.d.ts @@ -0,0 +1,9 @@ +import { Page, Locator } from '@playwright/test'; +export declare class AccountSettingsPage { + readonly page: Page; + readonly emailInput: Locator; + readonly saveButton: Locator; + readonly saveConfirm: Locator; + constructor(page: Page); +} +export default AccountSettingsPage; diff --git a/dist/page-objects/account/AccountSettingsPage.js b/dist/page-objects/account/AccountSettingsPage.js new file mode 100644 index 0000000..2247c70 --- /dev/null +++ b/dist/page-objects/account/AccountSettingsPage.js @@ -0,0 +1,9 @@ +export class AccountSettingsPage { + constructor(page) { + this.page = page; + this.emailInput = page.getByRole('textbox', { name: 'Email' }); + this.saveButton = page.getByRole('button', { name: /save/i }); + this.saveConfirm = page.getByText(/All Changes Saved/i); + } +} +export default AccountSettingsPage; diff --git a/dist/page-objects/clinician/ClinicCreationPage.d.ts b/dist/page-objects/clinician/ClinicCreationPage.d.ts new file mode 100644 index 0000000..b21595a --- /dev/null +++ b/dist/page-objects/clinician/ClinicCreationPage.d.ts @@ -0,0 +1,55 @@ +import { Locator, Page } from '@playwright/test'; +export default class ClinicCreationPage { + page: Page; + url: string; + pageHeader: Locator; + pageDescription: Locator; + clinicNameInput: Locator; + teamTypeDropdown: Locator; + countryDropdown: Locator; + stateDropdown: Locator; + addressInput: Locator; + cityInput: Locator; + zipCodeInput: Locator; + websiteInput: Locator; + mgdlRadio: Locator; + mmolRadio: Locator; + adminAcknowledgeCheckbox: Locator; + backButton: Locator; + createWorkspaceButton: Locator; + constructor(page: Page); + /** + * Navigate to the clinic creation page + */ + goto(): Promise; + /** + * Fill the clinic creation form with required information + * @param clinicName - Name of the clinic + * @param teamType - Type of the team + * @param state - State (for US clinics) + * @param address - Street address + * @param city - City name + * @param zipCode - Zip/Postal code + * @param website - Optional website URL + */ + fillClinicForm({ clinicName, teamType, state, address, city, zipCode, website, }: { + clinicName: string; + teamType?: string; + state?: string; + address?: string; + city?: string; + zipCode?: string; + website?: string; + }): Promise; + /** + * Select blood glucose units + * @param unit - "mg/dL" or "mmol/L" + */ + selectBloodGlucoseUnit(unit: 'mg/dL' | 'mmol/L'): Promise; + /** + * Create a clinic by filling the form and submitting + * @param clinicName - Name of the clinic to create (required) + * @param formData - Optional form data (uses defaults if not provided) + */ + createClinic(clinicName: string, formData?: Omit[0], 'clinicName'>): Promise; +} diff --git a/dist/page-objects/clinician/ClinicCreationPage.js b/dist/page-objects/clinician/ClinicCreationPage.js new file mode 100644 index 0000000..4a0a94f --- /dev/null +++ b/dist/page-objects/clinician/ClinicCreationPage.js @@ -0,0 +1,81 @@ +export default class ClinicCreationPage { + constructor(page) { + this.url = '/clinic-details/new'; + this.page = page; + // Page header elements + this.pageHeader = page.getByText('Create your Clinic Workspace'); + this.pageDescription = page.getByText('The information below will be displayed along with your name'); + // Form input fields + this.clinicNameInput = page.getByLabel('Clinic Name'); + this.teamTypeDropdown = page.getByRole('combobox', { name: 'What best describes your team?' }); + this.countryDropdown = page.getByRole('combobox', { name: 'Country' }); + this.stateDropdown = page.getByRole('combobox', { name: 'State' }); + this.addressInput = page.getByLabel('Address'); + this.cityInput = page.getByLabel('City'); + this.zipCodeInput = page.getByLabel('Zip code'); + this.websiteInput = page.getByLabel('Website (optional)'); + // Blood glucose units radio buttons + this.mgdlRadio = page.getByLabel('mg/dL'); + this.mmolRadio = page.getByLabel('mmol/L'); + // Acknowledgement checkbox + this.adminAcknowledgeCheckbox = page.getByRole('checkbox', { + name: 'By creating this clinic, your Tidepool account will become the default administrator', + }); + // Action buttons + this.backButton = page.getByRole('button', { name: 'Back' }); + this.createWorkspaceButton = page.getByRole('button', { name: 'Create Workspace' }); + } + /** + * Navigate to the clinic creation page + */ + async goto() { + await this.page.goto(this.url); + } + /** + * Fill the clinic creation form with required information + * @param clinicName - Name of the clinic + * @param teamType - Type of the team + * @param state - State (for US clinics) + * @param address - Street address + * @param city - City name + * @param zipCode - Zip/Postal code + * @param website - Optional website URL + */ + async fillClinicForm({ clinicName, teamType = 'Provider Practice', state = 'California', address = '123 Test Street', city = 'Test City', zipCode = '12345', website = '', }) { + // Fill in clinic name + await this.clinicNameInput.fill(clinicName); + // Select team type + await this.teamTypeDropdown.selectOption(teamType); + // Select state (US is selected by default) + await this.stateDropdown.selectOption(state); + // Fill in address details + await this.addressInput.fill(address); + await this.cityInput.fill(city); + await this.zipCodeInput.fill(zipCode); + // Fill in optional website if provided + if (website) { + await this.websiteInput.fill(website); + } + } + /** + * Select blood glucose units + * @param unit - "mg/dL" or "mmol/L" + */ + async selectBloodGlucoseUnit(unit) { + if (unit === 'mg/dL') { + await this.mgdlRadio.check(); + } + else { + await this.mmolRadio.check(); + } + } + /** + * Create a clinic by filling the form and submitting + * @param clinicName - Name of the clinic to create (required) + * @param formData - Optional form data (uses defaults if not provided) + */ + async createClinic(clinicName, formData) { + await this.fillClinicForm({ clinicName, ...formData }); + await this.createWorkspaceButton.click(); + } +} diff --git a/dist/page-objects/clinician/ClinicianDashboardPage.d.ts b/dist/page-objects/clinician/ClinicianDashboardPage.d.ts new file mode 100644 index 0000000..5f1113a --- /dev/null +++ b/dist/page-objects/clinician/ClinicianDashboardPage.d.ts @@ -0,0 +1,46 @@ +import { Locator, Page } from '@playwright/test'; +declare class ClinicianDashboardPage { + page: Page; + url: string; + name: string; + readonly addNewPatientButton: Locator; + readonly searchInput: Locator; + readonly patientListTable: Locator; + readonly addPatientDialog: Locator; + readonly addPatientDialog_fullNameInput: Locator; + readonly addPatientDialog_birthdateInput: Locator; + readonly addPatientDialog_addButton: Locator; + readonly bringDataDialog: Locator; + readonly bringDataDialog_doneButton: Locator; + constructor(page: Page); + /** + * Opens the Add Patient dialog and fills in the patient details. + * @param name - The full name of the patient. + * @param birthdate - The birthdate of the patient (e.g., MM/DD/YYYY). + */ + openAndFillAddPatientDialog(name: string, birthdate: string): Promise; + /** + * Clicks the Add Patient button in the dialog to submit the new patient. + */ + submitAddPatientDialog(): Promise; + /** + * Closes the Bring Data into Tidepool dialog by clicking Done. + */ + closeBringDataDialog(): Promise; + /** + * Searches for a patient in the list. + * @param name - The name of the patient to search for. + */ + searchForPatient(name: string): Promise; + /** + * Gets the locator for a patient cell in the table by name. + * @param name - The name of the patient. + * @returns Locator for the table cell containing the patient's name. + */ + getPatientCellByName(name: string): Locator; + /** + * Waits for the main elements of the Clinic Workspace page to be visible. + */ + waitForLoadState(): Promise; +} +export default ClinicianDashboardPage; diff --git a/dist/page-objects/clinician/ClinicianDashboardPage.js b/dist/page-objects/clinician/ClinicianDashboardPage.js new file mode 100644 index 0000000..558fd9b --- /dev/null +++ b/dist/page-objects/clinician/ClinicianDashboardPage.js @@ -0,0 +1,77 @@ +class ClinicianDashboardPage { + constructor(page) { + this.url = '/clinic-workspace'; + this.name = 'ClinicianDashboardPage'; // Added name for step decorator context + this.page = page; + // Main page locators + this.addNewPatientButton = page.getByRole('button', { name: 'Add New Patient' }); + this.searchInput = page.getByRole('textbox', { name: 'Search' }); + this.patientListTable = page.getByRole('table', { name: 'peopletablelabel' }); + // Add Patient Dialog locators + this.addPatientDialog = page.getByRole('dialog', { name: /Add New Patient Account/i }); + this.addPatientDialog_fullNameInput = this.addPatientDialog.getByRole('textbox', { + name: 'Full Name', + }); + this.addPatientDialog_birthdateInput = this.addPatientDialog.getByRole('textbox', { + name: 'Birthdate', + }); + this.addPatientDialog_addButton = this.addPatientDialog.getByRole('button', { + name: 'Add Patient', + }); + // Bring Data Dialog locators + this.bringDataDialog = page.getByRole('dialog', { name: /Bring Data into Tidepool/i }); + this.bringDataDialog_doneButton = this.bringDataDialog.getByRole('button', { name: 'Done' }); + } + /** + * Opens the Add Patient dialog and fills in the patient details. + * @param name - The full name of the patient. + * @param birthdate - The birthdate of the patient (e.g., MM/DD/YYYY). + */ + async openAndFillAddPatientDialog(name, birthdate) { + await this.addNewPatientButton.click(); + await this.addPatientDialog.waitFor({ state: 'visible' }); + await this.addPatientDialog_fullNameInput.fill(name); + await this.addPatientDialog_birthdateInput.fill(birthdate); + } + /** + * Clicks the Add Patient button in the dialog to submit the new patient. + */ + async submitAddPatientDialog() { + await this.addPatientDialog_addButton.click(); + } + /** + * Closes the Bring Data into Tidepool dialog by clicking Done. + */ + async closeBringDataDialog() { + await this.bringDataDialog.waitFor({ state: 'visible' }); + await this.bringDataDialog_doneButton.click(); + await this.bringDataDialog.waitFor({ state: 'hidden' }); + } + /** + * Searches for a patient in the list. + * @param name - The name of the patient to search for. + */ + async searchForPatient(name) { + await this.searchInput.fill(name); + // Press Enter to trigger search + await this.searchInput.press('Enter'); + // Wait longer for search to process and results to load + await this.page.waitForTimeout(3000); + } + /** + * Gets the locator for a patient cell in the table by name. + * @param name - The name of the patient. + * @returns Locator for the table cell containing the patient's name. + */ + getPatientCellByName(name) { + // Use exact match to avoid multiple matches with similar names + return this.patientListTable.getByRole('cell', { name, exact: true }); + } + /** + * Waits for the main elements of the Clinic Workspace page to be visible. + */ + async waitForLoadState() { + await this.addNewPatientButton.waitFor({ state: 'visible' }); + } +} +export default ClinicianDashboardPage; diff --git a/dist/page-objects/clinician/ClinicianNavigation.d.ts b/dist/page-objects/clinician/ClinicianNavigation.d.ts new file mode 100644 index 0000000..d3996f9 --- /dev/null +++ b/dist/page-objects/clinician/ClinicianNavigation.d.ts @@ -0,0 +1,20 @@ +import { Locator, Page } from '@playwright/test'; +export interface WorkspaceNavVerify { + name: string; + link: Locator; + verifyURL: string; + verifyElement: Locator; +} +export interface PageNavVerify { + name: string; + link: Locator; + verifyURL: string; + verifyElement: Locator; + closeButton?: Locator; +} +export default class ClinicianNav { + readonly page: Page; + readonly workspaces: Record<'AdminClinicBase' | 'AdminClinicEnterprise' | 'MemberClinicBase' | 'MemberClinicEnterprise' | 'NonMemberClinicBase' | 'NonMemberClinicEnterprise' | 'PartnerClinicBase' | 'PartnerClinicEnterprise', WorkspaceNavVerify>; + readonly pages: Record<'PatientList' | 'WorkspaceSettings' | 'AddPatient' | 'Profile' | 'ProfileEdit', PageNavVerify>; + constructor(page: Page); +} diff --git a/dist/page-objects/clinician/ClinicianNavigation.js b/dist/page-objects/clinician/ClinicianNavigation.js new file mode 100644 index 0000000..5a7502e --- /dev/null +++ b/dist/page-objects/clinician/ClinicianNavigation.js @@ -0,0 +1,116 @@ +export default class ClinicianNav { + constructor(page) { + this.page = page; + // Define hardcoded workspace configurations (matching PatientNavigation approach) + this.workspaces = { + AdminClinicBase: { + name: 'Admin Clinic (Base)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Admin Clinic (Base) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Admin Clinic (Base)' }), + }, + AdminClinicEnterprise: { + name: 'Admin Clinic (Enterprise)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Admin Clinic (Enterprise) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Admin Clinic (Enterprise)' }), + }, + MemberClinicBase: { + name: 'Member Clinic (Base)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Member Clinic (Base) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Member Clinic (Base)' }), + }, + MemberClinicEnterprise: { + name: 'Member Clinic (Enterprise)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Member Clinic (Enterprise) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Member Clinic (Enterprise)' }), + }, + NonMemberClinicBase: { + name: 'Non-Member Clinic (Base)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Non-Member Clinic (Base) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Non-Member Clinic (Base)' }), + }, + NonMemberClinicEnterprise: { + name: 'Non-Member Clinic (Enterprise)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Non-Member Clinic (Enterprise) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Non-Member Clinic (Enterprise)' }), + }, + PartnerClinicBase: { + name: 'Partner Clinic (Base)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Partner Clinic (Base) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Partner Clinic (Base)' }), + }, + PartnerClinicEnterprise: { + name: 'Partner Clinic (Enterprise)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Partner Clinic (Enterprise) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Partner Clinic (Enterprise)' }), + }, + }; + // Define clinician page navigation (matching PatientNavigation format) + this.pages = { + PatientList: { + name: 'PatientList', + link: page.getByRole('link', { name: 'Patients' }), + verifyURL: 'clinic-workspace/patients', + verifyElement: page.getByRole('heading', { name: 'Patients' }), + }, + WorkspaceSettings: { + name: 'WorkspaceSettings', + link: page.getByRole('link', { name: 'Workspace Settings' }), + verifyURL: 'clinic-workspace/workspace/settings', + verifyElement: page.getByRole('heading', { name: 'Workspace Settings' }), + }, + AddPatient: { + name: 'AddPatient', + link: page.getByRole('button', { name: 'Add Patient' }), + verifyURL: 'clinic-workspace/patients/add', + verifyElement: page.getByRole('heading', { name: 'Add Patient' }), + }, + Profile: { + name: 'Profile', + link: page + .getByRole('button', { name: 'Patient Profile Profile' }) + .or(page.getByRole('tab', { name: 'Profile' })) + .or(page.getByRole('link', { name: 'Profile' })) + .or(page.getByRole('button', { name: 'Profile' })), + verifyURL: 'profile', + verifyElement: page + .getByRole('button', { name: 'Edit' }) + .or(page.getByRole('button', { name: 'Edit Profile' })), + }, + ProfileEdit: { + name: 'ProfileEdit', + link: page + .getByRole('button', { name: 'Edit' }) + .or(page.getByRole('button', { name: 'Edit Profile' })), + verifyURL: 'profile', + verifyElement: page + .getByRole('button', { name: 'Save changes' }) + .or(page.getByRole('button', { name: 'Save Profile' })) + .or(page.getByRole('button', { name: 'Save' })), + }, + }; + } +} diff --git a/dist/page-objects/clinician/WorkspaceSettingsPage.d.ts b/dist/page-objects/clinician/WorkspaceSettingsPage.d.ts new file mode 100644 index 0000000..666bb9a --- /dev/null +++ b/dist/page-objects/clinician/WorkspaceSettingsPage.d.ts @@ -0,0 +1,18 @@ +import { Locator, Page } from '@playwright/test'; +export default class ClinicAdminPage { + readonly clinicDetailsHeader: Locator; + readonly editDetailsButton: Locator; + readonly editClinicModal: Locator; + readonly editClinicModalTitle: Locator; + readonly addressInput: Locator; + readonly saveChangesButton: Locator; + readonly clinicDetailsSection: Locator; + url: string; + name: string; + page: Page; + constructor(page: Page); + /** + * Waits for essential elements of the Clinic Admin page to be loaded. + */ + waitForLoadState(): Promise; +} diff --git a/dist/page-objects/clinician/WorkspaceSettingsPage.js b/dist/page-objects/clinician/WorkspaceSettingsPage.js new file mode 100644 index 0000000..aec2426 --- /dev/null +++ b/dist/page-objects/clinician/WorkspaceSettingsPage.js @@ -0,0 +1,26 @@ +export default class ClinicAdminPage { + constructor(page) { + this.url = '/clinic-admin'; + this.name = 'ClinicAdminPage'; // Added name for step decorator context + this.page = page; + this.clinicDetailsHeader = page.getByText('Workspace Settings'); + // Assuming the edit button is specifically associated with the details section + this.editDetailsButton = page.getByRole('button', { name: 'Edit' }); + this.editClinicModal = page.getByRole('dialog'); // General dialog selector + this.editClinicModalTitle = this.editClinicModal.getByRole('heading', { + name: 'Edit Workspace Details', + }); + this.addressInput = this.editClinicModal.getByLabel('Address', { exact: true }); // Use exact label match + this.saveChangesButton = this.editClinicModal.getByRole('button', { name: 'Save Changes' }); + // Assuming the details are within a specific container section related to the header + this.clinicDetailsSection = page.locator('div:has(> span:text-is("Workspace Settings")) + div'); + } + /** + * Waits for essential elements of the Clinic Admin page to be loaded. + */ + async waitForLoadState() { + await this.page.waitForLoadState(); // Wait for base elements like header/footer + await this.clinicDetailsHeader.waitFor({ state: 'visible', timeout: 40000 }); + await this.editDetailsButton.waitFor({ state: 'visible', timeout: 10000 }); + } +} diff --git a/dist/page-objects/clinician/WorkspacesPage.d.ts b/dist/page-objects/clinician/WorkspacesPage.d.ts new file mode 100644 index 0000000..44f2a64 --- /dev/null +++ b/dist/page-objects/clinician/WorkspacesPage.d.ts @@ -0,0 +1,16 @@ +import { Locator, Page } from '@playwright/test'; +export default class WorkspacesPage { + readonly page: Page; + readonly url: string; + readonly header: Locator; + readonly subHeader: Locator; + readonly createClinicButton: Locator; + constructor(page: Page); + goto(): Promise; + visitFirstClinic(): Promise; + /** + * Visit a clinic by name + * @param clinicName - The name of the clinic to visit + */ + visitClinic(clinicName: string): Promise; +} diff --git a/dist/page-objects/clinician/WorkspacesPage.js b/dist/page-objects/clinician/WorkspacesPage.js new file mode 100644 index 0000000..1c9cc60 --- /dev/null +++ b/dist/page-objects/clinician/WorkspacesPage.js @@ -0,0 +1,30 @@ +import env from '../../utilities/env'; +export default class WorkspacesPage { + constructor(page) { + this.url = `${env.BASE_URL}/workspaces`; + this.page = page; + this.header = page.getByRole('heading', { name: 'Clinic Workspace' }); + this.subHeader = page.getByRole('paragraph', { + name: 'View, share and manage patient data', + }); + this.createClinicButton = page.getByRole('button', { + name: 'Create a New Clinic', + }); + } + async goto() { + await this.page.goto(this.url); + } + async visitFirstClinic() { + await this.page.getByRole('button', { name: 'Go To Workspace' }).first().click(); + } + /** + * Visit a clinic by name + * @param clinicName - The name of the clinic to visit + */ + async visitClinic(clinicName) { + // find child element with text and filter by parent element with class + const child = this.page.getByText(clinicName); + const parent = this.page.locator('.workspace-item-clinic').filter({ has: child }); + await parent.getByRole('button', { name: 'Go To Workspace' }).first().click(); + } +} diff --git a/dist/page-objects/clinician/components/navigation-menu.section.d.ts b/dist/page-objects/clinician/components/navigation-menu.section.d.ts new file mode 100644 index 0000000..203acf6 --- /dev/null +++ b/dist/page-objects/clinician/components/navigation-menu.section.d.ts @@ -0,0 +1,16 @@ +import { Locator, Page } from '@playwright/test'; +export default class NavigationMenu { + page: Page; + container: Locator; + buttons: { + trigger: Locator; + menu: { + privateWorkspace: Locator; + accountSettings: Locator; + logout: Locator; + }; + }; + constructor(page: Page); + open(): Promise; + close(): Promise; +} diff --git a/dist/page-objects/clinician/components/navigation-menu.section.js b/dist/page-objects/clinician/components/navigation-menu.section.js new file mode 100644 index 0000000..c999acd --- /dev/null +++ b/dist/page-objects/clinician/components/navigation-menu.section.js @@ -0,0 +1,24 @@ +export default class NavigationMenu { + constructor(page) { + this.page = page; + this.container = page.locator('div#navigation-menu'); + this.buttons = { + trigger: this.container.locator('#navigation-menu-trigger'), + menu: { + privateWorkspace: this.container.getByRole('button', { + name: 'Private Workspace', + }), + accountSettings: this.container.getByRole('button', { + name: 'Account Settings', + }), + logout: this.container.getByRole('button', { name: 'Logout' }), + }, + }; + } + async open() { + await this.buttons.trigger.click(); + } + async close() { + await this.buttons.trigger.click(); + } +} diff --git a/dist/page-objects/clinician/components/navigation.section.d.ts b/dist/page-objects/clinician/components/navigation.section.d.ts new file mode 100644 index 0000000..eea6afb --- /dev/null +++ b/dist/page-objects/clinician/components/navigation.section.d.ts @@ -0,0 +1,14 @@ +import { Locator, Page } from '@playwright/test'; +import NavigationMenu from './navigation-menu.section'; +export default class NavigationSection { + page: Page; + container: Locator; + menu: NavigationMenu; + buttons: { + viewData: Locator; + patientProfile: Locator; + share: Locator; + uploadData: Locator; + }; + constructor(page: Page); +} diff --git a/dist/page-objects/clinician/components/navigation.section.js b/dist/page-objects/clinician/components/navigation.section.js new file mode 100644 index 0000000..e75d2a6 --- /dev/null +++ b/dist/page-objects/clinician/components/navigation.section.js @@ -0,0 +1,16 @@ +import NavigationMenu from './navigation-menu.section'; +export default class NavigationSection { + constructor(page) { + this.page = page; + this.container = page.locator('div#navPatientHeader'); + this.menu = new NavigationMenu(page); + this.buttons = { + viewData: this.container.getByRole('button', { name: 'View Data' }), + patientProfile: this.container.getByRole('button', { + name: 'Patient Profile', + }), + share: this.container.getByRole('button', { name: 'Share' }), + uploadData: this.container.getByRole('button', { name: 'Upload Data' }), + }; + } +} diff --git a/dist/page-objects/patient/BasicsPage.d.ts b/dist/page-objects/patient/BasicsPage.d.ts new file mode 100644 index 0000000..009dbe7 --- /dev/null +++ b/dist/page-objects/patient/BasicsPage.d.ts @@ -0,0 +1,58 @@ +import { Locator, Page } from '@playwright/test'; +import PatientNav from '@pom/patient/PatientNavigation'; +import NavigationSection from '@components/navigation.section'; +interface CalendarSection { + container: Locator; + firstDayOfData: Locator; + calendarDayhover: { + el: Locator; + text(): Promise; + }; +} +interface Stat { + container: Locator; + header: Locator; + hoverBar: Locator; + hoverBarLabel: Locator; +} +interface StatsSidebar { + toggleContainer: Locator; + toggleTo(toState: 'BGM' | 'CGM'): Promise; + timeInRange: Stat; + readingsInRange: Stat; + averageGlucose: Stat; + totalInsulin: Stat; + carbs: Stat; + standardDev: Stat; + coefficientOfVariation: Stat; + sensorUsage: Stat; + glucoseManagementIndicator: Stat; + averageDailyDose: Stat; +} +interface TubingPrimeSection extends CalendarSection { + settings: Locator; + settingsOption: { + fillTubing: Locator; + fillCannula: Locator; + }; + tubingIcons: Locator; + cannulaIcons: Locator; + filledDay: Locator; +} +export default class PatientDataBasicsPage { + page: Page; + url: string; + emailInput: Locator; + navigationBar: NavigationSection; + navigationSubMenu: PatientNav; + headerBgReading: Locator; + headerBolusing: Locator; + statsSidebar: StatsSidebar; + bgReadingsSection: CalendarSection; + bolusingSection: CalendarSection; + tubingPrimeSection: TubingPrimeSection; + basalsSection: CalendarSection; + constructor(page: Page); + goto(): Promise; +} +export {}; diff --git a/dist/page-objects/patient/BasicsPage.js b/dist/page-objects/patient/BasicsPage.js new file mode 100644 index 0000000..067a865 --- /dev/null +++ b/dist/page-objects/patient/BasicsPage.js @@ -0,0 +1,138 @@ +var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) { + var useValue = arguments.length > 2; + for (var i = 0; i < initializers.length; i++) { + value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); + } + return useValue ? value : void 0; +}; +var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) { + function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; } + var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; + var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null; + var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); + var _, done = false; + for (var i = decorators.length - 1; i >= 0; i--) { + var context = {}; + for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; + for (var p in contextIn.access) context.access[p] = contextIn.access[p]; + context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); }; + var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context); + if (kind === "accessor") { + if (result === void 0) continue; + if (result === null || typeof result !== "object") throw new TypeError("Object expected"); + if (_ = accept(result.get)) descriptor.get = _; + if (_ = accept(result.set)) descriptor.set = _; + if (_ = accept(result.init)) initializers.unshift(_); + } + else if (_ = accept(result)) { + if (kind === "field") initializers.unshift(_); + else descriptor[key] = _; + } + } + if (target) Object.defineProperty(target, contextIn.name, descriptor); + done = true; +}; +import { step } from '@fixtures/base'; +import PatientNav from '@pom/patient/PatientNavigation'; +import NavigationSection from '@components/navigation.section'; +function createSection(page, selector) { + const parsedSelector = selector === 'tubing-primes' ? 'siteChanges' : selector; + const container = page.locator(`.Calendar-container-${parsedSelector}`); + return { + container, + firstDayOfData: container.locator(`.Calendar-day--${parsedSelector}.Calendar-day`).first(), + calendarDayhover: { + el: container.locator('.Calendar-day--HOVER'), + async text() { + return container.locator('.Calendar-day--HOVER').locator('.Calendar-weekday').textContent(); + }, + }, + }; +} +/** + * helper function to create a stat object with locators for the container, header, hoverBar, and hoverBarLabel + */ +function createStat(page, selector) { + const container = page.locator(`#Stat--${selector}`); + return { + container, + header: container.locator('[class^="Stat--chartTitleText"]'), + hoverBar: container.locator('.HoverBar'), + hoverBarLabel: container.locator('.HoverBarLabel'), + }; +} +// list of sections in the stats sidebar +const statsSideBarSection = [ + 'timeInRange', + 'readingsInRange', + 'averageGlucose', + 'totalInsulin', + 'carbs', + 'standardDev', + 'coefficientOfVariation', + 'sensorUsage', + 'glucoseManagementIndicator', + 'totalInsulin', + 'averageDailyDose', +]; +let PatientDataBasicsPage = (() => { + var _a; + let _instanceExtraInitializers = []; + let _goto_decorators; + return _a = class PatientDataBasicsPage { + constructor(page) { + this.page = __runInitializers(this, _instanceExtraInitializers); + this.page = page; + this.url = '/patients/data/basics'; + this.emailInput = page.getByRole('textbox', { name: 'Email' }); + this.navigationBar = new NavigationSection(page); + this.navigationSubMenu = new PatientNav(page); + this.headerBgReading = page.getByRole('heading', { name: 'BG readings' }); + this.headerBolusing = page.getByRole('heading', { name: 'Bolusing' }); + this.statsSidebar = { + toggleContainer: page.locator('.toggle-container'), + async toggleTo(toState) { + const activeToggleState = await page + .locator(".toggle-container span[class*='TwoOptionToggle--active']") + .innerText(); + if (activeToggleState === 'BGM' && toState === 'CGM') { + await this.toggleContainer.click(); + } + else if (activeToggleState === 'CGM' && toState === 'BGM') { + await this.toggleContainer.click(); + } + }, + ...Object.fromEntries(statsSideBarSection.map(stat => [stat, createStat(page, stat)])), + }; + // charts + this.bgReadingsSection = createSection(page, 'fingersticks'); + this.bolusingSection = createSection(page, 'boluses'); + this.tubingPrimeSection = { + ...createSection(page, 'tubing-primes'), + settings: page.locator('.SiteChangeSelector-option').first(), + settingsOption: { + fillTubing: page.getByLabel('Tubing Fill'), + fillCannula: page.getByLabel('Cannula Fill'), + }, + tubingIcons: page.locator('.Change--tubing').first(), + cannulaIcons: page.locator('.Change--cannula').first(), + filledDay: createSection(page, 'tubing-primes') + .container.locator('.Calendar-day') + .filter({ has: page.locator('.Change-daysSince-text') }) + .first(), + }; + this.basalsSection = createSection(page, 'basals'); + } + async goto() { + await this.page.goto(this.url); + } + }, + (() => { + const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0; + _goto_decorators = [step('Navigate to the basics page')]; + __esDecorate(_a, null, _goto_decorators, { kind: "method", name: "goto", static: false, private: false, access: { has: obj => "goto" in obj, get: obj => obj.goto }, metadata: _metadata }, null, _instanceExtraInitializers); + if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata }); + })(), + _a; +})(); +export default PatientDataBasicsPage; diff --git a/dist/page-objects/patient/DailyPage.d.ts b/dist/page-objects/patient/DailyPage.d.ts new file mode 100644 index 0000000..fd4c533 --- /dev/null +++ b/dist/page-objects/patient/DailyPage.d.ts @@ -0,0 +1,11 @@ +import { Page } from '@playwright/test'; +import DailyChartSection from '@components/daily-chart.js'; +import PatientNav from '@pom/patient/PatientNavigation.js'; +import NavigationSection from '@components/navigation.section.js'; +export default class PatientDataDailyPage { + page: Page; + navigationBar: NavigationSection; + navigationSubMenu: PatientNav; + dailyChart: DailyChartSection; + constructor(page: Page); +} diff --git a/dist/page-objects/patient/DailyPage.js b/dist/page-objects/patient/DailyPage.js new file mode 100644 index 0000000..01a824e --- /dev/null +++ b/dist/page-objects/patient/DailyPage.js @@ -0,0 +1,11 @@ +import DailyChartSection from '@components/daily-chart.js'; +import PatientNav from '@pom/patient/PatientNavigation.js'; +import NavigationSection from '@components/navigation.section.js'; +export default class PatientDataDailyPage { + constructor(page) { + this.page = page; + this.navigationBar = new NavigationSection(page); + this.navigationSubMenu = new PatientNav(page); + this.dailyChart = new DailyChartSection(page); + } +} diff --git a/dist/page-objects/patient/PatientNavigation.d.ts b/dist/page-objects/patient/PatientNavigation.d.ts new file mode 100644 index 0000000..6ee3791 --- /dev/null +++ b/dist/page-objects/patient/PatientNavigation.d.ts @@ -0,0 +1,13 @@ +import { Locator, Page } from '@playwright/test'; +export interface PageNavVerify { + name: string; + link: Locator; + verifyURL: string; + verifyElement: Locator; + closeButton?: Locator; +} +export default class PatientNav { + readonly page: Page; + readonly pages: Record<'ViewData' | 'Basics' | 'ChartDateRange' | 'Daily' | 'ChartDate' | 'BGLog' | 'Trends' | 'Devices' | 'Print' | 'Profile' | 'ProfileEdit' | 'Share' | 'ShareData' | 'UploadData', PageNavVerify>; + constructor(page: Page); +} diff --git a/dist/page-objects/patient/PatientNavigation.js b/dist/page-objects/patient/PatientNavigation.js new file mode 100644 index 0000000..0c536a5 --- /dev/null +++ b/dist/page-objects/patient/PatientNavigation.js @@ -0,0 +1,97 @@ +export default class PatientNav { + // currentDate: Locator; + constructor(page) { + this.page = page; + this.pages = { + ViewData: { + name: 'ViewData', + link: page.getByRole('button', { name: 'View Data View' }), + verifyURL: 'data', + verifyElement: page.locator('div.patient-data-subnav-inner'), + }, + Basics: { + name: 'Basics', + link: page.getByRole('link', { name: 'Basics' }), + verifyURL: 'data/basics', + verifyElement: page.locator('.js-basics.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + ChartDateRange: { + name: 'ChartDateRange', + link: page + .locator('button svg, .css-15vjjnj svg, [aria-label*="calendar"], [title*="calendar"]') + .first(), // Calendar icon in blue navigation bar + verifyURL: '', + verifyElement: page.locator('#printDateRangePickerInner').locator('*').first(), // Any content inside the dialog + closeButton: page.getByRole('button', { name: 'close dialog' }), + }, + Daily: { + name: 'Daily', + link: page.getByRole('link', { name: 'Daily' }), + verifyURL: 'data/daily', + verifyElement: page.locator('.js-daily.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + ChartDate: { + name: 'ChartDate', + link: page.locator('#tidelineLabel .css-15vjjnj svg'), // Using the same calendar icon selector + verifyURL: '', + verifyElement: page.getByRole('heading', { name: 'Chart Date' }), + closeButton: page.getByRole('button', { name: 'close dialog' }), + }, + BGLog: { + name: 'BGLog', + link: page.getByRole('link', { name: 'BG Log' }), + verifyURL: 'data/bglog', + verifyElement: page.locator('.js-bgLog.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + Trends: { + name: 'Trends', + link: page.getByRole('link', { name: 'Trends' }), + verifyURL: 'data/trends', + verifyElement: page.locator('.js-trends.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + Devices: { + name: 'Devices', + link: page.getByRole('link', { name: 'Devices' }), + verifyURL: 'data/devices', + verifyElement: page.locator('.js-settings.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + Print: { + name: 'Print', + link: page.getByRole('link', { name: 'Print PDF report Print' }), // Print link from the snapshot + verifyURL: '', + verifyElement: page.getByRole('heading', { name: 'Print Report' }), // Assuming modal title + closeButton: page.getByRole('button', { name: 'close dialog' }), + }, + Profile: { + name: 'Profile', + link: page.getByRole('button', { name: 'Profile Profile' }), + verifyURL: '', + verifyElement: page.getByRole('button', { name: 'Edit' }), // Edit button is visible on profile page + }, + ProfileEdit: { + name: 'ProfileEdit', + link: page.getByRole('button', { name: 'Edit' }), + verifyURL: 'profile', + verifyElement: page.getByRole('button', { name: 'Save changes' }), // Save changes button appears when in edit mode + }, + Share: { + name: 'Share', + link: page.getByRole('button', { name: 'Share Share' }), + verifyURL: 'share', + verifyElement: page.getByRole('heading', { name: 'Access Management' }), + }, + ShareData: { + name: 'ShareData', + link: page.getByRole('button', { name: 'Share Data' }), + verifyURL: 'share/invite', + verifyElement: page.getByRole('heading', { name: 'Share your data' }), + }, + UploadData: { + name: 'UploadData', + link: page.getByRole('button', { name: 'Upload Data Upload' }), + verifyURL: 'upload', + verifyElement: page.getByRole('heading', { name: 'Upload Data' }), + }, + }; + } +} diff --git a/dist/page-objects/patient/ProfilePage.d.ts b/dist/page-objects/patient/ProfilePage.d.ts new file mode 100644 index 0000000..f37a6f7 --- /dev/null +++ b/dist/page-objects/patient/ProfilePage.d.ts @@ -0,0 +1,22 @@ +import { Page } from '@playwright/test'; +export declare class ProfilePage { + readonly page: Page; + private fieldLocators; + constructor(page: Page); + fillField(field: keyof typeof this.fieldLocators, value: string): Promise; + selectDiagnosisType(index: number): Promise; + getCurrentDiagnosisIndex(): Promise; + fillFullName(name: string): Promise; + fillBirthDate(date: string): Promise; + fillMRN(mrn: string): Promise; + fillDiagnosisDate(date: string): Promise; + fillClinicalNotes(notes: string): Promise; + fillEmail(email: string): Promise; + saveProfile(): Promise; + /** + * Checks if the edit button is displayed and validates against expected state + * @param shouldBeVisible - Boolean indicating whether the edit button should be visible + * @throws Error if the actual visibility doesn't match the expected state + */ + editButtonDisplays(shouldBeVisible: boolean): Promise; +} diff --git a/dist/page-objects/patient/ProfilePage.js b/dist/page-objects/patient/ProfilePage.js new file mode 100644 index 0000000..87a80b0 --- /dev/null +++ b/dist/page-objects/patient/ProfilePage.js @@ -0,0 +1,111 @@ +export class ProfilePage { + constructor(page) { + this.page = page; + this.fieldLocators = { + fullName: this.page.getByRole('textbox', { name: 'Full name' }), + birthDate: this.page.getByRole('textbox', { name: 'Date of birth' }), + mrn: this.page.getByRole('textbox', { name: 'MRN' }), + diagnosisDate: this.page.getByRole('textbox', { name: 'Date of diagnosis' }), + clinicalNotes: this.page.getByRole('textbox', { name: 'Anything you would like to share' }), + email: this.page.getByRole('textbox', { name: /email/i }), + }; + } + // Generic fill method for text fields + async fillField(field, value) { + const locator = this.fieldLocators[field]; + if (!locator) + throw new Error(`No locator defined for field: ${field}`); + if (await locator.isVisible({ timeout: 3000 }).catch(() => false)) { + await locator.fill(value); + } + else { + throw new Error(`Field '${field}' not found or not visible`); + } + } + // Select a diagnosis type from the dropdown + async selectDiagnosisType(index) { + const diagnosisCombo = this.page.getByRole('combobox', { name: 'Diagnosed as' }); + if (await diagnosisCombo.isVisible({ timeout: 3000 })) { + await diagnosisCombo.selectOption({ index }); + } + } + // Get the current diagnosis index from the dropdown (needed for setting a new diagnosis) + async getCurrentDiagnosisIndex() { + const diagnosisCombo = this.page.getByRole('combobox', { name: 'Diagnosed as' }); + if (await diagnosisCombo.isVisible({ timeout: 3000 })) { + const currentValue = await diagnosisCombo.inputValue(); + const options = await diagnosisCombo.locator('option').all(); + // Find current index by checking option values + for (let i = 0; i < options.length; i++) { + const optionValue = await options[i].getAttribute('value'); + if (optionValue === currentValue) { + return i; + } + } + } + return 1; // Default to 1 if not found + } + // For backwards compatibility, keep these as wrappers (optional) + async fillFullName(name) { + return this.fillField('fullName', name); + } + async fillBirthDate(date) { + return this.fillField('birthDate', date); + } + async fillMRN(mrn) { + return this.fillField('mrn', mrn); + } + async fillDiagnosisDate(date) { + return this.fillField('diagnosisDate', date); + } + async fillClinicalNotes(notes) { + return this.fillField('clinicalNotes', notes); + } + async fillEmail(email) { + return this.fillField('email', email); + } + async saveProfile() { + // Save button locators + const saveButtons = [ + this.page.getByRole('button', { name: 'Save changes' }), + this.page.getByRole('button', { name: 'Save Profile' }), + this.page.getByRole('button', { name: 'Save' }), + ]; + // Wait for the PUT request to complete after clicking save + const saveProfilePromise = this.page.waitForResponse(response => response.url().includes('/metadata/') && + response.url().includes('/profile') && + response.request().method() === 'PUT'); + let clicked = false; + for (const btn of saveButtons) { + if (await btn.isVisible({ timeout: 5000 }).catch(() => false)) { + await btn.click(); + clicked = true; + break; + } + } + if (!clicked) + throw new Error('No save button found'); + // Wait for the PUT request to complete (with timeout) + try { + await saveProfilePromise; + } + catch (error) { + console.log('āš ļø PUT request timeout - continuing anyway'); + } + } + /** + * Checks if the edit button is displayed and validates against expected state + * @param shouldBeVisible - Boolean indicating whether the edit button should be visible + * @throws Error if the actual visibility doesn't match the expected state + */ + async editButtonDisplays(shouldBeVisible) { + const editButton = this.page.getByRole('button', { name: 'Edit' }); + const isEditButtonVisible = await editButton.isVisible({ timeout: 3000 }).catch(() => false); + if (shouldBeVisible && !isEditButtonVisible) { + throw new Error('Edit button should be visible but was not found'); + } + else if (!shouldBeVisible && isEditButtonVisible) { + throw new Error('Edit button should not be visible for this user - security violation!'); + } + } +} diff --git a/dist/page-objects/patient/components/daily-chart.d.ts b/dist/page-objects/patient/components/daily-chart.d.ts new file mode 100644 index 0000000..6e7de56 --- /dev/null +++ b/dist/page-objects/patient/components/daily-chart.d.ts @@ -0,0 +1,11 @@ +import { Locator, Page } from '@playwright/test'; +export default class DailyChartSection { + page: Page; + container: Locator; + dayLabel: Locator; + newNote: Locator; + buttons: { + refresh: Locator; + }; + constructor(page: Page); +} diff --git a/dist/page-objects/patient/components/daily-chart.js b/dist/page-objects/patient/components/daily-chart.js new file mode 100644 index 0000000..51c4f46 --- /dev/null +++ b/dist/page-objects/patient/components/daily-chart.js @@ -0,0 +1,11 @@ +export default class DailyChartSection { + constructor(page) { + this.page = page; + this.container = page.locator('div.patient-data-content'); + this.dayLabel = this.container.locator('text.d3-day-label').filter({ visible: true }); + this.newNote = this.container.locator('image.newNoteIcon'); + this.buttons = { + refresh: this.container.getByRole('button', { name: 'Refresh' }), + }; + } +} diff --git a/dist/playwright.config.d.ts b/dist/playwright.config.d.ts new file mode 100644 index 0000000..9c39b85 --- /dev/null +++ b/dist/playwright.config.d.ts @@ -0,0 +1,2 @@ +declare const _default: import("@playwright/test").PlaywrightTestConfig<{}, {}>; +export default _default; diff --git a/dist/playwright.config.js b/dist/playwright.config.js new file mode 100644 index 0000000..647a368 --- /dev/null +++ b/dist/playwright.config.js @@ -0,0 +1,108 @@ +import { defineConfig, devices } from '@playwright/test'; +import path from 'node:path'; +import env from './utilities/env'; +const xrayOptions = { + embedAnnotationsAsProperties: true, + textContentAnnotations: ['test_description', 'testrun_comment'], + embedAttachmentsAsProperty: 'testrun_evidence', + outputFile: 'test-output/test-results.xml', +}; +// Helper to detect BrowserStack run +const isBrowserStack = Boolean(process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESS_KEY); +function buildBrowserStackEndpoint(testName) { + const caps = { + browser: 'chrome', + browser_version: 'latest', + os: 'os x', + os_version: 'catalina', + name: testName, + build: process.env.CI_BUILD_NUMBER || 'local-run', + 'browserstack.username': process.env.BROWSERSTACK_USERNAME, + 'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY, + }; + return `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify(caps))}`; +} +export default defineConfig({ + testDir: './tests', + outputDir: './test-results', // Custom output directory + globalSetup: require.resolve(path.join(__dirname, 'tests/global-setup')), + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + timeout: 60000, + expect: { + toHaveScreenshot: { maxDiffPixelRatio: 0.2 }, + }, + reporter: [ + ['html', { open: 'never', outputFolder: 'playwright-report' }], + ['json', { outputFile: 'test-results/last-run.json' }], + ['junit', xrayOptions], + ['./utilities/xray-json-reporter.ts'], + ], + use: { + baseURL: env.BASE_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + // Custom test attachment naming + testIdAttribute: 'data-testid', + }, + projects: [ + { + name: 'chromium-personal', + testMatch: '**/personal/**/*.spec.ts', + use: { + ...devices['Desktop Chrome'], + storageState: 'tests/.auth/personal.json', + headless: false, + }, + }, + { + name: 'chromium-claimed', + testMatch: '**/claimed/**/*.spec.ts', + use: { + ...devices['Desktop Chrome'], + storageState: 'tests/.auth/claimed.json', + headless: false, + }, + }, + { + name: 'chromium-clinician', + testMatch: '**/clinician/**/*.spec.ts', + use: { + ...devices['Desktop Chrome'], + storageState: 'tests/.auth/clinician.json', + headless: false, + }, + }, + ...(isBrowserStack + ? [ + { + name: 'bs-chrome-personal', + testMatch: '**/patient/**/*.spec.ts', + use: { + storageState: 'tests/.auth/personal.json', + connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Personal Patient Tests') }, + }, + }, + { + name: 'bs-chrome-claimed', + testMatch: '**/claimed/**/*.spec.ts', + use: { + storageState: 'tests/.auth/claimed.json', + connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Claimed Patient Tests') }, + }, + }, + { + name: 'bs-chrome-clinician', + testMatch: '**/clinician/**/*.spec.ts', + use: { + storageState: 'tests/.auth/clinician.json', + connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Clinician Tests') }, + }, + }, + ] + : []), + ], +}); diff --git a/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.d.ts b/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js b/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js new file mode 100644 index 0000000..e95b07d --- /dev/null +++ b/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js @@ -0,0 +1,146 @@ +import { test } from '../../fixtures/base'; +import { test as patientTest } from '../../fixtures/patient-helpers'; +import { test as accountTest } from '../../fixtures/account-helpers'; +import { test as clinicTest } from '../../fixtures/clinic-helpers'; +import { createNetworkHelper } from '../../fixtures/network-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { AccountSettingsPage } from '../../../page-objects/account/AccountSettingsPage'; +import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; +const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; +const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; +test.describe('Claimed Account Settings edit (Full Name only) updates Profile endpoint and visually updates for user, clinic, and shared member', () => { + test.setTimeout(120000); // 2 minute timeout for multi-phase test + let api; + let putCapture; + let newName; // Declare at test level scope + test('should allow navigation to account settings, edit full name, and verify profile update for claimed, shared, and clinician users', { + tag: createValidatedTags([ + TEST_TAGS.PATIENT, + TEST_TAGS.CLINICIAN, // Added clinician tag + TEST_TAGS.CLAIMED, + TEST_TAGS.SHARED_MEMBER, // Added shared member tag + TEST_TAGS.API, + TEST_TAGS.UI, + TEST_TAGS.HIGH, + TEST_TAGS.API_PROFILE, + ]), + }, async ({ page }) => { + // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== + // Step 1: Log in to clinician account and setup network capture + await test.step('Given claimed account has been logged in', async () => { + api = createNetworkHelper(page); + await api.startCapture(); + await page.goto('/data'); + await patientTest.patient.setup(page); + }); + // Step 2: Navigate to account settings + await test.step('When user navigates to account settings', async () => { + await accountTest.account.navigateTo('AccountSettings', page); + }); + // Step 3: GET response is pulled and validated + await test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Create new acccount settings page for the following test + const accountSettingsPage = new AccountSettingsPage(page); + // Step 4: Change the Full Name field to a new value + await test.step('When user updates the Full Name field', async () => { + newName = `Claimed User Updated ${Math.floor(Math.random() * 10000)}`; // Remove let declaration + const nameInput = page.getByRole('textbox', { name: /full name/i }); + await nameInput.fill(newName); + }); + // Step 5: Tap the Save button + await test.step('When user taps the save button', async () => { + await accountSettingsPage.saveButton.click(); + }); + // Step 6: Confirm save changes message displays + await test.step('Then the save changes message displays', async () => { + await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); + }); + // Step 7: Validate PUT request and save value + await test.stepNoScreenshot('Then PUT request is validated and name is set to new value', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + putCapture = api + .getCaptures() + .find((req) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) + throw new Error('No PUT /profile request captured'); + if (!putCapture.requestBody || + !putCapture.requestBody.fullName || + putCapture.requestBody.fullName !== newName) { + throw new Error(`PUT request did not set fullName to ${newName}`); + } + }); + // Step 8: Navigate to Profile page + await test.step('When user navigates to Profile page', async () => { + await patientTest.patient.navigateTo('Profile', page); + }); + // Step 9: Confirm GET request matches the saved PUT request + await test.stepNoScreenshot('Then GET request matches the saved PUT request', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + // Get all captures and find the LATEST GET request (after the PUT) + const allCaptures = api.getCaptures(); + const putIndex = allCaptures.findIndex(req => req === putCapture); + // Find GET requests that occurred AFTER the PUT request + const laterGetCaptures = allCaptures + .slice(putIndex + 1) + .filter((req) => req.method === 'GET' && req.url.includes('/profile')); + if (laterGetCaptures.length === 0) { + throw new Error('No GET /profile request captured after the PUT request'); + } + // Use the most recent GET request + const getCapture = laterGetCaptures[laterGetCaptures.length - 1]; + if (!getCapture.responseBody || + getCapture.responseBody.fullName !== putCapture.requestBody.fullName) { + console.log('GET response fullName:', getCapture.responseBody.fullName); + console.log('PUT request fullName:', putCapture.requestBody.fullName); + console.log('Total captures:', allCaptures.length); + console.log('PUT index:', putIndex); + console.log('Later GET captures found:', laterGetCaptures.length); + throw new Error('GET response fullName does not match PUT request fullName'); + } + }); + // ========== PHASE 2: SHARED USER VIEWS PROFILE ========== + // Step 10: Switch to shared user authentication and go directly to Profile + await test.step('When shared user views claimed user profile', async () => { + await accountTest.account.switchUser('shared', page); + await page.goto('/data'); + await patientTest.patient.setup(page); + // Wait a moment for the page to stabilize after user switch + await page.waitForTimeout(500); + // Navigate directly to Profile in the same step to avoid redundancy + await patientTest.patient.navigateTo('Profile', page); + }); + // Step 11: Verify Edit button is not present for shared users + await test.step('Then Edit button should not be present for shared patients', async () => { + const profilePage = new ProfilePage(page); + await profilePage.editButtonDisplays(false); + }); + // Step 12: Validate shared user sees updated profile data + await test.stepNoScreenshot('Then shared user sees view-only claimed profile data with matching data', async () => { + await api.compareEndpointResponse('profile-metadata-get', putCapture); + }); + // ========== PHASE 3: CLINICIAN VIEWS PROFILE ========== + // Step 13: Switch to clinician user authentication + await test.step('When clinician accesses patient workspace', async () => { + await accountTest.account.switchUser('clinician', page); + await page.goto('/'); + await clinicTest.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); + }); + // Step 14: Access the specific claimed patient that was modified by the producer test + await test.step('When user accesses the claimed patient modified by producer test', async () => { + await clinicTest.clinician.findAndAccessPatientByPartialName(CLAIMED_PATIENT_SEARCH, page); + // Navigate directly to Profile in the same step to avoid redundancy + await clinicTest.clinician.navigateTo('Profile', page); + }); + // Step 15: Verify Edit button is not present for claimed patients viewed by clinicians + await test.step('Then Edit button should not be present for claimed patients', async () => { + const profilePage = new ProfilePage(page); + await profilePage.editButtonDisplays(false); + }); + // Step 16: Validate clinician sees updated profile data + await test.stepNoScreenshot('Then clinician sees claimed profile data with matching data and no save access', async () => { + await api.compareEndpointResponse('profile-metadata-get', putCapture); + }); + }); +}); diff --git a/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.d.ts b/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js b/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js new file mode 100644 index 0000000..47da045 --- /dev/null +++ b/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js @@ -0,0 +1,124 @@ +import { test } from '../../fixtures/base'; +import { test as patientTest } from '../../fixtures/patient-helpers'; +import { test as clinicTest } from '../../fixtures/clinic-helpers'; +import { test as accountTest } from '../../fixtures/account-helpers'; +import { createNetworkHelper } from '../../fixtures/network-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; +const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; +const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; +test.describe('Comprehensive Profile Access Test: Edit as Claimed, View as Shared and Clinician', () => { + test('should edit claimed profile then verify view-only access for shared and clinician users', { + tag: createValidatedTags([ + TEST_TAGS.PATIENT, // User Type (required) + TEST_TAGS.CLINICIAN, // User Type (required) + TEST_TAGS.CLAIMED, + TEST_TAGS.SHARED_MEMBER, + TEST_TAGS.API, // Test Type (required) + TEST_TAGS.UI, // Test Type (required) + TEST_TAGS.HIGH, // Priority (required) + TEST_TAGS.API_PROFILE, // Feature (optional) + ]), + }, async ({ page }) => { + let api; + let producerPutCapture; + // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== + // Step 1: Claimed account has been logged in + await test.step('Given claimed account has been logged in', async () => { + api = createNetworkHelper(page); + await api.startCapture(); + await page.goto('/data'); + await patientTest.patient.setup(page); + }); + // Step 2: User navigates to Profile page + await test.step('When user navigates to Profile page', async () => { + await patientTest.patient.navigateTo('Profile', page); + }); + // Step 3: GET response is pulled and validated + await test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Step 4: Confirm edit button and click it + await test.step('When user selects Edit button', async () => { + await patientTest.patient.navigateTo('ProfileEdit', page); + }); + // Initialize ProfilePage for steps 4 and 5 + const profilePage = new ProfilePage(page); + // Step 5: Change profile fields (confirmed user access) + await test.step('When user updates profile fields', async () => { + const testRunId = Math.floor(Math.random() * 10000); + const updatedName = `Claimed User Updated ${testRunId}`; + const birthYear = 1985 + (testRunId % 10); + const diagnosisYear = birthYear + 20; + const birthDate = `01/15/${birthYear}`; + const diagnosisDate = `03/10/${diagnosisYear}`; + // Generate random 15-letter string for clinical notes + const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); + // Get current diagnosis index and calculate next one (1-7, wrapping) + const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); + let nextDiagnosisIndex = currentDiagnosisIndex + 1; + if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { + nextDiagnosisIndex = 1; + } + // Update fields using ProfilePage methods + await profilePage.fillFullName(updatedName); + await profilePage.fillBirthDate(birthDate); + await profilePage.fillDiagnosisDate(diagnosisDate); + await profilePage.selectDiagnosisType(nextDiagnosisIndex); + await profilePage.fillClinicalNotes(randomString); + }); + // Step 6: Save profile edit + await test.step('When user saves profile changes', async () => { + await profilePage.saveProfile(); + }); + // Step 7: PUT response is validated and saved for comparison + await test.stepNoScreenshot('Then profile endpoint responds with PUT request consistent with schema', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putSchema = await import('../../../endpoint-schema/profile-endpoints'); + const schema = putSchema.putProfileMetadataSchema; + producerPutCapture = api.getLatestCaptureMatching(schema.method, schema.url); + }); + //= ========= SHARED MEMEBER VIEWS PROFILE ========== + // Step 8: Switch to shared user authentication + await test.step('When shared user views claimed user profile', async () => { + await accountTest.account.switchUser('shared', page); + await page.goto('/data'); + await patientTest.patient.navigateTo('ViewData', page); + }); + // Step 9: Navigate to profile page + await test.step('When user navigates to Profile page', async () => { + await patientTest.patient.navigateTo('Profile', page); + }); + // Step 10: Confirm edit button is not present + await test.step('Then Edit button should not be present for shared patients', async () => { + await profilePage.editButtonDisplays(false); + }); + // Step 11: Validate GET response and compare it against the + await test.stepNoScreenshot('Then shared user sees view-only claimed profile data with matching data', async () => { + await api.compareEndpointResponse('profile-metadata-get', producerPutCapture); + }); + // ========== CLINICIAN VIEWS PROFILE ========== + // Step 12: Switch to clinician authentication and navigate to patient profile + await test.step('When clinician accesses patient workspace', async () => { + await accountTest.account.switchUser('clinician', page); + await page.goto('/'); + await clinicTest.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); + }); + // Step 13: Access the specific claimed patient that was modified by the producer test + await test.step('When user accesses the claimed patient modified by producer test', async () => { + await clinicTest.clinician.findAndAccessPatientByPartialName(CLAIMED_PATIENT_SEARCH, page); + }); + // Step 14: Navigate to profile + await test.step('When user navigates to Profile page', async () => { + await clinicTest.clinician.navigateTo('Profile', page); + }); + // Step 15: Confirm edit button is not present + await test.step('Then Edit button should not be present for claimed patients', async () => { + await profilePage.editButtonDisplays(false); + }); + // Step 16: Validate GET response and confirm appropriate permissions + await test.stepNoScreenshot('Then clinician sees claimed profile data with matching data and no save access', async () => { + await api.compareEndpointResponse('profile-metadata-get', producerPutCapture); + }); + }); +}); diff --git a/dist/tests/claimed/API-User/claimed-email-edit.spec.d.ts b/dist/tests/claimed/API-User/claimed-email-edit.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/claimed/API-User/claimed-email-edit.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/claimed/API-User/claimed-email-edit.spec.js b/dist/tests/claimed/API-User/claimed-email-edit.spec.js new file mode 100644 index 0000000..4b8ec83 --- /dev/null +++ b/dist/tests/claimed/API-User/claimed-email-edit.spec.js @@ -0,0 +1,93 @@ +import { test } from '../../fixtures/base'; +import { test as patientTest } from '../../fixtures/patient-helpers'; +import { test as accountTest } from '../../fixtures/account-helpers'; +import { createNetworkHelper } from '../../fixtures/network-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { AccountSettingsPage } from '../../../page-objects/account/AccountSettingsPage'; +test.describe('Clinician Account Settings Access', () => { + // API Test cases require this to capture network activity + let api; + test('should allow navigation to account settings and capture GET response', { + tag: createValidatedTags([ + TEST_TAGS.PATIENT, + TEST_TAGS.CLAIMED, + TEST_TAGS.API, + TEST_TAGS.UI, + TEST_TAGS.HIGH, + TEST_TAGS.API_USER, + ]), + }, async ({ page }) => { + // Step 1: Log in to clinician account and setup network capture + await test.step('Given clinician has been logged in', async () => { + api = createNetworkHelper(page); + await api.startCapture(); + await page.goto('/data'); + await patientTest.patient.setup(page); + }); + // Step 2: Navigate to account settings + await test.step('When user navigates to account settings', async () => { + await accountTest.account.navigateTo('AccountSettings', page); + }); + // Step 3: Validate profile GET response + await test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema ', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Setup for Account Settings page and previous email for reset + const accountSettingsPage = new AccountSettingsPage(page); + let originalEmail = ''; + // Step 4: Read and change email field to temporary value + await test.step('When user updates the email field', async () => { + originalEmail = await accountSettingsPage.emailInput.inputValue(); + await accountSettingsPage.emailInput.fill('qa+TempEdit@tidepool.org'); + }); + // Step 5: Tap the save button + await test.step('When user taps the save button', async () => { + await accountSettingsPage.saveButton.click(); + }); + // Step 6: Confirm save changes message displays + await test.step('Then the save changes message displays', async () => { + await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); + }); + // Step 7: Validate PUT request and email value + await test.stepNoScreenshot('Then PUT request is validated and email is set to new value', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putCapture = api + .getCaptures() + .find((req) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) + throw new Error('No PUT /profile request captured'); + if (!putCapture.requestBody || + !putCapture.requestBody.email || + putCapture.requestBody.email !== 'qa+TempEdit@tidepool.org') { + throw new Error('PUT request did not set email to qa+TempEdit@tidepool.org'); + } + }); + // Step 8: Change email field to temporary value + await test.step('When user sets the email field to the previous value', async () => { + await accountSettingsPage.emailInput.fill(originalEmail); + }); + // Step 9: Tap the save button + await test.step('When user taps the save button', async () => { + await accountSettingsPage.saveButton.click(); + }); + // Step 10: Confirm save changes message displays + await test.step('Then the save changes message displays', async () => { + await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); + }); + // Step 7: Validate PUT request and email value + await test.stepNoScreenshot('Then PUT request is validated and email is set to new value', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putCapture = api + .getCaptures() + .find((req) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) + throw new Error('No PUT /profile request captured'); + if (!putCapture.requestBody || + !putCapture.requestBody.email || + putCapture.requestBody.email !== originalEmail) { + throw new Error('PUT request did not set email to originalEmail'); + } + }); + await api.stopCapture(); + }); +}); diff --git a/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.d.ts b/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js b/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js new file mode 100644 index 0000000..5285fee --- /dev/null +++ b/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js @@ -0,0 +1,89 @@ +import { test } from '../../fixtures/clinic-helpers'; +import { createNetworkHelper } from '../../fixtures/network-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; +test.describe('Custodial patients are allowed access and modification of profile details', () => { + // Define the workspace and patient at top level + const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; + const CUSTODIAL_PATIENT_SEARCH = 'Custodial Patient'; + // API Test cases require this to capture network activity + let api; + test('should allow navigation to profile details and edit profile fields', { + tag: createValidatedTags([ + TEST_TAGS.CLINICIAN, // User Type (required) + TEST_TAGS.API, // Test Type (required) + TEST_TAGS.UI, // Test Type (required) + TEST_TAGS.HIGH, // Priority (required) + TEST_TAGS.API_PROFILE, // Feature (optional) + ]), + }, async ({ page }, testInfo) => { + // Step 1: Log in to clinician account and setup network capture + await test.step('Given clinician has been logged in', async () => { + api = createNetworkHelper(page); + await api.startCapture(); + await test.clinician.setup(page); + }); + // Step 2: Navigate to workspace + await test.step('When user navigates to desired workspace', async () => { + await test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); + }); + // Step 3: Access custodial patient + await test.step('When user accesses a custodial patient summary', async () => { + await test.clinician.findAndAccessPatientByPartialName(CUSTODIAL_PATIENT_SEARCH, page); + }); + // Step 4: Navigate to profile + await test.step('When user navigates to Profile page', async () => { + await test.clinician.navigateTo('Profile', page); + }); + // Step 5: Capture GET response + await test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Step 6: Open Edit Profile + await test.step('When user selects Edit button', async () => { + await test.clinician.navigateTo('ProfileEdit', page); + }); + // Create Profile page for following steps + const profilePage = new ProfilePage(page); + // Step 7: Change profile fields (custodial access) + await test.step('When user updates profile fields', async () => { + // Generate completely unique values for this custodial test run + const randomSeed = Math.random(); + const randomId = Math.floor(randomSeed * 10000); + const updatedName = `Custodial Patient Updated ${Math.floor(randomId * 10000)}`; + const birthYear = 1980 + (randomId % 15); + const diagnosisYear = birthYear + 25; + const birthDate = `05/20/${birthYear}`; + const diagnosisDate = `08/15/${diagnosisYear}`; + // Generate random 15-digit MRN + const randomMRN = Array.from({ length: 15 }, () => Math.floor(Math.random() * 10).toString()).join(''); + // Generate random 15-letter string for clinical notes + const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); + // Generate unique email + const email = `webuiautomation+custodialEdit${randomId}@tidepool.org`; + // Get current diagnosis index and calculate next one (1-7, wrapping) + const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); + let nextDiagnosisIndex = currentDiagnosisIndex + 1; + if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { + nextDiagnosisIndex = 1; + } + // Update fields using ProfilePage methods + await profilePage.fillFullName(updatedName); + await profilePage.fillBirthDate(birthDate); + await profilePage.fillMRN(randomMRN); + await profilePage.fillDiagnosisDate(diagnosisDate); + await profilePage.selectDiagnosisType(nextDiagnosisIndex); + await profilePage.fillEmail(email); + await profilePage.fillClinicalNotes(randomString); + }); + // Step 8: Save profile edit + await test.step('When user saves profile changes', async () => { + await profilePage.saveProfile(); + }); + // Step 9: Check profile PUT response + await test.step('Then profile endpoint responds with PUT request consistent with schema [no-screenshot]', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + }); + await api.stopCapture(); + }); +}); diff --git a/dist/tests/clinician/add-patient.spec.d.ts b/dist/tests/clinician/add-patient.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/clinician/add-patient.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/clinician/add-patient.spec.js b/dist/tests/clinician/add-patient.spec.js new file mode 100644 index 0000000..65a9915 --- /dev/null +++ b/dist/tests/clinician/add-patient.spec.js @@ -0,0 +1,33 @@ +import { expect, test } from '@fixtures/base'; +import ClinicianDashboardPage from '@pom/clinician/ClinicianDashboardPage'; +import WorkspacesPage from '@pom/clinician/WorkspacesPage'; +test.describe('Add new patient', () => { + // Use a unique patient name for each test run to avoid collisions + const patientName = `Test Patient Playwright ${Date.now()}`; + const patientBirthdate = '01/01/1990'; + test.beforeEach(async () => { + await test.step('Given user has been logged in and navigated to base URL', async () => { }); + }); + test('should successfully add a new patient', async ({ page }) => { + const workspacesPage = new WorkspacesPage(page); + const clinicWorkspacePage = new ClinicianDashboardPage(page); + await test.step('Given the user is on the workspaces page', async () => { + await workspacesPage.goto(); + await workspacesPage.header.waitFor({ state: 'visible' }); + }); + await test.step('When user selects the first workspace', async () => { + await workspacesPage.visitFirstClinic(); + await clinicWorkspacePage.waitForLoadState(); // Wait for clinic page elements + }); + await test.step('When user adds a new patient via dialog', async () => { + await clinicWorkspacePage.openAndFillAddPatientDialog(patientName, patientBirthdate); + await clinicWorkspacePage.submitAddPatientDialog(); + await clinicWorkspacePage.closeBringDataDialog(); + }); + await test.step('Then the new patient should appear in the patient list', async () => { + await clinicWorkspacePage.searchForPatient(patientName); + const patientCell = clinicWorkspacePage.getPatientCellByName(patientName); + await expect(patientCell).toBeVisible(); + }); + }); +}); diff --git a/dist/tests/clinician/create-clinic-workspace.spec.d.ts b/dist/tests/clinician/create-clinic-workspace.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/clinician/create-clinic-workspace.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/clinician/create-clinic-workspace.spec.js b/dist/tests/clinician/create-clinic-workspace.spec.js new file mode 100644 index 0000000..e1363cc --- /dev/null +++ b/dist/tests/clinician/create-clinic-workspace.spec.js @@ -0,0 +1,81 @@ +import { expect, test } from '@fixtures/base'; +import ClinicCreationPage from '@pom/clinician/ClinicCreationPage'; +import WorkspacesPage from '@pom/clinician/WorkspacesPage'; +import { randomUUID } from 'node:crypto'; +test.describe('Create clinic workspace', () => { + const uniqueSuffix = randomUUID().substring(0, 8); + const clinicName = `Test Clinic ${uniqueSuffix}`; + let workspacesPage; + let clinicCreationPage; + test.beforeEach(async ({ page }) => { + workspacesPage = new WorkspacesPage(page); + clinicCreationPage = new ClinicCreationPage(page); + }); + test('should successfully create a new clinic workspace', async ({ page }) => { + await test.step('Given user is on the workspaces page', async () => { + await workspacesPage.goto(); + await expect(workspacesPage.header).toBeVisible(); + await expect(workspacesPage.createClinicButton).toBeVisible(); + }); + await test.step("When user clicks on the 'Create a New Clinic' button", async () => { + await workspacesPage.createClinicButton.click(); + // Wait for the clinic details page to load + await expect(page).toHaveURL(/clinic-details\/new/); + await expect(clinicCreationPage.pageHeader).toBeVisible(); + }); + await test.step('When user fills in all the required clinic information', async () => { + // Fill the clinic form with test data + await clinicCreationPage.fillClinicForm({ + clinicName, + teamType: 'Provider Practice', + state: 'California', + address: '123 Test Street', + city: 'Test City', + zipCode: '12345', + }); + // Verify blood glucose units (mg/dL is pre-selected) + await expect(clinicCreationPage.mgdlRadio).toBeChecked(); + // Verify the admin acknowledgment checkbox is checked + await expect(clinicCreationPage.adminAcknowledgeCheckbox).toBeChecked(); + // Verify Create Workspace button is enabled + await expect(clinicCreationPage.createWorkspaceButton).toBeEnabled(); + }); + await test.step("When user clicks on the 'Create Workspace' button", async () => { + await clinicCreationPage.createWorkspaceButton.click(); + // Wait for redirect to workspaces page + await expect(page).toHaveURL('/workspaces'); + }); + await test.step('Then user should see the new clinic in the list and a success message', async () => { + // Verify success message is shown + const successMessage = page.getByText(`"${clinicName}" clinic created`); + await expect(successMessage).toBeVisible(); + // Verify the new clinic appears in the list + const clinicHeaderLocator = page.getByRole('heading', { name: clinicName }); + await expect(clinicHeaderLocator).toBeVisible(); + // Verify the clinic has the necessary action buttons + const clinicContainer = page + .locator('.workspace-item-clinic') + .filter({ has: clinicHeaderLocator }); + await expect(clinicContainer.getByRole('button', { name: 'Leave Clinic' })).toBeVisible(); + await expect(clinicContainer.getByRole('button', { name: 'Go To Workspace' })).toBeVisible(); + }); + }); + test('should create a new clinic with the simplified createClinic method', async ({ page }) => { + // Navigate to the workspaces page + await page.goto('/workspaces'); + await expect(workspacesPage.header).toBeVisible(); + // Click the "Create a New Clinic" button + await workspacesPage.createClinicButton.click(); + await expect(page).toHaveURL(/clinic-details\/new/); + // Use the simplified method to create a clinic in one step + await clinicCreationPage.createClinic(clinicName); + // Verify we're back on the workspaces page + await expect(page).toHaveURL('/workspaces'); + // Verify the clinic was created + const successMessage = page.getByText(`"${clinicName}" clinic created`); + await expect(successMessage).toBeVisible(); + // Verify the clinic appears in the list + const clinicHeaderLocator = page.getByRole('heading', { name: clinicName }); + await expect(clinicHeaderLocator).toBeVisible(); + }); +}); diff --git a/dist/tests/clinician/edit-clinic-address.spec.d.ts b/dist/tests/clinician/edit-clinic-address.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/clinician/edit-clinic-address.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/clinician/edit-clinic-address.spec.js b/dist/tests/clinician/edit-clinic-address.spec.js new file mode 100644 index 0000000..936d9b5 --- /dev/null +++ b/dist/tests/clinician/edit-clinic-address.spec.js @@ -0,0 +1,42 @@ +import { expect, test } from '@fixtures/base'; +import ClinicAdminPage from '@pom/clinician/WorkspaceSettingsPage'; +import WorkspacesPage from '@pom/clinician/WorkspacesPage'; +test.describe('Edit clinic address', () => { + const newAddress = `123 Test Street ${Date.now()}`; // Unique address for test run + let clinicAdminPage; + let workspacesPage; + test.beforeEach(async ({ page }) => { + clinicAdminPage = new ClinicAdminPage(page); + workspacesPage = new WorkspacesPage(page); + await test.step('Given user has navigated to the Clinic Admin page', async () => { + await workspacesPage.goto(); + await workspacesPage.visitFirstClinic(); + await page.goto('/clinic-admin'); + await clinicAdminPage.waitForLoadState(); // Wait for clinic admin page elements + await clinicAdminPage.clinicDetailsHeader.waitFor({ state: 'visible' }); + }); + }); + test('should successfully edit the clinic address', async ({ page }) => { + await test.step('When user clicks the "Edit" button for workspace details', async () => { + await clinicAdminPage.editDetailsButton.click(); + await clinicAdminPage.editClinicModal.waitFor({ state: 'visible' }); + }); + await test.step('Then user sees the modal for Editing workspace details', async () => { + await expect(clinicAdminPage.editClinicModalTitle).toBeVisible(); + await expect(clinicAdminPage.addressInput).toBeVisible(); + }); + await test.step('When user changes the address', async () => { + await clinicAdminPage.addressInput.fill(newAddress); + }); + await test.step('When user clicks on "Save changes"', async () => { + await clinicAdminPage.saveChangesButton.click(); + await clinicAdminPage.editClinicModal.waitFor({ state: 'hidden' }); // Wait for modal to close + }); + await test.step('Then user sees the updated address on the page', async () => { + // Wait for the details section to potentially update + await page.waitForTimeout(1000); // Small wait for potential DOM update + const detailsText = clinicAdminPage.clinicDetailsSection; + await expect(detailsText).toContainText(newAddress); + }); + }); +}); diff --git a/dist/tests/clinician/filter-patient.spec.d.ts b/dist/tests/clinician/filter-patient.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/clinician/filter-patient.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/clinician/filter-patient.spec.js b/dist/tests/clinician/filter-patient.spec.js new file mode 100644 index 0000000..e4abb1c --- /dev/null +++ b/dist/tests/clinician/filter-patient.spec.js @@ -0,0 +1,65 @@ +import { expect, test } from '@fixtures/base'; +import ClinicianDashboardPage from '@pom/clinician/ClinicianDashboardPage'; +import WorkspacesPage from '@pom/clinician/WorkspacesPage'; +test.describe('Filter patients in clinic', () => { + // Use unique patient names for each test run + const timestamp = Date.now(); + const patientName1 = `Filter Patient A ${timestamp}`; + const patientName2 = `Filter Patient B ${timestamp}`; + const patientBirthdate = '01/01/1995'; // Shared birthdate for simplicity + let workspacesPage; + let clinicWorkspacePage; + test.beforeEach(async ({ page }) => { + workspacesPage = new WorkspacesPage(page); + clinicWorkspacePage = new ClinicianDashboardPage(page); + await test.step('Given user has been logged in and navigated to base URL', async () => { + await workspacesPage.goto(); + await page.waitForURL(workspacesPage.url); + await workspacesPage.header.waitFor({ state: 'visible' }); + }); + await test.step('Given the user is on the first clinic workspace', async () => { + await workspacesPage.visitFirstClinic(); + await clinicWorkspacePage.waitForLoadState(); // Wait for clinic page elements + }); + await test.step('Given two patients exist', async () => { + // Add first patient + await clinicWorkspacePage.openAndFillAddPatientDialog(patientName1, patientBirthdate); + await clinicWorkspacePage.submitAddPatientDialog(); + await clinicWorkspacePage.closeBringDataDialog(); + // Ensure the first patient is added before adding the second + await expect(clinicWorkspacePage.getPatientCellByName(patientName1)).toBeVisible({ + timeout: 10000, + }); + // Add second patient + await clinicWorkspacePage.openAndFillAddPatientDialog(patientName2, patientBirthdate); + await clinicWorkspacePage.submitAddPatientDialog(); + await clinicWorkspacePage.closeBringDataDialog(); + // Ensure the second patient is also added + await expect(clinicWorkspacePage.getPatientCellByName(patientName2)).toBeVisible({ + timeout: 10000, + }); + }); + }); + test('should successfully filter patients by name', async () => { + await test.step("When user filters by the first patient's name", async () => { + await clinicWorkspacePage.searchForPatient(patientName1); + }); + await test.step('Then only the first patient should be visible', async () => { + const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); + const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); + await expect(patientCell1).toBeVisible(); + await expect(patientCell2).not.toBeVisible(); + }); + await test.step('When user clears the filter', async () => { + // Assuming a method like clearPatientSearch exists or searchForPatient('') clears + await clinicWorkspacePage.searchForPatient(''); // Clear search by searching for empty string + // Or potentially: await clinicWorkspacePage.clearPatientSearch(); + }); + await test.step('Then both patients should be visible again', async () => { + const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); + const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); + await expect(patientCell1).toBeVisible(); + await expect(patientCell2).toBeVisible(); + }); + }); +}); diff --git a/dist/tests/fixtures/account-helpers.d.ts b/dist/tests/fixtures/account-helpers.d.ts new file mode 100644 index 0000000..21ab3cd --- /dev/null +++ b/dist/tests/fixtures/account-helpers.d.ts @@ -0,0 +1,20 @@ +import { test as base } from '@fixtures/base'; +import AccountNav from '@pom/account/AccountNavigation'; +import type { Page } from '@playwright/test'; +/** + * Switch user authentication context by loading different storageState + * @param userType - The user type corresponding to the storageState file (e.g., 'shared', 'clinician', 'claimed') + * @param page - The Playwright page instance + */ +declare function switchUser(userType: string, page: Page): Promise; +/** + * Core navigation function that handles account navigation consistently + */ +declare function navigateTo(targetPage: keyof AccountNav['pages'], page: Page): Promise; +declare const test: typeof base & { + account: { + navigateTo: typeof navigateTo; + switchUser: typeof switchUser; + }; +}; +export { test }; diff --git a/dist/tests/fixtures/account-helpers.js b/dist/tests/fixtures/account-helpers.js new file mode 100644 index 0000000..0e92578 --- /dev/null +++ b/dist/tests/fixtures/account-helpers.js @@ -0,0 +1,84 @@ +import { test as base } from '@fixtures/base'; +import AccountNav from '@pom/account/AccountNavigation'; +/** + * Switch user authentication context by loading different storageState + * @param userType - The user type corresponding to the storageState file (e.g., 'shared', 'clinician', 'claimed') + * @param page - The Playwright page instance + */ +async function switchUser(userType, page) { + try { + // Import fs dynamically + const fs = await import('node:fs'); + // Load the specified user's storage state + const storageStatePath = `tests/.auth/${userType}.json`; + const storageState = JSON.parse(fs.readFileSync(storageStatePath, 'utf-8')); + // Clear existing cookies first + await page.context().clearCookies(); + // Set cookies from the new user's storage state + if (storageState.cookies) { + await page.context().addCookies(storageState.cookies); + } + // Set localStorage from the new user's storage state + if (storageState.origins) { + for (const origin of storageState.origins) { + await page.addInitScript(originData => { + if (originData.localStorage) { + for (const item of originData.localStorage) { + localStorage.setItem(item.name, item.value); + } + } + }, origin); + } + } + console.log(`āœ… Successfully switched to ${userType} user authentication`); + } + catch (error) { + throw new Error(`Failed to switch to ${userType} user: ${error}`); + } +} +/** + * Core navigation function that handles account navigation consistently + */ +async function navigateTo(targetPage, page) { + const nav = new AccountNav(page); + const pageConfig = nav.pages[targetPage]; + try { + // Single page check at start + if (page.isClosed()) + return; + // Quick DOM ready check only + await page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => { }); + // Open navigation menu if needed (only for non-AccountNav targets) + if (targetPage !== 'AccountNav') { + const menuVisible = await nav.pages.AccountNav.verifyElement + .isVisible({ timeout: 1000 }) + .catch(() => false); + if (!menuVisible) { + await nav.pages.AccountNav.link.click(); + await nav.pages.AccountNav.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + } + } + // Handle logout specially + if (targetPage === 'Logout') { + await pageConfig.link.click(); + await page + .waitForURL(/.*login.*/, { waitUntil: 'domcontentloaded', timeout: 5000 }) + .catch(() => { }); + } + else { + // Standard navigation - click and verify + await pageConfig.link.click(); + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + } + } + catch (error) { + if (!page.isClosed()) + throw error; + } +} +const test = base; +test.account = { + navigateTo, + switchUser, +}; +export { test }; diff --git a/dist/tests/fixtures/base.d.ts b/dist/tests/fixtures/base.d.ts new file mode 100644 index 0000000..2b00097 --- /dev/null +++ b/dist/tests/fixtures/base.d.ts @@ -0,0 +1,23 @@ +import { Page, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, TestType } from '@playwright/test'; +interface CustomFixtures { + timeLogger: Page; + timeStepLogger: Page; + stepTimer: Page; + stepScreenshoter: Page; + exceptionLogger: Page; +} +export declare const test: TestType; +export { expect } from '@playwright/test'; +/** + * Decorator function for wrapping POM methods in a test.step. + * + * Use it without a step name `@step()`. + * + * Or with a step name `@step("Search something")`. + * + * @param stepName - The name of the test step. + * @returns A decorator function that can be used to decorate test methods. + */ +export declare function step(stepName?: string): (target: any, context: ClassMethodDecoratorContext) => (this: { + name: string; +}, ...args: any[]) => Promise; diff --git a/dist/tests/fixtures/base.js b/dist/tests/fixtures/base.js new file mode 100644 index 0000000..ccbeab6 --- /dev/null +++ b/dist/tests/fixtures/base.js @@ -0,0 +1,219 @@ +import { test as base, } from '@playwright/test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +// Define the test type with custom fixtures +export const test = base.extend({ + page: async ({ page }, use, testInfo) => { + const modifiedTestInfo = testInfo; + modifiedTestInfo.snapshotSuffix = ''; + modifiedTestInfo.snapshotPath = name => `${testInfo.file}-snapshots/${name}`; + // Make testInfo globally available for network helpers + globalThis.testInfo = testInfo; + try { + await use(page); + } + finally { + // Clean up after test + delete globalThis.testInfo; + } + }, + timeLogger: [ + async ({ page }, use, testInfo) => { + testInfo.annotations.push({ + type: 'Start', + description: new Date().toISOString(), + }); + await use(page); + testInfo.annotations.push({ + type: 'End', + description: new Date().toISOString(), + }); + }, + { auto: true }, + ], + timeStepLogger: [ + async ({ page }, use, testInfo) => { + const startTime = Date.now(); + console.time(`[test] ${testInfo.title}`); + await use(page); + console.timeEnd(`[test] ${testInfo.title}`); + const endTime = Date.now(); + const duration = endTime - startTime; + testInfo.annotations.push({ + type: 'Duration', + description: `${duration}ms`, + }); + testInfo.annotations.push({ + type: 'End', + description: new Date().toISOString(), + }); + }, + { auto: true }, + ], + stepTimer: [ + async ({ page }, use, testInfo) => { + const originalStep = test.step; + const stepTimings = new Map(); + // Create a new step function with the same interface as the original + const newStep = function newStepWrapper(name, fn) { + return originalStep.call(this, name, async (stepInfo) => { + const startTime = Date.now(); + console.time(`[step] ${name}`); + const result = await fn(stepInfo); + console.timeEnd(`[step] ${name}`); + const endTime = Date.now(); + const duration = endTime - startTime; + stepTimings.set(name, duration); + testInfo.annotations.push({ + type: `Step Duration: ${name}`, + description: `${duration}ms`, + }); + return result; + }); + }; + // Add the skip method to match the original test.step interface + newStep.skip = function skipStep(name, fn) { + return originalStep.skip.call(this, name, fn); + }; + // Replace the original step with our enhanced version + test.step = newStep; + await use(page); + // Restore original test.step + test.step = originalStep; + }, + { auto: true }, + ], + stepScreenshoter: [ + async ({ page }, use, testInfo) => { + const originalStep = test.step; + let stepCounter = 0; + // Create a safe directory name based on test info + const testDirName = path.basename(testInfo.file, '.spec.ts').replace(/[^a-z0-9]/gi, '-'); + const screenshotDir = path.join('test-results', testDirName); + // Store current step name for network helpers + let currentStepName = ''; + // Make step counter accessible globally for network helper + globalThis.__stepCounter = { + get: () => stepCounter, + increment: () => ++stepCounter, + getDirectory: () => screenshotDir, + getCurrentStepName: () => currentStepName, + setCurrentStepName: (name) => { + currentStepName = name; + }, + }; + // Clean up existing screenshots from previous runs + try { + await fs.promises.access(screenshotDir); + await fs.promises.rm(screenshotDir, { recursive: true, force: true }); + } + catch { + // Directory doesn't exist, no need to clean up + } + // Create a new step function that takes screenshots after completion and attaches them to the report + const newStep = function newStepScreenshot(name, fn) { + return originalStep.call(this, name, async (stepInfo) => { + // Set current step name for network helpers (clean name without [no-screenshot]) + const stepCounterObj = globalThis.__stepCounter; + if (stepCounterObj) { + const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); + stepCounterObj.setCurrentStepName(cleanName); + } + const result = await fn(stepInfo); + // Skip screenshot if step name contains [no-screenshot] + if (name.includes('[no-screenshot]')) { + return result; + } + // Take screenshot after step completion + stepCounter += 1; + try { + if (!page.isClosed()) { + // Use clean name for filename (without [no-screenshot]) + const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); + const screenshotName = `step-${stepCounter.toString().padStart(2, '0')}-${cleanName.toLowerCase().replace(/[^a-z0-9]/g, '-')}.png`; + // Take screenshot directly to buffer (no local file) + const screenshot = await page.screenshot({ + fullPage: true, + }); + // Attach to Playwright report AND force test-results folder creation + if (testInfo && typeof testInfo.attach === 'function') { + await testInfo.attach(screenshotName, { + body: screenshot, + contentType: 'image/png', + }); + // Also save to test-results for organized viewing (single source) + const testResultsDir = path.join(testInfo.outputDir, 'attachments'); + await fs.promises.mkdir(testResultsDir, { recursive: true }); + const screenshotPath = path.join(testResultsDir, screenshotName); + await fs.promises.writeFile(screenshotPath, screenshot); + } + } + } + catch (error) { } + return result; + }); + }; + // Add the skip method to match the original test.step interface + newStep.skip = function skipStepScreenshot(name, fn) { + return originalStep.skip.call(this, name, fn); + }; + // Add a custom stepNoScreenshot function for API validation steps + const stepNoScreenshot = function stepNoScreenshot(name, fn) { + return originalStep.call(this, name, async (stepInfo) => { + // Set current step name for network helpers (clean name) + const stepCounterObj = globalThis.__stepCounter; + if (stepCounterObj) { + stepCounterObj.setCurrentStepName(name); + } + const result = await fn(stepInfo); + // No screenshot taken for this step type + // console.log(`ā­ļø API step completed without screenshot: ${name}`); + return result; + }); + }; + // Replace the original step with our enhanced version + test.step = newStep; + // Add the no-screenshot step function to the test object + test.stepNoScreenshot = stepNoScreenshot; + await use(page); + // Restore original test.step + test.step = originalStep; + }, + { auto: true }, + ], + exceptionLogger: [ + async ({ page }, use, testInfo) => { + const errors = []; + page.on('pageerror', (error) => { + errors.push(error); + }); + await use(page); + if (errors.length > 0) { + await testInfo.attach('frontend-exceptions', { + body: errors.map(error => `${error.message}\n${error.stack}`).join('\n---------\n'), + }); + throw new Error('Some frontend exceptions occurred'); + } + }, + { auto: true }, + ], +}); +export { expect } from '@playwright/test'; +/** + * Decorator function for wrapping POM methods in a test.step. + * + * Use it without a step name `@step()`. + * + * Or with a step name `@step("Search something")`. + * + * @param stepName - The name of the test step. + * @returns A decorator function that can be used to decorate test methods. + */ +export function step(stepName) { + return function decorator(target, context) { + return function replacementMethod(...args) { + const name = `${stepName || context.name} (${this.name})`; + return test.step(name, async () => await target.call(this, ...args)); + }; + }; +} diff --git a/dist/tests/fixtures/clinic-helpers.d.ts b/dist/tests/fixtures/clinic-helpers.d.ts new file mode 100644 index 0000000..170b58e --- /dev/null +++ b/dist/tests/fixtures/clinic-helpers.d.ts @@ -0,0 +1,61 @@ +import { test as base } from '@fixtures/base'; +import type { Page } from '@playwright/test'; +import ClinicianNav from '../../page-objects/clinician/ClinicianNavigation'; +export type WorkspaceKey = 'AdminClinicBase' | 'AdminClinicEnterprise' | 'MemberClinicBase' | 'MemberClinicEnterprise' | 'NonMemberClinicBase' | 'NonMemberClinicEnterprise' | 'PartnerClinicBase' | 'PartnerClinicEnterprise'; +export type PageKey = 'PatientList' | 'WorkspaceSettings' | 'AddPatient' | 'Profile' | 'ProfileEdit'; +/** + * Initialize clinician navigation helpers after login + */ +declare function setupClinicianSession(page: Page): Promise; +/** + * Navigate to workspace selection page + */ +declare function navigateToWorkspaceSelection(page: Page): Promise; +/** + * Navigate to a specific workspace using hardcoded workspace key + */ +declare function navigateToWorkspace(workspaceKey: WorkspaceKey, page: Page): Promise; +/** + * Core navigation function that handles workspace prerequisites and page navigation + */ +declare function navigateTo(targetPage: PageKey, page: Page, workspaceKey?: WorkspaceKey): Promise; +/** + * Execute test logic across multiple workspaces + */ +declare function executeAcrossWorkspaces(workspaceConfigs: { + workspaceKey: WorkspaceKey; +}[], action: (config: { + workspaceKey: WorkspaceKey; +}) => Promise, page: Page): Promise; +/** + * Find and access any patient whose name contains the search term (optimized version) + * @param searchTerm - Partial name to search for (e.g., "Custodial") + * @param page - The Playwright page object + * @returns The full name of the patient that was accessed + */ +declare function findAndAccessPatientByPartialName(searchTerm: string, page: Page): Promise; +/** + * Find and access any available patient (fastest option) + * @param page - The Playwright page object + * @returns The full name of the first patient that was accessed + */ +declare function findAndAccessAnyPatient(page: Page): Promise; +/** + * Access a specific patient by name and navigate to their summary page + * @param patientName - The name of the patient to access + * @param page - The Playwright page object + */ +declare function accessPatient(patientName: string, page: Page): Promise; +declare const test: typeof base & { + clinician: { + navigateTo: typeof navigateTo; + navigateToWorkspace: typeof navigateToWorkspace; + navigateToWorkspaceSelection: typeof navigateToWorkspaceSelection; + executeAcrossWorkspaces: typeof executeAcrossWorkspaces; + accessPatient: typeof accessPatient; + findAndAccessPatientByPartialName: typeof findAndAccessPatientByPartialName; + findAndAccessAnyPatient: typeof findAndAccessAnyPatient; + setup: typeof setupClinicianSession; + }; +}; +export { test }; diff --git a/dist/tests/fixtures/clinic-helpers.js b/dist/tests/fixtures/clinic-helpers.js new file mode 100644 index 0000000..31fd2d1 --- /dev/null +++ b/dist/tests/fixtures/clinic-helpers.js @@ -0,0 +1,274 @@ +import { test as base } from '@fixtures/base'; +import ClinicianNav from '../../page-objects/clinician/ClinicianNavigation'; +import ClinicianDashboardPage from '../../page-objects/clinician/ClinicianDashboardPage'; +import AccountNav from '../../page-objects/account/AccountNavigation'; +/** + * Initialize clinician navigation helpers after login + */ +async function setupClinicianSession(page) { + // Wait for clinician navigation to be available + const nav = new ClinicianNav(page); + // Navigate to login and setup clinic session if needed + if (!page.url().includes('clinic-workspace')) { + await page.goto('/login'); + // Add any necessary login steps here + } + console.log('šŸ„ Clinic session setup complete'); + return nav; +} +/** + * Navigate to workspace selection page + */ +async function navigateToWorkspaceSelection(page) { + const accountNav = new AccountNav(page); + // Open the account navigation menu first + await accountNav.pages.AccountNav.link.click(); + // Then click the ManageWorkspaces option + await accountNav.pages.ManageWorkspaces.link.click(); + // Verify we're on the workspace selection page using the known verification element + await accountNav.pages.ManageWorkspaces.verifyElement.waitFor({ + state: 'visible', + timeout: 5000, + }); + // console.log('āœ… Navigated to workspace selection page'); +} +/** + * Navigate to a specific workspace using hardcoded workspace key + */ +async function navigateToWorkspace(workspaceKey, page) { + const clinicianNav = new ClinicianNav(page); + // First navigate to workspace selection if not already there + if (!page.url().includes('workspaces')) { + await navigateToWorkspaceSelection(page); + } + // Click on the specific workspace using the page object locator + await clinicianNav.workspaces[workspaceKey].link.click(); + // Verify we're in the correct workspace using URL verification + await page.waitForURL(new RegExp(clinicianNav.workspaces[workspaceKey].verifyURL), { + timeout: 5000, + }); + // console.log(`āœ… Successfully navigated to workspace: ${clinicianNav.workspaces[workspaceKey].name}`); +} +/** + * Core navigation function that handles workspace prerequisites and page navigation + */ +async function navigateTo(targetPage, page, workspaceKey) { + const clinicianNav = new ClinicianNav(page); + const pageConfig = clinicianNav.pages[targetPage]; + // Ensure we're in a workspace context (but don't auto-switch if already in one) + const isInWorkspaceContext = page.url().includes('clinic-workspace') || + page.url().includes('/patients/') || + page.url().includes('/profile'); + if (!isInWorkspaceContext) { + const defaultWorkspace = workspaceKey || 'AdminClinicBase'; + await navigateToWorkspace(defaultWorkspace, page); + } + else if (workspaceKey) { + // Only switch if specifically requested and we can verify we're in wrong workspace + const currentUrl = page.url(); + const targetWorkspacePattern = clinicianNav.workspaces[workspaceKey].verifyURL; + if (!currentUrl.includes(targetWorkspacePattern)) { + await navigateToWorkspace(workspaceKey, page); + } + } + // Handle page-specific prerequisites + if (targetPage === 'AddPatient') { + // AddPatient might need to be on PatientList first + if (!page.url().includes('patients')) { + await clinicianNav.pages.PatientList.link.click(); + await clinicianNav.pages.PatientList.verifyElement.waitFor({ + state: 'visible', + timeout: 5000, + }); + } + } + // Perform the actual navigation + try { + await pageConfig.link.click(); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`Failed to click ${targetPage}: ${errorMessage}`); + throw error; + } + // Verify navigation succeeded + try { + if (pageConfig.verifyURL) { + await page.waitForURL(`**/*${pageConfig.verifyURL}*`, { timeout: 5000 }); + } + if (pageConfig.verifyElement) { + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + } + // console.log(`āœ… Navigated to page: ${targetPage}`); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + // console.log(`Navigation verification failed for ${targetPage}: ${errorMessage}`); + } +} +/** + * Execute test logic across multiple workspaces + */ +async function executeAcrossWorkspaces(workspaceConfigs, action, page) { + for (const config of workspaceConfigs) { + console.log(`šŸ”„ Executing across workspace: ${config.workspaceKey}`); + // Navigate to the workspace + await navigateToWorkspace(config.workspaceKey, page); + // Execute the action + await action(config); + // Navigate back to workspace selection for next iteration + if (workspaceConfigs.indexOf(config) < workspaceConfigs.length - 1) { + await navigateToWorkspaceSelection(page); + } + } +} +/** + * Find and access any patient whose name contains the search term (optimized version) + * @param searchTerm - Partial name to search for (e.g., "Custodial") + * @param page - The Playwright page object + * @returns The full name of the patient that was accessed + */ +async function findAndAccessPatientByPartialName(searchTerm, page) { + const dashboard = new ClinicianDashboardPage(page); + // If empty search term, find any available patient + if (!searchTerm || searchTerm.trim() === '') { + return findAndAccessAnyPatient(page); + } + // Strategy 1: Fill search field THEN click Show All (proven fastest method) + try { + await dashboard.searchInput.fill(searchTerm); + await page.waitForTimeout(500); + const showAllButton = page + .getByRole('button', { name: 'Show All' }) + .or(page.getByRole('button', { name: 'Show all' })) + .or(page.getByText('Show All')) + .or(page.getByText('Show all')); + if (await showAllButton.isVisible({ timeout: 1000 })) { + await showAllButton.click(); + await page.waitForTimeout(1000); + const searchResultCells = await dashboard.patientListTable.getByRole('cell').all(); + if (searchResultCells.length > 0) { + for (const cell of searchResultCells) { + const cellText = await cell.textContent(); + if (cellText && cellText.toLowerCase().includes(searchTerm.toLowerCase())) { + await cell.click(); + await page.waitForTimeout(600); + return cellText.trim(); + } + } + } + } + else { + await dashboard.searchInput.press('Enter'); + await page.waitForTimeout(1000); + const searchResultCells = await dashboard.patientListTable.getByRole('cell').all(); + if (searchResultCells.length > 0) { + for (const cell of searchResultCells) { + const cellText = await cell.textContent(); + if (cellText && cellText.toLowerCase().includes(searchTerm.toLowerCase())) { + await cell.click(); + await page.waitForTimeout(600); + return cellText.trim(); + } + } + } + } + } + catch (error) { + // Silent fallback to any patient + } + // Strategy 2: Fallback to any available patient if specific search fails + try { + return await findAndAccessAnyPatient(page); + } + catch (fallbackError) { + throw new Error(`No patient found containing "${searchTerm}" and no fallback patients available`); + } +} +/** + * Find and access any available patient (fastest option) + * @param page - The Playwright page object + * @returns The full name of the first patient that was accessed + */ +async function findAndAccessAnyPatient(page) { + const dashboard = new ClinicianDashboardPage(page); + try { + // Clear search to show all patients + await dashboard.searchInput.click(); + await dashboard.searchInput.fill(' '); + await page.waitForTimeout(500); + await dashboard.searchInput.fill(''); + await page.waitForTimeout(1500); + let allCells = await dashboard.patientListTable.getByRole('cell').all(); + // If no cells, try pressing Enter on empty search + if (allCells.length === 0) { + await dashboard.searchInput.press('Enter'); + await page.waitForTimeout(1500); + allCells = await dashboard.patientListTable.getByRole('cell').all(); + } + // Find the first cell that looks like a patient name + for (const cell of allCells) { + const cellText = await cell.textContent(); + if (cellText && cellText.trim().length > 3 && cellText.includes(' ')) { + await cell.click(); + await page.waitForTimeout(800); + return cellText.trim(); + } + } + throw new Error('No patient names found in table'); + } + catch (error) { + throw new Error(`Failed to find any patient: ${error}`); + } +} +/** + * Access a specific patient by name and navigate to their summary page + * @param patientName - The name of the patient to access + * @param page - The Playwright page object + */ +async function accessPatient(patientName, page) { + const dashboard = new ClinicianDashboardPage(page); + console.log(`šŸ” Searching for patient: ${patientName}`); + // Try optimized search first + await dashboard.searchForPatient(patientName); + await page.waitForTimeout(1000); // Reduced wait time + // Check if search worked + const patientCell = dashboard.getPatientCellByName(patientName); + const isVisible = await patientCell.isVisible({ timeout: 2000 }); + if (isVisible) { + console.log(`šŸ‘¤ Found patient via search: ${patientName}`); + await patientCell.click(); + await page.waitForTimeout(1000); + console.log(`āœ… Successfully accessed patient summary for: ${patientName}`); + return; + } + // If search failed, fall back to show all + find + console.log(`šŸ”„ Search failed, trying show all approach...`); + const showAllButton = page.getByRole('button', { name: 'Show All' }); + if (await showAllButton.isVisible({ timeout: 1000 })) { + await showAllButton.click(); + await page.waitForTimeout(1500); + } + // Try again after showing all + const isVisibleAfterShowAll = await patientCell.isVisible({ timeout: 2000 }); + if (isVisibleAfterShowAll) { + await patientCell.click(); + await page.waitForTimeout(1000); + // console.log(`āœ… Successfully accessed patient summary for: ${patientName}`); + return; + } + // If still not found, throw error + throw new Error(`Patient "${patientName}" not found in current workspace`); +} +const test = base; +test.clinician = { + navigateTo, + navigateToWorkspace, + navigateToWorkspaceSelection, + executeAcrossWorkspaces, + accessPatient, + findAndAccessPatientByPartialName, + findAndAccessAnyPatient, + setup: setupClinicianSession, +}; +export { test }; diff --git a/dist/tests/fixtures/network-helpers.d.ts b/dist/tests/fixtures/network-helpers.d.ts new file mode 100644 index 0000000..78ad092 --- /dev/null +++ b/dist/tests/fixtures/network-helpers.d.ts @@ -0,0 +1,112 @@ +import { Page } from '@playwright/test'; +import { type EndpointName } from '../../endpoint-schema/endpoint-registry'; +export interface NetworkCapture { + url: string; + method: string; + requestBody?: any; + responseBody?: any; + statusCode?: number; + timestamp: number; +} +/** + * Simple network helper for API validation + */ +export declare class NetworkHelper { + private page; + private captures; + private isCapturing; + constructor(page: Page); + startCapture(): Promise; + stopCapture(): Promise; + waitForEndpoint(endpointName: string, method: string, timeout?: number): Promise; + getCaptures(): NetworkCapture[]; + /** + * Simple helper to validate endpoint requests by URL pattern and method + */ + validateEndpointRequests(urlPattern: string, method: string): NetworkCapture[]; + /** + * Save all captures to a JSON file + */ + saveCapturesTo(filename: string, testInfo?: import('@playwright/test').TestInfo): Promise; + /** + * Print a summary of all captures to console + */ + printCaptureSummary(): void; + /** + * Get captures filtered by status code + */ + getCapturesByStatus(statusCode: number): NetworkCapture[]; + /** + * Get the most recent capture matching method and URL pattern + */ + getLatestCaptureMatching(method: string, urlPattern: RegExp): NetworkCapture | null; + /** + * Get all captures for a specific endpoint + */ + getCapturesForEndpoint(endpointName: string): NetworkCapture[]; + /** + * Get all captures + */ + getAllCaptures(): NetworkCapture[]; + /** + * Save API response as JSON attachment and to organized test-results folder + */ + saveApiResponse(response: any, endpoint: string, method: string, fileName: string, testInfo?: import('@playwright/test').TestInfo): Promise; + /** + * Validate and save API response for any endpoint defined in the endpoint registry + * @param endpointName - The endpoint name from the registry (e.g., 'profile-metadata-get') + * @returns The captured network request or null if not found + */ + validateEndpointResponse(endpointName: EndpointName): Promise; + /** + * Save network capture for producer/consumer test patterns + * @param endpointName - The endpoint to save + * @param testName - Name of the test (used for file naming) + * @returns The saved network capture or null + */ + saveForDependentTests(endpointName: EndpointName, testName: string): Promise; + /** + * Load producer test data for consumer tests + * @param testName - Name of the producer test (used for file naming) + * @returns The loaded network capture or null + */ + loadFromProducerTest(testName: string): NetworkCapture | null; + /** + * Validate data consistency between producer and consumer responses + * @param producerCapture - The producer test network capture + * @param consumerCapture - The consumer test network capture + * @param fieldsToValidate - Array of field paths to validate (e.g., ['fullName', 'patient.birthday']) + * @param requiredFields - Array of fields that must exist and match (defaults to common required fields) + */ + validateDataConsistency(producerCapture: NetworkCapture, consumerCapture: NetworkCapture, fieldsToValidate?: string[], requiredFields?: string[]): void; + /** + * Helper method to get nested object values using dot notation + * @param obj - The object to search + * @param path - The dot-notation path (e.g., 'patient.birthday') + * @returns The value at the path or undefined + */ + private getNestedValue; + /** + * Validate producer-consumer data consistency for profile endpoints + * @param producerEndpointName - The PUT endpoint name (e.g., 'profile-metadata-put') + * @param consumerEndpointName - The GET endpoint name (e.g., 'profile-metadata-get') + * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) + * @throws Error if validation fails + */ + validateProducerConsumerData(producerEndpointName: EndpointName, consumerEndpointName: EndpointName, fieldsToValidate?: string[]): Promise; + /** + * Private method to validate endpoint response without generating JSON file + * @param endpointName - The endpoint name from the registry + * @returns The captured network request or null if not found + */ + private validateEndpointResponseSilent; + /** + * Complete validation workflow for a user viewing profile data + * Validates both API schema and data consistency in one call + * @param consumerEndpointName - The GET endpoint name + * @param producerCapture - The stored PUT capture from the producer + * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) + */ + compareEndpointResponse(consumerEndpointName: EndpointName, producerCapture: NetworkCapture, fieldsToValidate?: string[]): Promise; +} +export declare function createNetworkHelper(page: Page): NetworkHelper; diff --git a/dist/tests/fixtures/network-helpers.js b/dist/tests/fixtures/network-helpers.js new file mode 100644 index 0000000..09fb0cf --- /dev/null +++ b/dist/tests/fixtures/network-helpers.js @@ -0,0 +1,442 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { getEndpointSchema, } from '../../endpoint-schema/endpoint-registry'; +const ENDPOINTS = { + profile: /\/data\/[^\/]+$/, // GET requests for patient data + profileUpdate: /\/data\/[^\/]+$/, // PUT requests for patient data updates + profileMetrics: /\/metrics\/thisuser\//, + profileMessage: /\/message\/notes\//, +}; +/** + * Simple network helper for API validation + */ +export class NetworkHelper { + constructor(page) { + this.captures = []; + this.isCapturing = false; + this.page = page; + } + async startCapture() { + if (this.isCapturing) + return; + // Only intercept API requests we care about to avoid interfering with other requests + const apiPatterns = [ + '**/data/**', + '**/metrics/**', + '**/message/**', + '**/auth/**', + '**/v1/**', + '**/metadata/**', + '**/user/**', + '**/users/**', + '**/profile/**', + ]; + for (const pattern of apiPatterns) { + await this.page.route(pattern, async (route) => { + const request = route.request(); + try { + const response = await route.fetch(); + let requestBody; + let responseBody; + try { + requestBody = request.postDataJSON(); + } + catch { + requestBody = request.postData(); + } + try { + responseBody = await response.json(); + } + catch { + responseBody = await response.text(); + } + this.captures.push({ + url: request.url(), + method: request.method(), + requestBody, + responseBody, + statusCode: response.status(), + timestamp: Date.now(), + }); + await route.fulfill({ response }); + } + catch (error) { + // If there's an error, continue the request without handling + try { + await route.continue(); + } + catch { + // Route might already be handled, ignore + } + } + }); + } + this.isCapturing = true; + } + async stopCapture() { + if (!this.isCapturing) + return; + // Remove all API route handlers + const apiPatterns = ['**/data/**', '**/metrics/**', '**/message/**', '**/auth/**', '**/v1/**']; + for (const pattern of apiPatterns) { + await this.page.unroute(pattern); + } + this.isCapturing = false; + } + async waitForEndpoint(endpointName, method, timeout = 30000) { + const pattern = ENDPOINTS[endpointName]; + if (!pattern) { + throw new Error(`Unknown endpoint: ${endpointName}`); + } + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + const matches = this.captures.filter(capture => pattern.test(capture.url) && capture.method.toLowerCase() === method.toLowerCase()); + if (matches.length > 0) { + return matches[matches.length - 1]; // Return latest match + } + await this.page.waitForTimeout(100); + } + throw new Error(`${method} request to ${endpointName} not found within ${timeout}ms`); + } + getCaptures() { + return [...this.captures]; + } + /** + * Simple helper to validate endpoint requests by URL pattern and method + */ + validateEndpointRequests(urlPattern, method) { + return this.captures.filter(c => c.url.includes(urlPattern) && c.method === method); + } + /** + * Save all captures to a JSON file + */ + async saveCapturesTo(filename, testInfo) { + const logDir = path.join(process.cwd(), 'log'); + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + // Create capture data + const captureData = { + timestamp: new Date().toISOString(), + totalCaptures: this.captures.length, + captures: this.captures, + }; + // Use Playwright's automatic attachment instead of manual file writing + if (testInfo && typeof testInfo.attach === 'function') { + await testInfo.attach(filename, { + body: JSON.stringify(captureData, null, 2), + contentType: 'application/json', + }); + console.log(`šŸ“„ Network captures attached to Playwright report: ${filename}`); + } + else { + console.log(`šŸ“„ Network captures ready (${this.captures.length} captures)`); + } + } + /** + * Print a summary of all captures to console + */ + printCaptureSummary() { + console.log(`\nšŸ“Š Network Capture Summary (${this.captures.length} total requests):`); + console.log('='.repeat(60)); + this.captures.forEach((capture, index) => { + const timestamp = new Date(capture.timestamp).toLocaleTimeString(); + console.log(`${index + 1}. ${capture.method} ${capture.statusCode} - ${capture.url}`); + console.log(` Time: ${timestamp}`); + if (capture.requestBody) { + console.log(` Request: ${JSON.stringify(capture.requestBody).substring(0, 100)}...`); + } + console.log(''); + }); + } + /** + * Get captures filtered by status code + */ + getCapturesByStatus(statusCode) { + return this.captures.filter(c => c.statusCode === statusCode); + } + /** + * Get the most recent capture matching method and URL pattern + */ + getLatestCaptureMatching(method, urlPattern) { + const matches = this.captures + .filter(c => c.method === method && urlPattern.test(c.url)) + .sort((a, b) => b.timestamp - a.timestamp); + return matches.length > 0 ? matches[0] : null; + } + /** + * Get all captures for a specific endpoint + */ + getCapturesForEndpoint(endpointName) { + const pattern = ENDPOINTS[endpointName]; + if (!pattern) { + throw new Error(`Unknown endpoint: ${endpointName}`); + } + return this.captures.filter(c => pattern.test(c.url)); + } + /** + * Get all captures + */ + getAllCaptures() { + return [...this.captures]; + } + /** + * Save API response as JSON attachment and to organized test-results folder + */ + async saveApiResponse(response, endpoint, method, fileName, testInfo) { + const responseData = { + _request: { + method, + endpoint, + }, + ...response, + }; + const jsonContent = JSON.stringify(responseData, null, 2); + // Attach to Playwright report AND save to organized test-results folder + if (testInfo && typeof testInfo.attach === 'function') { + await testInfo.attach(fileName, { + body: jsonContent, + contentType: 'application/json', + }); + // Also save to test-results for organized viewing (like screenshots) + const testResultsDir = path.join(testInfo.outputDir, 'attachments'); + await fs.promises.mkdir(testResultsDir, { recursive: true }); + const jsonPath = path.join(testResultsDir, fileName); + await fs.promises.writeFile(jsonPath, jsonContent, 'utf8'); + } + } + /** + * Validate and save API response for any endpoint defined in the endpoint registry + * @param endpointName - The endpoint name from the registry (e.g., 'profile-metadata-get') + * @returns The captured network request or null if not found + */ + async validateEndpointResponse(endpointName) { + const schema = getEndpointSchema(endpointName); + const request = this.getLatestCaptureMatching(schema.method, schema.url); + if (request?.responseBody) { + // Access the shared step counter from the stepScreenshoter fixture + const stepCounterObj = globalThis.__stepCounter; + if (stepCounterObj) { + const stepNumber = stepCounterObj.increment(); + const currentStepName = stepCounterObj.getCurrentStepName(); + // Create consistent filename with step number and step name (like screenshots) + const stepNameForFile = currentStepName + ? currentStepName.toLowerCase().replace(/[^a-z0-9]/g, '-') + : endpointName.replace(/[^a-z0-9]/gi, '-'); + const fileName = `step-${stepNumber.toString().padStart(2, '0')}-${stepNameForFile}-response.json`; + await this.saveApiResponse(request.responseBody, request.url, schema.method, fileName, globalThis.testInfo); + } + } + return request; + } + /** + * Save network capture for producer/consumer test patterns + * @param endpointName - The endpoint to save + * @param testName - Name of the test (used for file naming) + * @returns The saved network capture or null + */ + async saveForDependentTests(endpointName, testName) { + const schema = getEndpointSchema(endpointName); + const capture = this.getLatestCaptureMatching(schema.method, schema.url); + if (capture) { + // Create step-based filename for better organization + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const stepName = testName.toLowerCase().replace(/[^a-z0-9]/g, '-'); + const fileName = `step-api-${stepName}-${endpointName.replace(/[^a-z0-9]/gi, '-')}-${timestamp}.json`; + console.log(`āœ… Saved ${endpointName} response for dependent tests`); + // Use Playwright's automatic attachment instead of file system + const { testInfo } = globalThis; + if (testInfo && typeof testInfo.attach === 'function') { + await testInfo.attach(fileName, { + body: JSON.stringify(capture, null, 2), + contentType: 'application/json', + }); + } + return capture; + } + return null; + } + /** + * Load producer test data for consumer tests + * @param testName - Name of the producer test (used for file naming) + * @returns The loaded network capture or null + */ + loadFromProducerTest(testName) { + const filePath = path.join(process.cwd(), 'log', 'test-data-pipeline', `${testName}-response.json`); + if (fs.existsSync(filePath)) { + const fileContent = fs.readFileSync(filePath, 'utf-8'); + const capture = JSON.parse(fileContent); + console.log(`āœ… Loaded ${testName} response from producer test`); + return capture; + } + throw new Error(`Producer test data not found at: ${filePath}. Please run ${testName} test first.`); + } + /** + * Validate data consistency between producer and consumer responses + * @param producerCapture - The producer test network capture + * @param consumerCapture - The consumer test network capture + * @param fieldsToValidate - Array of field paths to validate (e.g., ['fullName', 'patient.birthday']) + * @param requiredFields - Array of fields that must exist and match (defaults to common required fields) + */ + validateDataConsistency(producerCapture, consumerCapture, fieldsToValidate, requiredFields = ['fullName']) { + // Use provided fields or fall back to a basic set for backward compatibility + const defaultFields = ['fullName', 'patient.fullName', 'patient.birthday', 'email']; + const fieldsToCheck = fieldsToValidate || defaultFields; + const producerData = producerCapture.responseBody; + const consumerData = consumerCapture.responseBody; + if (!producerData || !consumerData) { + throw new Error('Missing response data for consistency validation'); + } + console.log('šŸ” Validating data consistency:'); + // Only log full data in development mode + if (process.env.VERBOSE_VALIDATION) { + console.log('Producer:', JSON.stringify(producerData, null, 2)); + console.log('Consumer:', JSON.stringify(consumerData, null, 2)); + } + else { + console.log('Producer fullName:', producerData.fullName); + console.log('Consumer fullName:', consumerData.fullName); + } + // Validate each specified field + for (const fieldPath of fieldsToCheck) { + const producerValue = this.getNestedValue(producerData, fieldPath); + const consumerValue = this.getNestedValue(consumerData, fieldPath); + // Check if this field is marked as required + const isRequired = requiredFields.includes(fieldPath); + if (isRequired) { + if (producerValue === undefined || producerValue === null) { + throw new Error(`Required field ${fieldPath} is missing in producer data`); + } + if (consumerValue === undefined || consumerValue === null) { + throw new Error(`Required field ${fieldPath} is missing in consumer data`); + } + } + // For optional fields: only validate if the field exists in producer data + // If it exists in producer, it must also exist in consumer with same value + if (producerValue !== undefined && producerValue !== null) { + // Handle array comparison + if (Array.isArray(producerValue) && Array.isArray(consumerValue)) { + if (JSON.stringify(producerValue) !== JSON.stringify(consumerValue)) { + throw new Error(`${fieldPath} mismatch - Expected: ${JSON.stringify(producerValue)}, Got: ${JSON.stringify(consumerValue)}`); + } + } + else if (producerValue !== consumerValue) { + throw new Error(`${fieldPath} mismatch - Expected: ${producerValue}, Got: ${consumerValue}`); + } + } + // If producer value doesn't exist, consumer doesn't need to have it either (optional field) + } + console.log('āœ… Data consistency validated: consumer data reflects producer changes'); + } + /** + * Helper method to get nested object values using dot notation + * @param obj - The object to search + * @param path - The dot-notation path (e.g., 'patient.birthday') + * @returns The value at the path or undefined + */ + getNestedValue(obj, path) { + return path.split('.').reduce((current, key) => current?.[key], obj); + } + /** + * Validate producer-consumer data consistency for profile endpoints + * @param producerEndpointName - The PUT endpoint name (e.g., 'profile-metadata-put') + * @param consumerEndpointName - The GET endpoint name (e.g., 'profile-metadata-get') + * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) + * @throws Error if validation fails + */ + async validateProducerConsumerData(producerEndpointName, consumerEndpointName, fieldsToValidate) { + const producerSchema = getEndpointSchema(producerEndpointName); + const consumerSchema = getEndpointSchema(consumerEndpointName); + // Use provided fields, or consumer endpoint validation fields, or producer endpoint validation fields + const validationFields = fieldsToValidate || + consumerSchema.validationFields || + producerSchema.validationFields || ['fullName', 'email']; + // Use consumer endpoint required fields, or producer endpoint required fields, or default + const requiredFields = consumerSchema.requiredFields || + producerSchema.requiredFields || ['fullName']; + const producerCapture = this.getLatestCaptureMatching(producerSchema.method, producerSchema.url); + const consumerCapture = this.getLatestCaptureMatching(consumerSchema.method, consumerSchema.url); + if (!producerCapture) { + throw new Error(`No ${producerEndpointName} capture found for producer validation`); + } + if (!consumerCapture) { + throw new Error(`No ${consumerEndpointName} capture found for consumer validation`); + } + this.validateDataConsistency(producerCapture, consumerCapture, validationFields, requiredFields); + } + /** + * Private method to validate endpoint response without generating JSON file + * @param endpointName - The endpoint name from the registry + * @returns The captured network request or null if not found + */ + validateEndpointResponseSilent(endpointName) { + const schema = getEndpointSchema(endpointName); + const request = this.getLatestCaptureMatching(schema.method, schema.url); + return request; + } + /** + * Complete validation workflow for a user viewing profile data + * Validates both API schema and data consistency in one call + * @param consumerEndpointName - The GET endpoint name + * @param producerCapture - The stored PUT capture from the producer + * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) + */ + async compareEndpointResponse(consumerEndpointName, producerCapture, fieldsToValidate) { + // Get the endpoint schema to determine validation fields + const consumerSchema = getEndpointSchema(consumerEndpointName); + // Use provided fields, or endpoint-specific fields, or fall back to basic fields + const validationFields = fieldsToValidate || + consumerSchema.validationFields || ['fullName', 'patient.fullName', 'email']; + // Use endpoint-specific required fields, or default to fullName for backward compatibility + const requiredFields = consumerSchema.requiredFields || ['fullName']; + // Validate GET response schema without generating JSON file + const consumerCapture = this.validateEndpointResponseSilent(consumerEndpointName); + if (!consumerCapture) { + throw new Error(`No compare endpoint found`); + } + if (!producerCapture) { + throw new Error('No base endpoint found'); + } + // Generate comparison JSON file similar to validateEndpointResponse + const stepCounterObj = globalThis.__stepCounter; + if (stepCounterObj) { + // Increment for JSON file naming (this is correct behavior) + const stepNumber = stepCounterObj.increment(); + const currentStepName = stepCounterObj.getCurrentStepName(); + // Create comparison data object + const comparisonData = { + _comparison: { + description: `Data consistency comparison for ${consumerEndpointName}`, + timestamp: new Date().toISOString(), + fieldsValidated: validationFields, + requiredFields, + }, + original: { + url: producerCapture.url, + method: producerCapture.method, + timestamp: producerCapture.timestamp, + responseBody: producerCapture.responseBody, + }, + new: { + url: consumerCapture.url, + method: consumerCapture.method, + timestamp: consumerCapture.timestamp, + responseBody: consumerCapture.responseBody, + }, + }; + // Create consistent filename with step number and step name (like screenshots) + const stepNameForFile = currentStepName + ? currentStepName.toLowerCase().replace(/[^a-z0-9]/g, '-') + : consumerEndpointName.replace(/[^a-z0-9]/gi, '-'); + const fileName = `step-${stepNumber.toString().padStart(2, '0')}-${stepNameForFile}-comparison.json`; + // Save the comparison data using the unified approach + const { testInfo } = globalThis; + await this.saveApiResponse(comparisonData, consumerCapture.url, consumerCapture.method, fileName, testInfo); + } + // Validate data consistency using the determined validation fields and required fields + this.validateDataConsistency(producerCapture, consumerCapture, validationFields, requiredFields); + } +} +export function createNetworkHelper(page) { + return new NetworkHelper(page); +} diff --git a/dist/tests/fixtures/patient-helpers.d.ts b/dist/tests/fixtures/patient-helpers.d.ts new file mode 100644 index 0000000..03cb4d8 --- /dev/null +++ b/dist/tests/fixtures/patient-helpers.d.ts @@ -0,0 +1,18 @@ +import { test as base } from '@fixtures/base'; +import PatientNav from '@pom/patient/PatientNavigation'; +import type { Page } from '@playwright/test'; +/** + * Initialize patient navigation helpers after login + */ +declare function setupPatientSession(page: Page): Promise; +/** + * New scalable navigation function using state machine approach + */ +declare function navigateTo(targetPage: keyof PatientNav['pages'], page: Page): Promise; +declare const test: typeof base & { + patient: { + navigateTo: typeof navigateTo; + setup: typeof setupPatientSession; + }; +}; +export { test }; diff --git a/dist/tests/fixtures/patient-helpers.js b/dist/tests/fixtures/patient-helpers.js new file mode 100644 index 0000000..9e06284 --- /dev/null +++ b/dist/tests/fixtures/patient-helpers.js @@ -0,0 +1,477 @@ +import { test as base } from '@fixtures/base'; +import PatientNav from '@pom/patient/PatientNavigation'; +import env from '../../utilities/env'; +/** + * Initialize patient navigation helpers after login + */ +async function setupPatientSession(page) { + // Wait for patient navigation to be available + const nav = new PatientNav(page); + await Promise.all([ + nav.pages.ViewData.link.waitFor({ state: 'visible' }), + nav.pages.Profile.link.waitFor({ state: 'visible' }), + ]); + return nav; +} +/** + * Close any open modal dialogs that might block navigation + */ +async function closeOpenDialogs(page) { + try { + if (page.isClosed()) + return; + // Simple and fast: just press Escape twice to close any modals + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); + } + catch (error) { + // Ignore errors in dialog closing - they're not critical + } +} +/** + * Check if we're in a context where patient navigation is supported + */ +async function isInPatientContext(nav, page) { + try { + // Check if any patient navigation elements are visible + const patientElements = [nav.pages.ViewData.link, nav.pages.Profile.link, nav.pages.Share.link]; + for (const element of patientElements) { + if (await element.isVisible({ timeout: 1000 })) { + return true; + } + } + return false; + } + catch { + return false; + } +} +/** + * Get current page state by checking URL and visible elements + */ +async function getCurrentPageState(nav, page) { + const url = page.url(); + // Check each page in order of specificity + for (const [pageName, pageConfig] of Object.entries(nav.pages)) { + try { + if (pageConfig.verifyURL && url.includes(pageConfig.verifyURL)) { + if (pageConfig.verifyElement && + (await pageConfig.verifyElement.isVisible({ timeout: 1000 }))) { + return pageName; + } + } + } + catch { + // Continue checking other pages + } + } + return 'unknown'; +} +/** + * Navigation strategies for different page types + */ +const navigationStrategies = { + // Basic page navigation + default: [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'check-patient-context', + condition: async (state) => !(await isInPatientContext(state.nav, state.page)), + action: async (state) => { + console.log('Not in patient context, navigating to /data URL to reset'); + // Navigate to /data endpoint specifically, not just base URL + await state.page.goto(`${env.BASE_URL}/data`); + await state.page.waitForLoadState('domcontentloaded'); + // Wait for patient navigation to be available + await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); + console.log('Successfully reset to patient context via /data URL'); + }, + }, + { + name: 'wait-for-loading', + action: async (state) => { + const loading = state.page.getByText('Loading...', { exact: true }); + try { + await loading.waitFor({ state: 'hidden', timeout: 3000 }); + } + catch { + // Loading might not be visible + } + }, + }, + { + name: 'navigate-click', + action: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + await pageConfig.link.click({ timeout: 5000 }); + }, + }, + { + name: 'verify-navigation', + verify: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + if (pageConfig.verifyElement) { + try { + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + return true; + } + catch { + return false; + } + } + return true; + }, + }, + ], + // Profile page - handle account settings conflict + Profile: [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'check-patient-context', + condition: async (state) => !(await isInPatientContext(state.nav, state.page)), + action: async (state) => { + console.log('Not in patient context, navigating to /data URL to reset'); + // Navigate to /data endpoint specifically, not just base URL + await state.page.goto(`${env.BASE_URL}/data`); + await state.page.waitForLoadState('domcontentloaded'); + // Wait for patient navigation to be available + await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); + console.log('Successfully reset to patient context via /data URL'); + }, + }, + { + name: 'handle-account-settings-conflict', + condition: async (state) => state.page.url().includes('/profile') && + (await state.page + .getByRole('heading', { name: /account/i }) + .or(state.page.getByText('Account Settings')) + .or(state.page.getByText('Account')) + .or(state.page.locator('.profile-subnav-title').getByText('Account')) + .isVisible() + .catch(() => false)), + action: async (state) => { + console.log('On account settings page, redirecting to base URL first'); + await state.page.goto(env.BASE_URL); + await state.page.waitForTimeout(500); + }, + }, + { + name: 'navigate-click', + action: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + await pageConfig.link.click({ timeout: 5000 }); + }, + }, + { + name: 'verify-navigation', + verify: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + if (pageConfig.verifyElement) { + try { + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + return true; + } + catch { + return false; + } + } + return true; + }, + }, + ], + // Modal dialogs + modal: [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'navigate-click', + action: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + await pageConfig.link.click({ timeout: 5000 }); + }, + }, + { + name: 'wait-for-modal', + action: async (state) => { + await state.page.waitForTimeout(500); + }, + }, + ], + // Data pages that need ViewData prerequisite + 'data-page': [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'ensure-data-view', + condition: async (state) => !state.page.url().includes('/data/'), + action: async (state) => { + await state.nav.pages.ViewData.link.click(); + await state.nav.pages.ViewData.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + }, + }, + { + name: 'navigate-click', + action: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + await pageConfig.link.click({ timeout: 5000 }); + }, + }, + { + name: 'verify-navigation', + verify: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + if (pageConfig.verifyElement) { + try { + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + return true; + } + catch { + return false; + } + } + return true; + }, + }, + ], + // ShareData requires Share main page to be accessible first + ShareData: [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'check-patient-context', + condition: async (state) => !(await isInPatientContext(state.nav, state.page)), + action: async (state) => { + console.log('Not in patient context, navigating to /data URL to reset'); + await state.page.goto(`${env.BASE_URL}/data`); + await state.page.waitForLoadState('domcontentloaded'); + await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); + console.log('Successfully reset to patient context via /data URL'); + }, + }, + { + name: 'ensure-share-dependency', + action: async (state) => { + // First ensure Share main page is accessible + try { + await state.nav.pages.Share.link.waitFor({ state: 'visible', timeout: 3000 }); + console.log('Share dependency met - Share button is accessible'); + } + catch { + console.log('Share dependency not met - performing URL reset to /data'); + await state.page.goto(`${env.BASE_URL}/data`); + await state.page.waitForLoadState('domcontentloaded'); + await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); + console.log('URL reset completed, Share dependency should now be available'); + } + }, + }, + { + name: 'navigate-to-share-first', + action: async (state) => { + // Navigate to Share main page first to establish context + try { + await state.nav.pages.Share.link.click({ timeout: 3000 }); + await state.nav.pages.Share.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + console.log('Successfully navigated to Share main page'); + } + catch { + console.log('Could not reach Share main page, staying in current state'); + } + }, + }, + { + name: 'navigate-to-sharedata', + action: async (state) => { + // Now try to navigate to ShareData sub-page + try { + await state.nav.pages.ShareData.link.click({ timeout: 5000 }); + console.log('Successfully clicked ShareData button'); + } + catch { + console.log('ShareData button not available - this is expected and OK'); + } + }, + }, + { + name: 'verify-navigation', + verify: async (state) => { + // Try to verify ShareData first, fall back to Share if not available + try { + await state.nav.pages.ShareData.verifyElement.waitFor({ + state: 'visible', + timeout: 3000, + }); + console.log('āœ… ShareData page verified'); + return true; + } + catch { + try { + await state.nav.pages.Share.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + console.log('āœ… Share main page verified (ShareData not available - this is OK)'); + return true; + } + catch { + console.log('Neither ShareData nor Share page could be verified'); + return false; + } + } + }, + }, + ], +}; +/** + * Page type mappings to determine which strategy to use + */ +const pageStrategies = { + ViewData: 'default', + Basics: 'data-page', + Daily: 'data-page', + BGLog: 'data-page', + Trends: 'data-page', + Devices: 'data-page', + Profile: 'Profile', + ProfileEdit: 'default', // TODO: Add prerequisite logic + Share: 'default', + ShareData: 'ShareData', // Uses dependency-aware strategy + UploadData: 'default', + ChartDateRange: 'modal', + ChartDate: 'modal', + Print: 'modal', +}; +/** + * Execute navigation strategy + */ +async function executeNavigationStrategy(state) { + const strategyName = pageStrategies[state.targetPage] || 'default'; + const strategy = navigationStrategies[strategyName]; + console.log(`Executing ${strategyName} strategy for ${state.targetPage}`); + for (const step of strategy) { + try { + // Check condition if present + if (step.condition && !(await step.condition(state))) { + console.log(`Skipping step ${step.name} - condition not met`); + continue; + } + console.log(`Executing step: ${step.name}`); + // Execute action if present + if (step.action) { + await step.action(state); + } + // Verify if present + if (step.verify && !(await step.verify(state))) { + console.log(`Step ${step.name} verification failed`); + return false; + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`Step ${step.name} failed:`, errorMessage); + return false; + } + } + return true; +} +/** + * New scalable navigation function using state machine approach + */ +async function navigateTo(targetPage, page) { + if (page.isClosed()) { + console.log(`Page is closed, cannot navigate to ${targetPage}`); + return; + } + const nav = new PatientNav(page); + const currentPage = await getCurrentPageState(nav, page); + const state = { + currentPage, + targetPage, + nav, + page, + }; + console.log(`Navigating from ${currentPage} to ${targetPage}`); + // Execute primary navigation strategy + const success = await executeNavigationStrategy(state); + if (!success) { + console.log(`Primary navigation failed, trying fallback strategies`); + // Fallback strategy - go to base URL and try again + if (targetPage === 'Profile') { + try { + console.log('Profile fallback: going to base URL and trying again'); + await page.goto(env.BASE_URL); + await page.waitForTimeout(500); + await nav.pages[targetPage].link.click({ timeout: 3000 }); + console.log(`Successfully navigated to ${targetPage} via fallback`); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`Profile fallback failed: ${errorMessage}`); + throw error; + } + } + else if (nav.pages[targetPage].verifyURL) { + // Generic URL fallback for pages with backup URLs + try { + let fallbackURL = env.BASE_URL; + // For sub-pages that might not be available, fall back to the main page + if (targetPage === 'ShareData') { + fallbackURL = `${env.BASE_URL}/share`; // Fall back to main Share page + } + else if (targetPage === 'ProfileEdit') { + fallbackURL = `${env.BASE_URL}/profile`; // Fall back to main Profile page + } + else if (['Basics', 'Daily', 'BGLog', 'Trends', 'Devices'].includes(targetPage)) { + fallbackURL = `${env.BASE_URL}/data`; // Fall back to main ViewData page + } + else if (nav.pages[targetPage].verifyURL) { + fallbackURL = `${env.BASE_URL}/${nav.pages[targetPage].verifyURL}`; + } + await page.goto(fallbackURL); + console.log(`Used backup URL for ${targetPage}: ${fallbackURL}`); + // For sub-pages that fall back to main pages, verify the main page elements + let { verifyElement } = nav.pages[targetPage]; + if (targetPage === 'ShareData') { + verifyElement = nav.pages.Share.verifyElement; // Verify main Share page instead + } + else if (targetPage === 'ProfileEdit') { + verifyElement = nav.pages.Profile.verifyElement; // Verify main Profile page instead + } + else if (['Basics', 'Daily', 'BGLog', 'Trends', 'Devices'].includes(targetPage)) { + verifyElement = nav.pages.ViewData.verifyElement; // Verify main ViewData page instead + } + // Wait for the fallback page to actually load and verify we're there + if (verifyElement) { + await verifyElement.waitFor({ + state: 'visible', + timeout: 10000, + }); + console.log(`āœ… Backup URL navigation to ${targetPage} verified successfully (using fallback verification)`); + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`Backup URL failed: ${errorMessage}`); + throw error; + } + } + else { + throw new Error(`Navigation to ${targetPage} failed and no fallback available`); + } + } +} +const test = base; +test.patient = { + navigateTo, + setup: setupPatientSession, +}; +export { test }; diff --git a/dist/tests/fixtures/test-tags.d.ts b/dist/tests/fixtures/test-tags.d.ts new file mode 100644 index 0000000..8b9da8a --- /dev/null +++ b/dist/tests/fixtures/test-tags.d.ts @@ -0,0 +1,60 @@ +/** + * Test Tags Fixture + * + * Simple tag definitions for test organization and Xray integration. + */ +export declare const TEST_TAGS: { + /** + * Generate a Jira-related tag for linking tests to Jira tickets. + * Usage: TEST_TAGS.RELATED('JIRA-1234') => '@jira(JIRA-1234)' + */ + RELATED: (jiraId: string) => string; + BACK_SHORELINE: string; + BACK_CLINIC: string; + BACK_HIGHWATER: string; + BACK_HYDROPHONE: string; + BACK_PLATFORM: string; + BACK_SEAGULL: string; + BACK_TIDEWHISPERER: string; + BACK_MESSAGEAPI: string; + BACK_JELLYFISH: string; + BACK_GATEKEEPER: string; + BACK_EXPORT: string; + BACK_KEYCLOAK: string; + PATIENT: string; + CLINICIAN: string; + CUSTODIAL: string; + SHARED_MEMBER: string; + PERSONAL: string; + CLAIMED: string; + API: string; + UI: string; + SMOKE: string; + REGRESSION: string; + CRITICAL: string; + HIGH: string; + MEDIUM: string; + LOW: string; + API_PROFILE: string; + API_USER: string; +}; +export declare const TAG_CATEGORIES: { + USER_TYPES: string[]; + TEST_TYPES: string[]; + PRIORITIES: string[]; +}; +/** + * Validates that tags include at least one from each required category + * @param tags Array of tags to validate + * @returns Object with validation results + */ +export declare function validateRequiredTags(tags: string[]): { + isValid: boolean; + missing: string[]; + message: string; +}; +/** + * Helper function to create tags with validation + * Throws error if required tags are missing + */ +export declare function createValidatedTags(tags: string[]): string[]; diff --git a/dist/tests/fixtures/test-tags.js b/dist/tests/fixtures/test-tags.js new file mode 100644 index 0000000..26b2aa7 --- /dev/null +++ b/dist/tests/fixtures/test-tags.js @@ -0,0 +1,93 @@ +/** + * Test Tags Fixture + * + * Simple tag definitions for test organization and Xray integration. + */ +export const TEST_TAGS = { + /** + * Generate a Jira-related tag for linking tests to Jira tickets. + * Usage: TEST_TAGS.RELATED('JIRA-1234') => '@jira(JIRA-1234)' + */ + RELATED: (jiraId) => { + // Accepts formats like ABC-1234 or JIRA-1234 + const jiraPattern = /^[A-Z][A-Z0-9]+-\d+$/; + if (!jiraPattern.test(jiraId)) { + throw new Error(`Invalid Jira ID: ${jiraId}. Must match pattern ABC-1234.`); + } + return `@jira(${jiraId})`; + }, + // Backend Services + BACK_SHORELINE: '@back-shoreline', + BACK_CLINIC: '@back-clinic', + BACK_HIGHWATER: '@back-highwater', + BACK_HYDROPHONE: '@back-hydrophone', + BACK_PLATFORM: '@back-platform', + BACK_SEAGULL: '@back-seagull', + BACK_TIDEWHISPERER: '@back-tidewhisperer', + BACK_MESSAGEAPI: '@back-messageapi', + BACK_JELLYFISH: '@back-jellyfish', + BACK_GATEKEEPER: '@back-gatekeeper', + BACK_EXPORT: '@back-export', + BACK_KEYCLOAK: '@back-keycloak', + // User Types + PATIENT: '@patient', + CLINICIAN: '@clinician', + // User-Subtypes + CUSTODIAL: '@custodial', + SHARED_MEMBER: '@shared_member', + PERSONAL: '@personal', + CLAIMED: '@claimed', + // Test Types + API: '@api', + UI: '@ui', + SMOKE: '@smoke', + REGRESSION: '@regression', + // Priority + CRITICAL: '@critical', + HIGH: '@high', + MEDIUM: '@medium', + LOW: '@low', + // Endpoint API Testing + API_PROFILE: '@api_profile', + API_USER: '@api_user', +}; +// Tag Categories for Validation +export const TAG_CATEGORIES = { + USER_TYPES: [TEST_TAGS.PATIENT, TEST_TAGS.CLINICIAN], + TEST_TYPES: [TEST_TAGS.API, TEST_TAGS.UI, TEST_TAGS.SMOKE, TEST_TAGS.REGRESSION], + PRIORITIES: [TEST_TAGS.CRITICAL, TEST_TAGS.HIGH, TEST_TAGS.MEDIUM, TEST_TAGS.LOW], +}; +/** + * Validates that tags include at least one from each required category + * @param tags Array of tags to validate + * @returns Object with validation results + */ +export function validateRequiredTags(tags) { + const hasUserType = tags.some(tag => TAG_CATEGORIES.USER_TYPES.includes(tag)); + const hasTestType = tags.some(tag => TAG_CATEGORIES.TEST_TYPES.includes(tag)); + const hasPriority = tags.some(tag => TAG_CATEGORIES.PRIORITIES.includes(tag)); + const isValid = hasUserType && hasTestType && hasPriority; + const missing = []; + if (!hasUserType) + missing.push('User Type'); + if (!hasTestType) + missing.push('Test Type'); + if (!hasPriority) + missing.push('Priority'); + return { + isValid, + missing, + message: isValid ? 'All required tags present' : `Missing required tags: ${missing.join(', ')}`, + }; +} +/** + * Helper function to create tags with validation + * Throws error if required tags are missing + */ +export function createValidatedTags(tags) { + const validation = validateRequiredTags(tags); + if (!validation.isValid) { + throw new Error(`Test tags validation failed: ${validation.message}`); + } + return tags; +} diff --git a/dist/tests/global-setup.d.ts b/dist/tests/global-setup.d.ts new file mode 100644 index 0000000..b9988ec --- /dev/null +++ b/dist/tests/global-setup.d.ts @@ -0,0 +1,2 @@ +import { FullConfig } from '@playwright/test'; +export default function globalSetup(_config: FullConfig): Promise; diff --git a/dist/tests/global-setup.js b/dist/tests/global-setup.js new file mode 100644 index 0000000..4cd1e80 --- /dev/null +++ b/dist/tests/global-setup.js @@ -0,0 +1,41 @@ +import { chromium } from '@playwright/test'; +import LoginPage from '@pom/LoginPage'; +import fs from 'node:fs'; +import path from 'node:path'; +import env from '../utilities/env'; +async function loginUserType(role) { + const browser = await chromium.launch(); + const context = await browser.newContext({ + baseURL: process.env.BASE_URL, + }); + const page = await context.newPage(); + await page.goto(env.BASE_URL); + const loginPage = new LoginPage(page); + if (role === 'personal') { + await loginPage.login(env.PERSONAL_USERNAME, env.PERSONAL_PASSWORD); + await page.waitForURL('**/data'); + } + else if (role === 'claimed') { + await loginPage.login(env.CLAIMED_USERNAME, env.CLAIMED_PASSWORD); + await page.waitForURL('**/data'); + } + else if (role === 'shared') { + await loginPage.login(env.SHARED_USERNAME, env.SHARED_PASSWORD); + await page.waitForURL('**/data'); + } + else { + await loginPage.login(env.CLINICIAN_USERNAME, env.CLINICIAN_PASSWORD); + await page.waitForURL('**/workspaces'); + } + const authDir = path.resolve(process.cwd(), 'tests', '.auth'); + await fs.promises.mkdir(authDir, { recursive: true }); + const filePath = path.join(authDir, `${role}.json`); + await context.storageState({ path: filePath }); + await browser.close(); +} +export default async function globalSetup(_config) { + await loginUserType('personal'); + await loginUserType('claimed'); + await loginUserType('shared'); + await loginUserType('clinician'); +} diff --git a/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.d.ts b/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.js b/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.js new file mode 100644 index 0000000..6027330 --- /dev/null +++ b/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.js @@ -0,0 +1,73 @@ +import { test } from '../../fixtures/patient-helpers'; +import { createNetworkHelper } from '../../fixtures/network-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; +test.describe('Personal Accounts allow access and modification of profile details', () => { + // API Test cases require this to capture network activity + let api; + test('should allow navigation to profile details and edit profile fields', { + tag: createValidatedTags([ + TEST_TAGS.PATIENT, // User Type (required) + TEST_TAGS.PERSONAL, // User Subtype (required) + TEST_TAGS.API, // Test Type (required) + TEST_TAGS.UI, // Test Type (required) + TEST_TAGS.HIGH, // Priority (required) + TEST_TAGS.API_PROFILE, // Feature (optional) + ]), + }, async ({ page }) => { + // Step 1: Log in to personal account and setup network capture + await test.step('Given personal account has been logged in', async () => { + api = createNetworkHelper(page); + await api.startCapture(); + await page.goto('/data'); + await test.patient.setup(page); + // Step 2: Navigate to profile + await test.step('When user navigates to Profile page', async () => { + await test.patient.navigateTo('Profile', page); + }); + // Step 3: Check profile GET response + await test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Step 4: Open Edit Profile + await test.step('When user selects Edit button', async () => { + await test.patient.navigateTo('ProfileEdit', page); + }); + // Initialize ProfilePage for steps 4 and 5 + const profilePage = new ProfilePage(page); + // Step 5: Change profile fields (confirmed user access) + await test.step('When user updates profile fields', async () => { + // Generate completely unique values for this confirmed user test run + const testRunId = Math.floor(Math.random() * 10000); + const updatedName = `Personal Patient Updated ${testRunId}`; + const birthYear = 1985 + (testRunId % 10); + const diagnosisYear = birthYear + 20; + const birthDate = `01/15/${birthYear}`; + const diagnosisDate = `03/10/${diagnosisYear}`; + // Generate random 15-letter string for clinical notes + const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); + // Get current diagnosis index and calculate next one (1-7, wrapping) + const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); + let nextDiagnosisIndex = currentDiagnosisIndex + 1; + if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { + nextDiagnosisIndex = 1; + } + // Update fields using ProfilePage methods + await profilePage.fillFullName(updatedName); + await profilePage.fillBirthDate(birthDate); + await profilePage.fillDiagnosisDate(diagnosisDate); + await profilePage.selectDiagnosisType(nextDiagnosisIndex); + await profilePage.fillClinicalNotes(randomString); + }); + // Step 6: Save profile edit + await test.step('When user saves profile changes', async () => { + await profilePage.saveProfile(); + }); + // Step 7: Check profile PUT response + await test.stepNoScreenshot('Then profile endpoint responds with PUT request consistent with schema', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + }); + await api.stopCapture(); + }); + }); +}); diff --git a/dist/tests/personal/basic-functionality.spec.d.ts b/dist/tests/personal/basic-functionality.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/personal/basic-functionality.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/personal/basic-functionality.spec.js b/dist/tests/personal/basic-functionality.spec.js new file mode 100644 index 0000000..84da7d1 --- /dev/null +++ b/dist/tests/personal/basic-functionality.spec.js @@ -0,0 +1,235 @@ +// @ts-check +import { expect, test } from '@fixtures/base'; +import PatientDataBasicsPage from '@pom/patient/BasicsPage'; +import PatientDataDailyPage from '@pom/patient/DailyPage'; +test.describe('Patient Data Navigation and Visualization', () => { + test.beforeEach(async ({ page }) => { + await test.step('Given user has been logged in', async () => { + const basicsPage = new PatientDataBasicsPage(page); + await basicsPage.goto(); + // await page.getByText("Loading").waitFor({ state: "detached", timeout: 10000 }); + }); + }); + // BG readings dashboard functionality + test('should display daily chart when selecting a date from basics page', async ({ page }) => { + const basicsPage = new PatientDataBasicsPage(page); + const dailyPage = new PatientDataDailyPage(page); + let selectedDateText; + await test.step('When the navigation bar is visible', async () => { + await basicsPage.navigationBar.buttons.viewData.waitFor({ + state: 'visible', + }); + }); + await test.step('When the user clicks on the most recent day', async () => { + const recentDayElement = basicsPage.bgReadingsSection.firstDayOfData; + await recentDayElement.waitFor({ state: 'visible' }); + await recentDayElement.hover(); + selectedDateText = await basicsPage.bgReadingsSection.calendarDayhover.text(); + await basicsPage.bgReadingsSection.calendarDayhover.el.click(); + }); + await test.step('Then the daily chart is visible and correctly rendered', async () => { + const chartContainer = dailyPage.dailyChart.container; + await chartContainer.waitFor({ state: 'visible' }); + if (!selectedDateText) { + throw new Error('Selected date text is null'); + } + // Verify the selected date matches the displayed date + // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); + // Capture chart screenshot for visual regression + await expect(chartContainer).toHaveScreenshot('daily-chart-1.png'); + }); + }); + // Bolus dashboard functionality + test('should display bolus dashboard when selecting a date from basics page', async ({ page, }) => { + const basicsPage = new PatientDataBasicsPage(page); + const dailyPage = new PatientDataDailyPage(page); + let selectedDateText; + await test.step('When the navigation bar is visible', async () => { + await basicsPage.navigationBar.buttons.viewData.waitFor({ + state: 'visible', + }); + }); + await test.step('When the user clicks on the most recent day', async () => { + const recentDayElement = basicsPage.bolusingSection.firstDayOfData; + await recentDayElement.waitFor({ state: 'visible' }); + await recentDayElement.hover(); + selectedDateText = await basicsPage.bolusingSection.calendarDayhover.text(); + await basicsPage.bolusingSection.calendarDayhover.el.click(); + }); + await test.step('Then the daily chart is visible and correctly rendered', async () => { + const chartContainer = dailyPage.dailyChart.container; + await chartContainer.waitFor({ state: 'visible' }); + if (!selectedDateText) { + throw new Error('Selected date text is null'); + } + // Verify the selected date matches the displayed date + // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); + // Capture chart screenshot for visual regression + await expect(chartContainer).toHaveScreenshot('daily-chart-2.png'); + }); + }); + // Infusion Site Changes dashboard functionality + test('should display Infusion site changes dashboard when selecting a date from basics page', async ({ page, }) => { + const basicsPage = new PatientDataBasicsPage(page); + const dailyPage = new PatientDataDailyPage(page); + let selectedDateText; + await test.step('When the infusion site changes dashboard is visible', async () => { + // Verify dashboard title and initial state + // await expect(basicsPage.tubingPrimeSection.title).toBeVisible(); + // await expect(basicsPage.tubingPrimeSection.description).toHaveText( + // "We are using Fill Cannula to visualize your infusion site changes." + // ); + }); + await test.step('When testing Fill Cannula functionality', async () => { + // Verify radio button options + await basicsPage.tubingPrimeSection.settingsOption.fillCannula.waitFor({ + state: 'visible', + timeout: 60000, + }); + await expect(basicsPage.tubingPrimeSection.settingsOption.fillCannula).toBeVisible(); + await expect(basicsPage.tubingPrimeSection.settingsOption.fillTubing).toBeVisible(); + // Select Fill Cannula and verify highlighted days + await basicsPage.tubingPrimeSection.settingsOption.fillCannula.click(); + // // Verify duration indicator is visible + // await expect( + // basicsPage.tubingPrimeSection.durationIndicator + // ).toContainText("4 days"); + // Verify cannula icons are visible and tubing icons are not + await expect(basicsPage.tubingPrimeSection.cannulaIcons).toBeAttached(); + await expect(basicsPage.tubingPrimeSection.tubingIcons).not.toBeAttached(); + // Select a highlighted day + const highlightedDay = basicsPage.tubingPrimeSection.filledDay; + await highlightedDay.hover(); + selectedDateText = await basicsPage.tubingPrimeSection.calendarDayhover.text(); + await basicsPage.tubingPrimeSection.calendarDayhover.el.click(); + }); + await test.step('Then the daily chart shows correct cannula fill date', async () => { + const chartContainer = dailyPage.dailyChart.container; + await chartContainer.waitFor({ state: 'visible' }); + if (!selectedDateText) { + throw new Error('Selected date text is null'); + } + // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); + await expect(chartContainer).toHaveScreenshot('daily-chart-cannula.png'); + }); + // Return to basics page and test Fill Tubing Option + await test.step('When testing Fill Tubing functionality', async () => { + // Navigate back to basics + await test.step('When the navigation bar is visible', async () => { + await basicsPage.navigationBar.buttons.viewData.waitFor({ + state: 'visible', + }); + }); + // await basicsPage.navigationSubMenu.links.basics.click(); + await basicsPage.tubingPrimeSection.settings.waitFor({ + state: 'visible', + }); + // Click settings and select Fill Tubing + await basicsPage.tubingPrimeSection.settings.click(); + await basicsPage.tubingPrimeSection.settingsOption.fillTubing.click(); + // Verify filled tubing day is visible and cannula day is not + await expect(basicsPage.tubingPrimeSection.tubingIcons).toBeAttached(); + await expect(basicsPage.tubingPrimeSection.cannulaIcons).not.toBeAttached(); + // Click on the most recent day with tubing fill + const tubingDay = basicsPage.tubingPrimeSection.filledDay; + await tubingDay.hover(); + selectedDateText = await basicsPage.tubingPrimeSection.calendarDayhover.text(); + await basicsPage.tubingPrimeSection.calendarDayhover.el.click(); + }); + await test.step('Then the daily chart shows correct tubing fill date', async () => { + const chartContainer = dailyPage.dailyChart.container; + await chartContainer.waitFor({ state: 'visible' }); + if (!selectedDateText) { + throw new Error('Selected date text is null'); + } + // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); + await expect(chartContainer).toHaveScreenshot('daily-chart-tubing.png'); + }); + }); + // TODO: Previous test doesn't test values. Should we? :) + // Readings in range functionality + test('The hover over elements in sidebar shows correct values', async ({ page }) => { + // Stats for BGM + const expectedHeadersReadingInRange = [ + { header: 'Readings Below Range', value: 3 }, + { header: 'Readings Below Range', value: 0 }, + { header: 'Readings In Range', value: 71 }, + { header: 'Readings Above Range', value: 24 }, + { header: 'Readings Above Range', value: 2 }, + ]; + const basicsPage = new PatientDataBasicsPage(page); + await test.step('When the navigation bar is visible', async () => { + await basicsPage.navigationBar.buttons.viewData.waitFor({ + state: 'visible', + }); + }); + // Other BGM tooltip functionality + await basicsPage.statsSidebar.toggleTo('BGM'); + for (let i = 0; i < 5; i += 1) { + const bar = basicsPage.statsSidebar.readingsInRange.hoverBar.nth(i); + const barLabel = basicsPage.statsSidebar.readingsInRange.hoverBarLabel.nth(i); + await test.step('When the user hovers over the Avg. Daily Readings In Range chart', async () => { + await bar.hover(); + }); + await test.step('Then the correct header is visible', async () => { + await expect + .soft(basicsPage.statsSidebar.readingsInRange.header) + .toContainText(expectedHeadersReadingInRange[i].header); + }); + await test.step('Then the correct value is visible', async () => { + await expect + .soft(barLabel) + .toContainText(expectedHeadersReadingInRange[i].value.toString()); + }); + } + // Stats for CGM + // Time in range functionality + const expectedHeadersTimeInRange = [ + { header: 'Time Below Range', value: 0.1 }, + { header: 'Time Below Range', value: 1 }, + { header: 'Time In Range', value: 90 }, + { header: 'Time Above Range', value: 9 }, + { header: 'Time Above Range', value: 0.3 }, + ]; + await basicsPage.statsSidebar.toggleTo('CGM'); + for (let i = 0; i < expectedHeadersTimeInRange.length; i += 1) { + const bar = basicsPage.statsSidebar.timeInRange.hoverBar.nth(i); + const barLabel = basicsPage.statsSidebar.timeInRange.hoverBarLabel.nth(i); + await test.step('When the user hovers over the Avg. Daily Time In Range chart', async () => { + await bar.hover(); + }); + await test.step('Then the correct header is visible', async () => { + await expect + .soft(basicsPage.statsSidebar.timeInRange.header) + .toContainText(expectedHeadersTimeInRange[i].header); + }); + await test.step('Then the correct value is visible', async () => { + await expect.soft(barLabel).toContainText(expectedHeadersTimeInRange[i].value.toString()); + }); + } + }); + // Other CGM tooltip functionality + test('other CGM tooltip functionality', async ({ page }) => { + const basicsPage = new PatientDataBasicsPage(page); + await basicsPage.statsSidebar.toggleTo('CGM'); + const expectedHeadersTimeInRange = [ + { header: 'Basal Insulin', value: 14.7, percentage: 44 }, + { header: 'Bolus Insulin', value: 18.8, percentage: 56 }, + ]; + for (let i = 0; i < expectedHeadersTimeInRange.length; i += 1) { + const bar = basicsPage.statsSidebar.totalInsulin.hoverBar.nth(i); + const barLabel = basicsPage.statsSidebar.totalInsulin.hoverBarLabel.nth(i); + await test.step('When the user hovers over the Avg. Daily Total Insulin chart', async () => { + await bar.hover(); + }); + await test.step('Then the correct header is visible', async () => { + await expect + .soft(basicsPage.statsSidebar.timeInRange.header) + .toContainText(expectedHeadersTimeInRange[i].header); + }); + await test.step('Then the correct value is visible', async () => { + await expect.soft(barLabel).toContainText(expectedHeadersTimeInRange[i].value.toString()); + }); + } + }); +}); diff --git a/dist/tests/personal/login.spec.d.ts b/dist/tests/personal/login.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/personal/login.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/personal/login.spec.js b/dist/tests/personal/login.spec.js new file mode 100644 index 0000000..c9ece3c --- /dev/null +++ b/dist/tests/personal/login.spec.js @@ -0,0 +1,61 @@ +// @ts-check +import { expect, test } from '@fixtures/base'; +import LoginPage from 'page-objects/LoginPage'; +import WorkspacesPage from '@pom/clinician/WorkspacesPage'; +import env from '../../utilities/env'; +// make sure we don't have any cookies or origins +test.use({ storageState: { cookies: [], origins: [] } }); +// Possible testcases: https://tidepool.atlassian.net/jira/software/c/projects/WEB/issues/?jql=project%20%3D%20%22WEB%22%20AND%20type%20%3D%20Test%20AND%20textfields%20~%20%22login%22%20ORDER%20BY%20created%20DESC +test.describe('Login into application', () => { + test('should work with valid credentials for clinician with multiple clinics', async ({ page, }) => { + const loginPage = new LoginPage(page); + await test.step('When user is logged into application', async () => { + await loginPage.goto(); + await loginPage.login(env.CLINICIAN_USERNAME, env.CLINICIAN_PASSWORD); + }); + await test.step('Then the user is redirected to workspaces page', async () => { + const workspacesPage = new WorkspacesPage(page); + await page.waitForURL(workspacesPage.url); + await expect(workspacesPage.header).toBeVisible(); + }); + }); + test('should show error message with invalid credentials', async ({ page }) => { + const loginPage = new LoginPage(page); + await test.step('When user attempts to login with invalid credentials', async () => { + await loginPage.goto(); + // Enter email + await page.fill('#username', 'invalid@email.com'); + await page.click('#kc-login'); + }); + await test.step('Then error message should be displayed', async () => { + // Wait for the error message to appear + await expect(page.locator('#input-error-username')).toBeVisible(); + await expect(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); + }); + }); + test('should validate email format', async ({ page }) => { + const loginPage = new LoginPage(page); + await test.step('When user attempts to login with invalid email format', async () => { + await loginPage.goto(); + // Enter invalid email format + await page.fill('#username', 'invalidemail'); + await page.click('#kc-login'); + }); + await test.step('Then email validation error should be displayed', async () => { + // Check for email validation error message + await expect(page.locator('#input-error-username')).toBeVisible(); + await expect(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); + }); + }); + test('should show error message with invalid credentials 1', async ({ page }) => { + const loginPage = new LoginPage(page); + await test.step('When user is logged into application', async () => { + await loginPage.goto(); + await loginPage.login(env.CLINICIAN_USERNAME, `${env.CLINICIAN_PASSWORD}1`); + }); + await test.step('Then error message should be displayed', async () => { + await expect(page.locator('#input-error')).toBeVisible(); + await expect(page.locator('#input-error')).toContainText('Invalid password.'); + }); + }); +}); diff --git a/dist/utilities/annotations.d.ts b/dist/utilities/annotations.d.ts new file mode 100644 index 0000000..915938f --- /dev/null +++ b/dist/utilities/annotations.d.ts @@ -0,0 +1,15 @@ +import { TestInfo } from '@playwright/test'; +/** + * Interface for test annotations used in JIRA integration + */ +interface TestAnnotations { + testKey: string; + testSummary: string; + requirements: string; + testDescription: string; +} +/** + * Add test annotations to the test info for JIRA integration + */ +export default function addTestAnnotations(testInfo: TestInfo, annotations: TestAnnotations): void; +export {}; diff --git a/dist/utilities/annotations.js b/dist/utilities/annotations.js new file mode 100644 index 0000000..faf1f84 --- /dev/null +++ b/dist/utilities/annotations.js @@ -0,0 +1,21 @@ +/** + * Add test annotations to the test info for JIRA integration + */ +export default function addTestAnnotations(testInfo, annotations) { + testInfo.annotations.push({ + type: 'test_key', + description: annotations.testKey, + }); + testInfo.annotations.push({ + type: 'test_summary', + description: annotations.testSummary, + }); + testInfo.annotations.push({ + type: 'requirements', + description: annotations.requirements, + }); + testInfo.annotations.push({ + type: 'test_description', + description: annotations.testDescription, + }); +} diff --git a/dist/utilities/env.d.ts b/dist/utilities/env.d.ts new file mode 100644 index 0000000..637f194 --- /dev/null +++ b/dist/utilities/env.d.ts @@ -0,0 +1,17 @@ +declare const _default: { + BASE_URL: string; + PERSONAL_USERNAME: string; + PERSONAL_PASSWORD: string; + CLAIMED_USERNAME: string; + CLAIMED_PASSWORD: string; + SHARED_USERNAME: string; + SHARED_PASSWORD: string; + CLINICIAN_USERNAME: string; + CLINICIAN_PASSWORD: string; + TARGET_ENV: "qa1" | "qa2" | "qa3" | "qa4" | "qa5" | "production" | "prd" | "int"; + BROWSERSTACK_USERNAME?: string | undefined; + BROWSERSTACK_ACCESS_KEY?: string | undefined; + XRAY_CLIENT_ID?: string | undefined; + XRAY_CLIENT_SECRET?: string | undefined; +}; +export default _default; diff --git a/dist/utilities/env.js b/dist/utilities/env.js new file mode 100644 index 0000000..5c69186 --- /dev/null +++ b/dist/utilities/env.js @@ -0,0 +1,37 @@ +import dotenv from 'dotenv'; +import z from 'zod'; +dotenv.config(); +const envSchema = z.object({ + BROWSERSTACK_USERNAME: z.string().optional(), + BROWSERSTACK_ACCESS_KEY: z.string().optional(), + PERSONAL_USERNAME: z.string(), + PERSONAL_PASSWORD: z.string(), + CLAIMED_USERNAME: z.string(), + CLAIMED_PASSWORD: z.string(), + SHARED_USERNAME: z.string(), + SHARED_PASSWORD: z.string(), + CLINICIAN_USERNAME: z.string(), + CLINICIAN_PASSWORD: z.string(), + TARGET_ENV: z.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production', 'prd', 'int']), + XRAY_CLIENT_ID: z.string().optional(), + XRAY_CLIENT_SECRET: z.string().optional(), +}); +const env = envSchema.safeParse(process.env); +if (!env.success) { + console.error('āŒ Invalid environment variables:\n', env.error.format()); + throw new Error('Invalid environment variables. Check your .env file.'); +} +const URL_MAP = { + qa1: 'https://qa1.development.tidepool.org', + qa2: 'https://qa2.development.tidepool.org', + qa3: 'https://qa3.development.tidepool.org', + qa4: 'https://qa4.development.tidepool.org', + qa5: 'https://qa5.development.tidepool.org', + production: 'https://app.tidepool.org', + prd: 'https://app.tidepool.org', // Alias for production + int: 'https://int.development.tidepool.org', // Integration environment +}; +export default { + ...env.data, + BASE_URL: URL_MAP[env.data.TARGET_ENV], +}; diff --git a/dist/utilities/xray-json-reporter.d.ts b/dist/utilities/xray-json-reporter.d.ts new file mode 100644 index 0000000..2846c31 --- /dev/null +++ b/dist/utilities/xray-json-reporter.d.ts @@ -0,0 +1,93 @@ +import { FullConfig, FullResult, Suite, TestCase, TestResult } from '@playwright/test/reporter'; +interface XrayTestStep { + action: string; + data?: string; + result?: string; + status: 'PASS' | 'FAIL' | 'PENDING'; + actualResult?: string; + evidences?: Array<{ + data: string; + filename: string; + contentType: string; + }>; +} +interface XrayTest { + testKey?: string; + testInfo: { + summary: string; + type: 'Manual' | 'Cucumber' | 'Generic'; + projectKey: string; + labels?: string[]; + }; + status: 'PASS' | 'FAIL' | 'PENDING' | 'EXECUTING'; + comment?: string; + evidences?: Array<{ + data: string; + filename: string; + contentType: string; + }>; + steps?: XrayTestStep[]; + examples?: string[]; +} +interface XrayExecutionResult { + info: { + summary: string; + description: string; + version?: string; + testPlanKey?: string; + testExecutionKey?: string; + startDate: string; + finishDate: string; + testEnvironments?: string[]; + }; + tests: XrayTest[]; +} +/** + * Unified Xray JSON Reporter for Playwright + * Maps rich Playwright test data to Xray's JSON format with step-by-step evidence + */ +declare class XrayJsonReporter { + private styles; + private startTime; + private endTime; + /** + * Authenticates with Xray API using client credentials + */ + authenticateWithXray(): Promise; + /** + * Converts file to base64 string for Xray evidence + */ + private fileToBase64; + /** + * Extracts step information from test annotations + */ + private extractSteps; + /** + * Maps Playwright test result to Xray test format + */ + private mapPlaywrightTestToXray; + /** + * Converts Playwright JSON results to Xray format + */ + convertPlaywrightJsonToXray(playwrightJsonPath: string): Promise; + /** + * Recursively processes test suites + */ + private processSuite; + /** + * Uploads Xray execution result to Xray + */ + uploadToXray(xrayResult: XrayExecutionResult): Promise; + /** + * Main method to process and upload results + */ + processAndUpload(playwrightJsonPath: string): Promise; + /** + * Reporter lifecycle methods for direct Playwright integration + */ + onBegin(_config: FullConfig, suite: Suite): void; + onTestBegin(test: TestCase, _result: TestResult): void; + onTestEnd(test: TestCase, result: TestResult): void; + onEnd(result: FullResult): Promise; +} +export default XrayJsonReporter; diff --git a/dist/utilities/xray-json-reporter.js b/dist/utilities/xray-json-reporter.js new file mode 100644 index 0000000..a6094f1 --- /dev/null +++ b/dist/utilities/xray-json-reporter.js @@ -0,0 +1,263 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import env from './env'; +/** + * Unified Xray JSON Reporter for Playwright + * Maps rich Playwright test data to Xray's JSON format with step-by-step evidence + */ +class XrayJsonReporter { + constructor() { + this.styles = { + success: 'āœ…', + error: 'āŒ', + info: 'ā„¹ļø', + warning: 'ā›”ļø', + upload: 'šŸš€', + test: '🧪', + separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + }; + this.startTime = ''; + this.endTime = ''; + } + /** + * Authenticates with Xray API using client credentials + */ + async authenticateWithXray() { + try { + console.log(`${this.styles.info} Authenticating with Xray...`); + const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: env.XRAY_CLIENT_ID, + client_secret: env.XRAY_CLIENT_SECRET, + }), + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + } + const token = await response.text(); + console.log(`${this.styles.success} Successfully authenticated with Xray`); + return token.replace(/"/g, ''); // Remove quotes from token + } + catch (error) { + console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); + throw error; + } + } + /** + * Converts file to base64 string for Xray evidence + */ + async fileToBase64(filePath) { + try { + const fileBuffer = fs.readFileSync(filePath); + return fileBuffer.toString('base64'); + } + catch (error) { + console.warn(`${this.styles.warning} Could not read file ${filePath}:`, error); + return ''; + } + } + /** + * Extracts step information from test annotations + */ + extractSteps(annotations, attachments) { + const steps = []; + const stepAnnotations = annotations.filter(ann => ann.type.startsWith('Step Duration:')); + for (const stepAnn of stepAnnotations) { + const stepName = stepAnn.type.replace('Step Duration: ', ''); + const duration = stepAnn.description; + // Find associated step attachments + const stepAttachments = attachments.filter(att => att.name.toLowerCase().includes(stepName.toLowerCase().substring(0, 20))); + const step = { + action: stepName, + data: `Duration: ${duration}`, + result: stepName.includes('Then') ? stepName : undefined, + status: 'PASS', // Will be updated based on test result + evidences: [] + }; + // Add evidence for this step + for (const attachment of stepAttachments) { + if (attachment.path && fs.existsSync(attachment.path)) { + step.evidences?.push({ + data: await this.fileToBase64(attachment.path), + filename: path.basename(attachment.path), + contentType: attachment.contentType || 'application/octet-stream' + }); + } + } + steps.push(step); + } + return steps; + } + /** + * Maps Playwright test result to Xray test format + */ + async mapPlaywrightTestToXray(testCase, testResult) { + const tags = testCase.tags || []; + const annotations = testResult.annotations || []; + const attachments = testResult.attachments || []; + // Extract steps from annotations + const steps = await this.extractSteps(annotations, attachments); + // Mark failed steps if test failed + if (testResult.status !== 'passed' && steps.length > 0) { + steps[steps.length - 1].status = 'FAIL'; + steps[steps.length - 1].actualResult = testResult.error?.message || 'Test failed'; + } + // Collect test-level evidence (screenshots, videos) + const testEvidences = []; + for (const attachment of attachments) { + if (attachment.path && fs.existsSync(attachment.path)) { + // Add main test evidence (final screenshots, videos, etc.) + if (attachment.name.includes('screenshot') || attachment.name.includes('video')) { + testEvidences.push({ + data: await this.fileToBase64(attachment.path), + filename: attachment.name, + contentType: attachment.contentType || 'application/octet-stream' + }); + } + } + } + const xrayTest = { + testInfo: { + summary: testCase.title, + type: 'Generic', + projectKey: 'XT', // Could be made configurable + labels: tags + }, + status: testResult.status === 'passed' ? 'PASS' : + testResult.status === 'skipped' ? 'PENDING' : 'FAIL', + comment: testResult.error?.message, + evidences: testEvidences, + steps: steps.length > 0 ? steps : undefined + }; + return xrayTest; + } + /** + * Converts Playwright JSON results to Xray format + */ + async convertPlaywrightJsonToXray(playwrightJsonPath) { + const jsonContent = fs.readFileSync(playwrightJsonPath, 'utf8'); + const playwrightResult = JSON.parse(jsonContent); + const tests = []; + // Process all test suites + for (const suite of playwrightResult.suites || []) { + await this.processSuite(suite, tests); + } + const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; + const targetEnv = process.env.TARGET_ENV || 'qa1'; + const xrayResult = { + info: { + summary: `Playwright Test Execution - ${new Date().toISOString()}`, + description: `Automated test execution for ${targetEnv} environment`, + version: '1.0', + testExecutionKey: testExecKey !== 'none' ? testExecKey : undefined, + startDate: playwrightResult.stats?.startTime || new Date().toISOString(), + finishDate: new Date(new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + + (playwrightResult.stats?.duration || 0)).toISOString(), + testEnvironments: [targetEnv] + }, + tests + }; + return xrayResult; + } + /** + * Recursively processes test suites + */ + async processSuite(suite, tests) { + // Process specs in this suite + for (const spec of suite.specs || []) { + for (const test of spec.tests || []) { + for (const result of test.results || []) { + const xrayTest = await this.mapPlaywrightTestToXray(spec, result); + tests.push(xrayTest); + } + } + } + // Process nested suites + for (const nestedSuite of suite.suites || []) { + await this.processSuite(nestedSuite, tests); + } + } + /** + * Uploads Xray execution result to Xray + */ + async uploadToXray(xrayResult) { + try { + console.log(`${this.styles.info} Uploading test execution to Xray...`); + const token = await this.authenticateWithXray(); + const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(xrayResult), + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + } + const result = await response.json(); + console.log(`${this.styles.success} Successfully uploaded to Xray. Execution Key: ${result.key}`); + } + catch (error) { + console.error(`${this.styles.error} Failed to upload to Xray:`, error); + throw error; + } + } + /** + * Main method to process and upload results + */ + async processAndUpload(playwrightJsonPath) { + if (!(env.XRAY_CLIENT_ID && env.XRAY_CLIENT_SECRET)) { + console.log(`${this.styles.warning} No Xray credentials found, skipping upload to JIRA Xray`); + return; + } + try { + console.log(`${this.styles.info} Processing Playwright results...`); + const xrayResult = await this.convertPlaywrightJsonToXray(playwrightJsonPath); + // Save converted result for debugging + fs.writeFileSync('test-results/xray-execution.json', JSON.stringify(xrayResult, null, 2)); + await this.uploadToXray(xrayResult); + console.log(`${this.styles.upload} Xray upload completed successfully`); + } + catch (error) { + console.error(`${this.styles.error} Failed to process and upload:`, error); + throw error; + } + } + /** + * Reporter lifecycle methods for direct Playwright integration + */ + onBegin(_config, suite) { + this.startTime = new Date().toISOString(); + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); + console.log(`${this.styles.separator}\n`); + } + onTestBegin(test, _result) { + console.log(`${this.styles.test} Starting: ${test.title}`); + } + onTestEnd(test, result) { + const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; + console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); + } + async onEnd(result) { + this.endTime = new Date().toISOString(); + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.info} Test Run Summary:`); + console.log(`Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`); + console.log(`Duration: ${result.duration}ms`); + console.log(`${this.styles.separator}\n`); + // Auto-upload if JSON results are available + const jsonPath = 'test-results/last-run.json'; + if (fs.existsSync(jsonPath)) { + await this.processAndUpload(jsonPath); + } + } +} +export default XrayJsonReporter; diff --git a/dist/utilities/xray-reporter.d.ts b/dist/utilities/xray-reporter.d.ts new file mode 100644 index 0000000..a81cd71 --- /dev/null +++ b/dist/utilities/xray-reporter.d.ts @@ -0,0 +1,44 @@ +import { FullConfig, FullResult, Suite, TestCase, TestResult } from '@playwright/test/reporter'; +/** + * Reporter class for uploading test results to Xray + */ +declare class XRayReporter { + private styles; + constructor(); + /** + * Authenticates with Xray API using client credentials + * @returns {Promise} The authentication token + * @throws {Error} If authentication fails + */ + authenticateWithXray(): Promise; + /** + * Uploads test results to Xray + * @param {string} token - The authentication token + * @param {string} xmlContent - The JUnit XML content to upload + * @returns {Promise} + * @throws {Error} If upload fails + */ + uploadTestResults(token: string, xmlContent: string): Promise; + /** + * Called when test run begins + * @param suite - Test suite object containing all tests + */ + onBegin(_config: FullConfig, suite: Suite): void; + /** + * Called when a test begins + * @param test - Test case object + */ + onTestBegin(test: TestCase, _result: TestResult): void; + /** + * Called when a test ends + * @param {Object} test - Test case object + * @param {Object} result - Test result object containing status and other details + */ + onTestEnd(test: TestCase, result: TestResult): void; + /** + * Called when all tests have finished + * @param result - Full test run result object containing status and duration + */ + onEnd(result: FullResult): Promise; +} +export default XRayReporter; diff --git a/dist/utilities/xray-reporter.js b/dist/utilities/xray-reporter.js new file mode 100644 index 0000000..523584c --- /dev/null +++ b/dist/utilities/xray-reporter.js @@ -0,0 +1,129 @@ +import fs from 'node:fs'; +import env from './env'; +/** + * Reporter class for uploading test results to Xray + */ +class XRayReporter { + constructor() { + this.styles = { + success: 'āœ…', + error: 'āŒ', + info: 'ā„¹ļø', + warning: 'ā›”ļø', + upload: 'šŸš€', + test: '🧪', + separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + }; + } + /** + * Authenticates with Xray API using client credentials + * @returns {Promise} The authentication token + * @throws {Error} If authentication fails + */ + async authenticateWithXray() { + try { + console.log(`${this.styles.info} Authenticating with Xray...`); + const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: env.XRAY_CLIENT_ID, + client_secret: env.XRAY_CLIENT_SECRET, + }), + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}, ${response.body}`); + } + const data = await response.json(); + console.log(`${this.styles.success} Successfully authenticated with Xray`); + return data.token; + } + catch (error) { + console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); + throw error; + } + } + /** + * Uploads test results to Xray + * @param {string} token - The authentication token + * @param {string} xmlContent - The JUnit XML content to upload + * @returns {Promise} + * @throws {Error} If upload fails + */ + async uploadTestResults(token, xmlContent) { + try { + console.log(`${this.styles.info} Uploading test results to Xray...`); + const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution/junit?projectKey=XT&testPlanKey=XT-380', { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + Authorization: `Bearer ${token}`, + }, + body: xmlContent, + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + } + console.log(`${this.styles.success} Successfully uploaded test results to Xray`); + } + catch (error) { + console.error(`${this.styles.error} Failed to upload test results to Xray:`, error); + throw error; + } + } + /** + * Called when test run begins + * @param suite - Test suite object containing all tests + */ + onBegin(_config, suite) { + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); + console.log(`${this.styles.separator}\n`); + } + /** + * Called when a test begins + * @param test - Test case object + */ + onTestBegin(test, _result) { + console.log(`${this.styles.test} Starting: ${test.title}`); + } + /** + * Called when a test ends + * @param {Object} test - Test case object + * @param {Object} result - Test result object containing status and other details + */ + onTestEnd(test, result) { + const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; + console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); + } + /** + * Called when all tests have finished + * @param result - Full test run result object containing status and duration + */ + async onEnd(result) { + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.info} Test Run Summary:`); + console.log(`Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`); + console.log(`Duration: ${result.duration}ms`); + console.log(`${this.styles.separator}\n`); + if (!(env.XRAY_CLIENT_ID || env.XRAY_CLIENT_SECRET)) { + console.log(`${this.styles.warning} No Xray client ID or secret found, skipping upload to JIRA Xray`); + return; + } + try { + console.log(`${this.styles.info} Reading test results file...`); + const testResults = fs.readFileSync('./test-results/test-results.xml', 'utf8'); + const token = await this.authenticateWithXray(); + await this.uploadTestResults(token, testResults); + console.log(`${this.styles.upload} Successfully uploaded test results to Xray`); + } + catch (error) { + console.error(`${this.styles.error} Failed to process test results:`, error); + } + console.log(`${this.styles.separator}\n`); + } +} +export default XRayReporter; diff --git a/docs/XRAY_INTEGRATION.md b/docs/XRAY_INTEGRATION.md new file mode 100644 index 0000000..706224c --- /dev/null +++ b/docs/XRAY_INTEGRATION.md @@ -0,0 +1,166 @@ +# Xray Integration Documentation + +## Overview +This project uses a unified JSON-based Xray integration that captures rich test data from Playwright and uploads it to Xray with step-by-step evidence including screenshots, videos, and test annotations. + +## Architecture + +### 1. **Playwright Configuration** (`playwright.config.ts`) +- **JSON Reporter**: Generates `test-results/last-run.json` with complete test data +- **Xray JSON Reporter**: Custom reporter that automatically uploads to Xray +- **Legacy XML Reporter**: Still available for backward compatibility + +```typescript +reporter: [ + ['html', { open: 'never', outputFolder: 'playwright-report' }], + ['json', { outputFile: 'test-results/last-run.json' }], // New JSON format + ['junit', xrayOptions], // Legacy XML format + ['./utilities/xray-json-reporter.ts'], // Auto-upload to Xray +], +``` + +### 2. **Xray JSON Reporter** (`utilities/xray-json-reporter.ts`) +**Features:** +- Maps Playwright test steps to Xray test steps with individual evidence +- Attaches screenshots per step (e.g., `step-01-given-clinician-has-been-logged-in.png`) +- Includes test tags, annotations, and custom properties +- Embeds video evidence for failed tests +- Supports test execution key parameter for linking to existing test executions + +**Data Mapping:** +- **Test Steps**: Extracts from `Step Duration:` annotations +- **Evidence**: Screenshots, videos, JSON responses per step +- **Status**: Pass/Fail/Pending with detailed failure messages +- **Metadata**: Environment, build info, test tags + +### 3. **CircleCI Integration** (`.circleci/config.yml`) +**Simplified Workflow:** +1. Run tests → Generate `test-results/last-run.json` +2. Build TypeScript utilities +3. Upload to Xray using `node utilities/upload-to-xray.js` + +**Environment Variables:** +- `TEST_EXECUTION_KEY`: Links results to existing Xray test execution +- `XRAY_CLIENT_ID`: Xray API authentication +- `XRAY_CLIENT_SECRET`: Xray API authentication +- `TARGET_ENV`: Test environment (qa1, qa2, etc.) + +## Usage + +### Local Development +```bash +# Run tests and auto-upload to Xray (if credentials available) +npm test + +# Manual upload of existing results +npm run upload-to-xray test-results/last-run.json + +# Build TypeScript utilities +npm run build +``` + +### CI/CD Pipeline +Tests automatically upload to Xray when: +- `XRAY_CLIENT_ID` and `XRAY_CLIENT_SECRET` are available +- `TEST_EXECUTION_KEY` parameter is provided +- JSON results file exists + +### Test Tagging +Use test tags to organize and filter results in Xray: +```typescript +{ + tag: createValidatedTags([ + TEST_TAGS.PATIENT, + TEST_TAGS.API, + TEST_TAGS.HIGH, + TEST_TAGS.API_USER, + ]), +} +``` + +## Xray JSON Format + +### Test Execution Structure +```json +{ + "info": { + "summary": "Playwright Test Execution - 2025-08-22T19:50:15.680Z", + "testExecutionKey": "XT-123", + "testEnvironments": ["qa1"], + "startDate": "2025-08-22T19:50:15.680Z", + "finishDate": "2025-08-22T19:50:56.408Z" + }, + "tests": [...] +} +``` + +### Individual Test Structure +```json +{ + "testInfo": { + "summary": "should allow navigation to account settings", + "type": "Generic", + "projectKey": "XT", + "labels": ["patient", "api", "high"] + }, + "status": "PASS", + "evidences": [ + { + "data": "base64-encoded-screenshot", + "filename": "final-screenshot.png", + "contentType": "image/png" + } + ], + "steps": [ + { + "action": "Given clinician has been logged in", + "data": "Duration: 5193ms", + "status": "PASS", + "evidences": [ + { + "data": "base64-encoded-step-screenshot", + "filename": "step-01-given-clinician-has-been-logged-in.png", + "contentType": "image/png" + } + ] + } + ] +} +``` + +## Benefits Over Legacy XML + +| Feature | XML (Legacy) | JSON (New) | +|---------|-------------|------------| +| Test Steps | āŒ Basic only | āœ… Full step breakdown | +| Screenshots | āŒ Separate API calls | āœ… Embedded per step | +| Videos | āŒ Not supported | āœ… Embedded evidence | +| Custom Properties | āŒ Limited | āœ… Rich metadata | +| Test Tags | āŒ Basic | āœ… Full tag system | +| Debugging Info | āŒ Minimal | āœ… Comprehensive | + +## Migration Notes + +### Current State +- **JSON**: Primary integration with rich evidence +- **XML**: Available for backward compatibility +- **Duplicate Steps**: Removed from CircleCI + +### Future Cleanup +Once fully validated, remove: +- `xrayOptions` configuration in `playwright.config.ts` +- `['junit', xrayOptions]` reporter +- Legacy `utilities/xray-reporter.ts` file + +## Troubleshooting + +### Common Issues +1. **Missing JSON file**: Ensure `json` reporter is enabled in Playwright config +2. **Upload failures**: Check Xray credentials and network connectivity +3. **Step evidence missing**: Verify step naming conventions in test annotations +4. **TypeScript compilation**: Run `npm run build` before upload + +### Debug Information +- Generated JSON saved to `test-results/xray-execution.json` +- Full logs available in CircleCI build output +- Test step timing and evidence captured in annotations \ No newline at end of file diff --git a/endpoint-schema/auth-endpoints.ts b/endpoint-schema/auth-endpoints.ts index b0672a3..3e9ec18 100644 --- a/endpoint-schema/auth-endpoints.ts +++ b/endpoint-schema/auth-endpoints.ts @@ -17,15 +17,10 @@ export const loginSchema: EndpointSchema = { emails: 'object', roles: 'object', }, - validationFields: [ - 'userid', - 'username', - 'emails', - 'roles', - ], + validationFields: ['userid', 'username', 'emails', 'roles'], requiredFields: [ - 'userid', // Auth endpoints require userid instead of fullName - 'username', // Username is also critical for auth + 'userid', // Auth endpoints require userid instead of fullName + 'username', // Username is also critical for auth ], }; @@ -52,11 +47,8 @@ export const refreshTokenSchema: EndpointSchema = { userid: 'string', username: 'string', }, - validationFields: [ - 'userid', - 'username', - ], + validationFields: ['userid', 'username'], requiredFields: [ - 'userid', // Token refresh must return userid + 'userid', // Token refresh must return userid ], }; diff --git a/endpoint-schema/endpoint-registry.ts b/endpoint-schema/endpoint-registry.ts index 6513c5a..e61003b 100644 --- a/endpoint-schema/endpoint-registry.ts +++ b/endpoint-schema/endpoint-registry.ts @@ -13,13 +13,13 @@ import { loginSchema, logoutSchema, refreshTokenSchema } from './auth-endpoints' /** * Centralized endpoint registry for all API validation * This allows network helpers to work with any endpoint by name - * + * * ADDING NEW ENDPOINTS: * 1. Define the endpoint schema in the appropriate *-endpoints.ts file * 2. Include validationFields array for data consistency checking * 3. Add the endpoint to this registry * 4. The validationFields will automatically be used by NetworkHelper methods - * + * * VALIDATION FIELDS: * - Use dot notation for nested fields (e.g., 'patient.fullName') * - Include all fields that should be validated for data consistency diff --git a/endpoint-schema/patient-data-endpoints.ts b/endpoint-schema/patient-data-endpoints.ts index 4024b71..948ed96 100644 --- a/endpoint-schema/patient-data-endpoints.ts +++ b/endpoint-schema/patient-data-endpoints.ts @@ -14,11 +14,7 @@ export const getPatientDataSchema: EndpointSchema = { size: 'number', }, }, - validationFields: [ - 'data', - 'meta.count', - 'meta.size', - ], + validationFields: ['data', 'meta.count', 'meta.size'], }; /** @@ -37,10 +33,7 @@ export const uploadPatientDataSchema: EndpointSchema = { id: 'string', success: 'boolean', }, - validationFields: [ - 'id', - 'success', - ], + validationFields: ['id', 'success'], }; /** @@ -60,10 +53,5 @@ export const getPatientSettingsSchema: EndpointSchema = { }, siteChangeSource: 'string', }, - validationFields: [ - 'bgTarget.low', - 'bgTarget.high', - 'units.bg', - 'siteChangeSource', - ], + validationFields: ['bgTarget.low', 'bgTarget.high', 'units.bg', 'siteChangeSource'], }; diff --git a/package.json b/package.json index afaf8b2..06e1194 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "test:ui": "TEST_TAGS='@ui' node utilities/test-runner.js", "test:patient": "TEST_TAGS='@patient' node utilities/test-runner.js", "test:clinician": "TEST_TAGS='@clinician' node utilities/test-runner.js", + "upload-to-xray": "node utilities/upload-to-xray.js", + "build": "tsc", "format": "prettier --write ." }, "repository": { diff --git a/page-objects/account/AccountSettingsPage.ts b/page-objects/account/AccountSettingsPage.ts index 1384965..e3e9a37 100644 --- a/page-objects/account/AccountSettingsPage.ts +++ b/page-objects/account/AccountSettingsPage.ts @@ -2,8 +2,11 @@ import { Page, Locator } from '@playwright/test'; export class AccountSettingsPage { readonly page: Page; + readonly emailInput: Locator; + readonly saveButton: Locator; + readonly saveConfirm: Locator; constructor(page: Page) { diff --git a/page-objects/patient/PatientNavigation.ts b/page-objects/patient/PatientNavigation.ts index 719bdfa..05d0aa6 100644 --- a/page-objects/patient/PatientNavigation.ts +++ b/page-objects/patient/PatientNavigation.ts @@ -107,7 +107,7 @@ export default class PatientNav { Profile: { name: 'Profile', link: page.getByRole('button', { name: 'Profile Profile' }), - verifyURL: '', + verifyURL: '', verifyElement: page.getByRole('button', { name: 'Edit' }), // Edit button is visible on profile page }, ProfileEdit: { diff --git a/page-objects/patient/ProfilePage.ts b/page-objects/patient/ProfilePage.ts index be8dd7e..6e9413b 100644 --- a/page-objects/patient/ProfilePage.ts +++ b/page-objects/patient/ProfilePage.ts @@ -1,7 +1,5 @@ - import { Locator, Page } from '@playwright/test'; - export class ProfilePage { readonly page: Page; @@ -20,7 +18,6 @@ export class ProfilePage { }; } - // Generic fill method for text fields async fillField(field: keyof typeof this.fieldLocators, value: string): Promise { const locator = this.fieldLocators[field]; @@ -58,21 +55,37 @@ export class ProfilePage { return 1; // Default to 1 if not found } - // For backwards compatibility, keep these as wrappers (optional) - async fillFullName(name: string) { return this.fillField('fullName', name); } - async fillBirthDate(date: string) { return this.fillField('birthDate', date); } - async fillMRN(mrn: string) { return this.fillField('mrn', mrn); } - async fillDiagnosisDate(date: string) { return this.fillField('diagnosisDate', date); } - async fillClinicalNotes(notes: string) { return this.fillField('clinicalNotes', notes); } - async fillEmail(email: string) { return this.fillField('email', email); } + async fillFullName(name: string) { + return this.fillField('fullName', name); + } + + async fillBirthDate(date: string) { + return this.fillField('birthDate', date); + } + + async fillMRN(mrn: string) { + return this.fillField('mrn', mrn); + } + + async fillDiagnosisDate(date: string) { + return this.fillField('diagnosisDate', date); + } + + async fillClinicalNotes(notes: string) { + return this.fillField('clinicalNotes', notes); + } + + async fillEmail(email: string) { + return this.fillField('email', email); + } async saveProfile(): Promise { // Save button locators const saveButtons = [ this.page.getByRole('button', { name: 'Save changes' }), this.page.getByRole('button', { name: 'Save Profile' }), - this.page.getByRole('button', { name: 'Save' }) + this.page.getByRole('button', { name: 'Save' }), ]; // Wait for the PUT request to complete after clicking save @@ -116,5 +129,4 @@ export class ProfilePage { throw new Error('Edit button should not be visible for this user - security violation!'); } } - } diff --git a/playwright.config.ts b/playwright.config.ts index ce3dd73..b43418e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; import path from 'node:path'; import env from './utilities/env'; +// Legacy XML options - can be removed when fully migrated to JSON const xrayOptions = { embedAnnotationsAsProperties: true, textContentAnnotations: ['test_description', 'testrun_comment'], @@ -44,7 +45,9 @@ export default defineConfig({ reporter: [ ['html', { open: 'never', outputFolder: 'playwright-report' }], + ['json', { outputFile: 'test-results/last-run.json' }], ['junit', xrayOptions], + ['./utilities/xray-json-reporter.ts'], ], use: { diff --git a/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts b/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts index c379e7a..ec63a04 100644 --- a/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts +++ b/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts @@ -10,10 +10,9 @@ import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; - test.describe('Claimed Account Settings edit (Full Name only) updates Profile endpoint and visually updates for user, clinic, and shared member', () => { test.setTimeout(120000); // 2 minute timeout for multi-phase test - + let api: ReturnType; let putCapture: any; let newName: string; // Declare at test level scope @@ -33,9 +32,8 @@ test.describe('Claimed Account Settings edit (Full Name only) updates Profile en ]), }, async ({ page }) => { - // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== - + // Step 1: Log in to clinician account and setup network capture await test.step('Given claimed account has been logged in', async () => { api = createNetworkHelper(page); @@ -56,10 +54,10 @@ test.describe('Claimed Account Settings edit (Full Name only) updates Profile en await api.validateEndpointResponse('profile-metadata-get'); }, ); - - //Create new acccount settings page for the following test + + // Create new acccount settings page for the following test const accountSettingsPage = new AccountSettingsPage(page); - + // Step 4: Change the Full Name field to a new value await test.step('When user updates the Full Name field', async () => { newName = `Claimed User Updated ${Math.floor(Math.random() * 10000)}`; // Remove let declaration @@ -78,16 +76,23 @@ test.describe('Claimed Account Settings edit (Full Name only) updates Profile en }); // Step 7: Validate PUT request and save value - await (test as any).stepNoScreenshot('Then PUT request is validated and name is set to new value', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - putCapture = api.getCaptures().find( - (req: any) => req.method === 'PUT' && req.url.includes('/profile') - ); - if (!putCapture) throw new Error('No PUT /profile request captured'); - if (!putCapture.requestBody || !putCapture.requestBody.fullName || putCapture.requestBody.fullName !== newName) { - throw new Error(`PUT request did not set fullName to ${newName}`); - } - }); + await (test as any).stepNoScreenshot( + 'Then PUT request is validated and name is set to new value', + async () => { + await api.validateEndpointResponse('profile-metadata-put'); + putCapture = api + .getCaptures() + .find((req: any) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) throw new Error('No PUT /profile request captured'); + if ( + !putCapture.requestBody || + !putCapture.requestBody.fullName || + putCapture.requestBody.fullName !== newName + ) { + throw new Error(`PUT request did not set fullName to ${newName}`); + } + }, + ); // Step 8: Navigate to Profile page await test.step('When user navigates to Profile page', async () => { @@ -95,34 +100,40 @@ test.describe('Claimed Account Settings edit (Full Name only) updates Profile en }); // Step 9: Confirm GET request matches the saved PUT request - await (test as any).stepNoScreenshot('Then GET request matches the saved PUT request', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - - // Get all captures and find the LATEST GET request (after the PUT) - const allCaptures = api.getCaptures(); - const putIndex = allCaptures.findIndex(req => req === putCapture); - - // Find GET requests that occurred AFTER the PUT request - const laterGetCaptures = allCaptures.slice(putIndex + 1).filter( - (req: any) => req.method === 'GET' && req.url.includes('/profile') - ); - - if (laterGetCaptures.length === 0) { - throw new Error('No GET /profile request captured after the PUT request'); - } - - // Use the most recent GET request - const getCapture = laterGetCaptures[laterGetCaptures.length - 1]; - - if (!getCapture.responseBody || getCapture.responseBody.fullName !== putCapture.requestBody.fullName) { + await (test as any).stepNoScreenshot( + 'Then GET request matches the saved PUT request', + async () => { + await api.validateEndpointResponse('profile-metadata-get'); + + // Get all captures and find the LATEST GET request (after the PUT) + const allCaptures = api.getCaptures(); + const putIndex = allCaptures.findIndex(req => req === putCapture); + + // Find GET requests that occurred AFTER the PUT request + const laterGetCaptures = allCaptures + .slice(putIndex + 1) + .filter((req: any) => req.method === 'GET' && req.url.includes('/profile')); + + if (laterGetCaptures.length === 0) { + throw new Error('No GET /profile request captured after the PUT request'); + } + + // Use the most recent GET request + const getCapture = laterGetCaptures[laterGetCaptures.length - 1]; + + if ( + !getCapture.responseBody || + getCapture.responseBody.fullName !== putCapture.requestBody.fullName + ) { console.log('GET response fullName:', getCapture.responseBody.fullName); console.log('PUT request fullName:', putCapture.requestBody.fullName); console.log('Total captures:', allCaptures.length); console.log('PUT index:', putIndex); console.log('Later GET captures found:', laterGetCaptures.length); - throw new Error('GET response fullName does not match PUT request fullName'); - } - }); + throw new Error('GET response fullName does not match PUT request fullName'); + } + }, + ); // ========== PHASE 2: SHARED USER VIEWS PROFILE ========== diff --git a/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.ts b/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.ts index ef07503..9f09401 100644 --- a/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.ts +++ b/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.ts @@ -1,5 +1,5 @@ -import { test } from '../../fixtures/base' -import { test as patientTest} from '../../fixtures/patient-helpers'; +import { test } from '../../fixtures/base'; +import { test as patientTest } from '../../fixtures/patient-helpers'; import { test as clinicTest } from '../../fixtures/clinic-helpers'; import { test as accountTest } from '../../fixtures/account-helpers'; import { createNetworkHelper } from '../../fixtures/network-helpers'; diff --git a/tests/claimed/API-User/claimed-email-edit.spec.ts b/tests/claimed/API-User/claimed-email-edit.spec.ts index fe931e5..8109075 100644 --- a/tests/claimed/API-User/claimed-email-edit.spec.ts +++ b/tests/claimed/API-User/claimed-email-edit.spec.ts @@ -1,12 +1,10 @@ import { test } from '../../fixtures/base'; import { test as patientTest } from '../../fixtures/patient-helpers'; -import { test as accountTest} from '../../fixtures/account-helpers' +import { test as accountTest } from '../../fixtures/account-helpers'; import { createNetworkHelper } from '../../fixtures/network-helpers'; import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; import { AccountSettingsPage } from '../../../page-objects/account/AccountSettingsPage'; - - test.describe('Clinician Account Settings Access', () => { // API Test cases require this to capture network activity let api: ReturnType; @@ -15,8 +13,8 @@ test.describe('Clinician Account Settings Access', () => { 'should allow navigation to account settings and capture GET response', { tag: createValidatedTags([ - TEST_TAGS.PATIENT, - TEST_TAGS.CLAIMED, + TEST_TAGS.PATIENT, + TEST_TAGS.CLAIMED, TEST_TAGS.API, TEST_TAGS.UI, TEST_TAGS.HIGH, @@ -24,8 +22,6 @@ test.describe('Clinician Account Settings Access', () => { ]), }, async ({ page }) => { - - // Step 1: Log in to clinician account and setup network capture await test.step('Given clinician has been logged in', async () => { api = createNetworkHelper(page); @@ -39,7 +35,7 @@ test.describe('Clinician Account Settings Access', () => { await accountTest.account.navigateTo('AccountSettings', page); }); - // Step 3: Validate profile GET response + // Step 3: Validate profile GET response await (test as any).stepNoScreenshot( 'Then profile endpoint responds with GET request consistent with schema ', async () => { @@ -47,11 +43,10 @@ test.describe('Clinician Account Settings Access', () => { }, ); - //Setup for Account Settings page and previous email for reset + // Setup for Account Settings page and previous email for reset const accountSettingsPage = new AccountSettingsPage(page); let originalEmail = ''; - // Step 4: Read and change email field to temporary value await test.step('When user updates the email field', async () => { originalEmail = await accountSettingsPage.emailInput.inputValue(); @@ -69,16 +64,23 @@ test.describe('Clinician Account Settings Access', () => { }); // Step 7: Validate PUT request and email value - await (test as any).stepNoScreenshot('Then PUT request is validated and email is set to new value', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - const putCapture = api.getCaptures().find( - (req: any) => req.method === 'PUT' && req.url.includes('/profile') - ); - if (!putCapture) throw new Error('No PUT /profile request captured'); - if (!putCapture.requestBody || !putCapture.requestBody.email || putCapture.requestBody.email !== 'qa+TempEdit@tidepool.org') { - throw new Error('PUT request did not set email to qa+TempEdit@tidepool.org'); - } - }); + await (test as any).stepNoScreenshot( + 'Then PUT request is validated and email is set to new value', + async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putCapture = api + .getCaptures() + .find((req: any) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) throw new Error('No PUT /profile request captured'); + if ( + !putCapture.requestBody || + !putCapture.requestBody.email || + putCapture.requestBody.email !== 'qa+TempEdit@tidepool.org' + ) { + throw new Error('PUT request did not set email to qa+TempEdit@tidepool.org'); + } + }, + ); // Step 8: Change email field to temporary value await test.step('When user sets the email field to the previous value', async () => { @@ -95,17 +97,24 @@ test.describe('Clinician Account Settings Access', () => { await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); }); - // Step 7: Validate PUT request and email value - await (test as any).stepNoScreenshot('Then PUT request is validated and email is set to new value', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - const putCapture = api.getCaptures().find( - (req: any) => req.method === 'PUT' && req.url.includes('/profile') - ); - if (!putCapture) throw new Error('No PUT /profile request captured'); - if (!putCapture.requestBody || !putCapture.requestBody.email || putCapture.requestBody.email !== originalEmail) { - throw new Error('PUT request did not set email to originalEmail'); - } - }); + // Step 7: Validate PUT request and email value + await (test as any).stepNoScreenshot( + 'Then PUT request is validated and email is set to new value', + async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putCapture = api + .getCaptures() + .find((req: any) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) throw new Error('No PUT /profile request captured'); + if ( + !putCapture.requestBody || + !putCapture.requestBody.email || + putCapture.requestBody.email !== originalEmail + ) { + throw new Error('PUT request did not set email to originalEmail'); + } + }, + ); await api.stopCapture(); }, diff --git a/tests/fixtures/account-helpers.ts b/tests/fixtures/account-helpers.ts index dac6a7b..763fbbd 100644 --- a/tests/fixtures/account-helpers.ts +++ b/tests/fixtures/account-helpers.ts @@ -59,7 +59,9 @@ async function navigateTo(targetPage: keyof AccountNav['pages'], page: Page): Pr // Open navigation menu if needed (only for non-AccountNav targets) if (targetPage !== 'AccountNav') { - const menuVisible = await nav.pages.AccountNav.verifyElement.isVisible({ timeout: 1000 }).catch(() => false); + const menuVisible = await nav.pages.AccountNav.verifyElement + .isVisible({ timeout: 1000 }) + .catch(() => false); if (!menuVisible) { await nav.pages.AccountNav.link.click(); await nav.pages.AccountNav.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); @@ -69,7 +71,9 @@ async function navigateTo(targetPage: keyof AccountNav['pages'], page: Page): Pr // Handle logout specially if (targetPage === 'Logout') { await pageConfig.link.click(); - await page.waitForURL(/.*login.*/, { waitUntil: 'domcontentloaded', timeout: 5000 }).catch(() => {}); + await page + .waitForURL(/.*login.*/, { waitUntil: 'domcontentloaded', timeout: 5000 }) + .catch(() => {}); } else { // Standard navigation - click and verify await pageConfig.link.click(); diff --git a/tests/fixtures/base.ts b/tests/fixtures/base.ts index a4d18f1..e052842 100644 --- a/tests/fixtures/base.ts +++ b/tests/fixtures/base.ts @@ -201,7 +201,7 @@ export const test: TestType< body: screenshot, contentType: 'image/png', }); - + // Also save to test-results for organized viewing (single source) const testResultsDir = path.join(testInfo.outputDir, 'attachments'); await fs.promises.mkdir(testResultsDir, { recursive: true }); diff --git a/tests/fixtures/network-helpers.ts b/tests/fixtures/network-helpers.ts index 3fd6039..953af7c 100644 --- a/tests/fixtures/network-helpers.ts +++ b/tests/fixtures/network-helpers.ts @@ -154,7 +154,10 @@ export class NetworkHelper { /** * Save all captures to a JSON file */ - async saveCapturesTo(filename: string, testInfo?: import('@playwright/test').TestInfo): Promise { + async saveCapturesTo( + filename: string, + testInfo?: import('@playwright/test').TestInfo, + ): Promise { const logDir = path.join(process.cwd(), 'log'); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); @@ -242,7 +245,7 @@ export class NetworkHelper { endpoint: string, method: string, fileName: string, - testInfo?: import('@playwright/test').TestInfo + testInfo?: import('@playwright/test').TestInfo, ): Promise { const responseData = { _request: { @@ -260,7 +263,7 @@ export class NetworkHelper { body: jsonContent, contentType: 'application/json', }); - + // Also save to test-results for organized viewing (like screenshots) const testResultsDir = path.join(testInfo.outputDir, 'attachments'); await fs.promises.mkdir(testResultsDir, { recursive: true }); @@ -296,7 +299,7 @@ export class NetworkHelper { request.url, schema.method, fileName, - (globalThis as any).testInfo + (globalThis as any).testInfo, ); } } @@ -324,16 +327,16 @@ export class NetworkHelper { const fileName = `step-api-${stepName}-${endpointName.replace(/[^a-z0-9]/gi, '-')}-${timestamp}.json`; console.log(`āœ… Saved ${endpointName} response for dependent tests`); - + // Use Playwright's automatic attachment instead of file system - const testInfo = (globalThis as any).testInfo; + const { testInfo } = globalThis as any; if (testInfo && typeof testInfo.attach === 'function') { await testInfo.attach(fileName, { body: JSON.stringify(capture, null, 2), contentType: 'application/json', }); } - + return capture; } @@ -378,13 +381,8 @@ export class NetworkHelper { requiredFields: string[] = ['fullName'], // Only require fullName by default, but allow override ): void { // Use provided fields or fall back to a basic set for backward compatibility - const defaultFields = [ - 'fullName', - 'patient.fullName', - 'patient.birthday', - 'email', - ]; - + const defaultFields = ['fullName', 'patient.fullName', 'patient.birthday', 'email']; + const fieldsToCheck = fieldsToValidate || defaultFields; const producerData = producerCapture.responseBody; const consumerData = consumerCapture.responseBody; @@ -410,7 +408,7 @@ export class NetworkHelper { // Check if this field is marked as required const isRequired = requiredFields.includes(fieldPath); - + if (isRequired) { if (producerValue === undefined || producerValue === null) { throw new Error(`Required field ${fieldPath} is missing in producer data`); @@ -468,15 +466,13 @@ export class NetworkHelper { const consumerSchema = getEndpointSchema(consumerEndpointName); // Use provided fields, or consumer endpoint validation fields, or producer endpoint validation fields - const validationFields = fieldsToValidate || - consumerSchema.validationFields || - producerSchema.validationFields || - ['fullName', 'email']; + const validationFields = fieldsToValidate || + consumerSchema.validationFields || + producerSchema.validationFields || ['fullName', 'email']; // Use consumer endpoint required fields, or producer endpoint required fields, or default - const requiredFields = consumerSchema.requiredFields || - producerSchema.requiredFields || - ['fullName']; + const requiredFields = consumerSchema.requiredFields || + producerSchema.requiredFields || ['fullName']; const producerCapture = this.getLatestCaptureMatching( producerSchema.method, @@ -495,7 +491,12 @@ export class NetworkHelper { throw new Error(`No ${consumerEndpointName} capture found for consumer validation`); } - this.validateDataConsistency(producerCapture, consumerCapture, validationFields, requiredFields); + this.validateDataConsistency( + producerCapture, + consumerCapture, + validationFields, + requiredFields, + ); } /** @@ -523,11 +524,10 @@ export class NetworkHelper { ): Promise { // Get the endpoint schema to determine validation fields const consumerSchema = getEndpointSchema(consumerEndpointName); - + // Use provided fields, or endpoint-specific fields, or fall back to basic fields - const validationFields = fieldsToValidate || - consumerSchema.validationFields || - ['fullName', 'patient.fullName', 'email']; + const validationFields = fieldsToValidate || + consumerSchema.validationFields || ['fullName', 'patient.fullName', 'email']; // Use endpoint-specific required fields, or default to fullName for backward compatibility const requiredFields = consumerSchema.requiredFields || ['fullName']; @@ -556,7 +556,7 @@ export class NetworkHelper { description: `Data consistency comparison for ${consumerEndpointName}`, timestamp: new Date().toISOString(), fieldsValidated: validationFields, - requiredFields: requiredFields, + requiredFields, }, original: { url: producerCapture.url, @@ -579,18 +579,23 @@ export class NetworkHelper { const fileName = `step-${stepNumber.toString().padStart(2, '0')}-${stepNameForFile}-comparison.json`; // Save the comparison data using the unified approach - const testInfo = (globalThis as any).testInfo; + const { testInfo } = globalThis as any; await this.saveApiResponse( comparisonData, consumerCapture.url, consumerCapture.method, fileName, - testInfo + testInfo, ); } // Validate data consistency using the determined validation fields and required fields - this.validateDataConsistency(producerCapture, consumerCapture, validationFields, requiredFields); + this.validateDataConsistency( + producerCapture, + consumerCapture, + validationFields, + requiredFields, + ); } } diff --git a/tests/fixtures/patient-helpers.ts b/tests/fixtures/patient-helpers.ts index 1a19f4a..865eb81 100644 --- a/tests/fixtures/patient-helpers.ts +++ b/tests/fixtures/patient-helpers.ts @@ -23,7 +23,7 @@ async function setupPatientSession(page: Page): Promise { async function closeOpenDialogs(page: Page): Promise { try { if (page.isClosed()) return; - + // Simple and fast: just press Escape twice to close any modals await page.keyboard.press('Escape'); await page.keyboard.press('Escape'); @@ -55,18 +55,14 @@ interface NavigationStep { async function isInPatientContext(nav: PatientNav, page: Page): Promise { try { // Check if any patient navigation elements are visible - const patientElements = [ - nav.pages.ViewData.link, - nav.pages.Profile.link, - nav.pages.Share.link - ]; - + const patientElements = [nav.pages.ViewData.link, nav.pages.Profile.link, nav.pages.Share.link]; + for (const element of patientElements) { if (await element.isVisible({ timeout: 1000 })) { return true; } } - + return false; } catch { return false; @@ -76,14 +72,20 @@ async function isInPatientContext(nav: PatientNav, page: Page): Promise /** * Get current page state by checking URL and visible elements */ -async function getCurrentPageState(nav: PatientNav, page: Page): Promise { +async function getCurrentPageState( + nav: PatientNav, + page: Page, +): Promise { const url = page.url(); - + // Check each page in order of specificity for (const [pageName, pageConfig] of Object.entries(nav.pages)) { try { if (pageConfig.verifyURL && url.includes(pageConfig.verifyURL)) { - if (pageConfig.verifyElement && await pageConfig.verifyElement.isVisible({ timeout: 1000 })) { + if ( + pageConfig.verifyElement && + (await pageConfig.verifyElement.isVisible({ timeout: 1000 })) + ) { return pageName as keyof PatientNav['pages']; } } @@ -91,7 +93,7 @@ async function getCurrentPageState(nav: PatientNav, page: Page): Promise = { // Basic page navigation - 'default': [ + default: [ { name: 'close-dialogs', - action: async (state) => await closeOpenDialogs(state.page) + action: async state => closeOpenDialogs(state.page), }, { name: 'check-patient-context', - condition: async (state) => !(await isInPatientContext(state.nav, state.page)), - action: async (state) => { + condition: async state => !(await isInPatientContext(state.nav, state.page)), + action: async state => { console.log('Not in patient context, navigating to /data URL to reset'); // Navigate to /data endpoint specifically, not just base URL - await state.page.goto(env.BASE_URL + '/data'); + await state.page.goto(`${env.BASE_URL}/data`); await state.page.waitForLoadState('domcontentloaded'); // Wait for patient navigation to be available await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); console.log('Successfully reset to patient context via /data URL'); - } + }, }, { name: 'wait-for-loading', - action: async (state) => { + action: async state => { const loading = state.page.getByText('Loading...', { exact: true }); try { await loading.waitFor({ state: 'hidden', timeout: 3000 }); } catch { // Loading might not be visible } - } + }, }, { name: 'navigate-click', - action: async (state) => { + action: async state => { const pageConfig = state.nav.pages[state.targetPage]; await pageConfig.link.click({ timeout: 5000 }); - } + }, }, { name: 'verify-navigation', - verify: async (state) => { + verify: async state => { const pageConfig = state.nav.pages[state.targetPage]; if (pageConfig.verifyElement) { try { @@ -149,55 +151,56 @@ const navigationStrategies: Record = { } } return true; - } - } + }, + }, ], // Profile page - handle account settings conflict - 'Profile': [ + Profile: [ { name: 'close-dialogs', - action: async (state) => await closeOpenDialogs(state.page) + action: async state => closeOpenDialogs(state.page), }, { name: 'check-patient-context', - condition: async (state) => !(await isInPatientContext(state.nav, state.page)), - action: async (state) => { + condition: async state => !(await isInPatientContext(state.nav, state.page)), + action: async state => { console.log('Not in patient context, navigating to /data URL to reset'); // Navigate to /data endpoint specifically, not just base URL - await state.page.goto(env.BASE_URL + '/data'); + await state.page.goto(`${env.BASE_URL}/data`); await state.page.waitForLoadState('domcontentloaded'); // Wait for patient navigation to be available await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); console.log('Successfully reset to patient context via /data URL'); - } + }, }, { name: 'handle-account-settings-conflict', - condition: async (state) => { - return state.page.url().includes('/profile') && - await state.page.getByRole('heading', { name: /account/i }) - .or(state.page.getByText('Account Settings')) - .or(state.page.getByText('Account')) - .or(state.page.locator('.profile-subnav-title').getByText('Account')) - .isVisible().catch(() => false); - }, - action: async (state) => { + condition: async state => + state.page.url().includes('/profile') && + (await state.page + .getByRole('heading', { name: /account/i }) + .or(state.page.getByText('Account Settings')) + .or(state.page.getByText('Account')) + .or(state.page.locator('.profile-subnav-title').getByText('Account')) + .isVisible() + .catch(() => false)), + action: async state => { console.log('On account settings page, redirecting to base URL first'); await state.page.goto(env.BASE_URL); await state.page.waitForTimeout(500); - } + }, }, { name: 'navigate-click', - action: async (state) => { + action: async state => { const pageConfig = state.nav.pages[state.targetPage]; await pageConfig.link.click({ timeout: 5000 }); - } + }, }, { name: 'verify-navigation', - verify: async (state) => { + verify: async state => { const pageConfig = state.nav.pages[state.targetPage]; if (pageConfig.verifyElement) { try { @@ -208,55 +211,55 @@ const navigationStrategies: Record = { } } return true; - } - } + }, + }, ], // Modal dialogs - 'modal': [ + modal: [ { name: 'close-dialogs', - action: async (state) => await closeOpenDialogs(state.page) + action: async state => closeOpenDialogs(state.page), }, { name: 'navigate-click', - action: async (state) => { + action: async state => { const pageConfig = state.nav.pages[state.targetPage]; await pageConfig.link.click({ timeout: 5000 }); - } + }, }, { name: 'wait-for-modal', - action: async (state) => { + action: async state => { await state.page.waitForTimeout(500); - } - } + }, + }, ], // Data pages that need ViewData prerequisite 'data-page': [ { name: 'close-dialogs', - action: async (state) => await closeOpenDialogs(state.page) + action: async state => closeOpenDialogs(state.page), }, { name: 'ensure-data-view', - condition: async (state) => !state.page.url().includes('/data/'), - action: async (state) => { + condition: async state => !state.page.url().includes('/data/'), + action: async state => { await state.nav.pages.ViewData.link.click(); await state.nav.pages.ViewData.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); - } + }, }, { name: 'navigate-click', - action: async (state) => { + action: async state => { const pageConfig = state.nav.pages[state.targetPage]; await pageConfig.link.click({ timeout: 5000 }); - } + }, }, { name: 'verify-navigation', - verify: async (state) => { + verify: async state => { const pageConfig = state.nav.pages[state.targetPage]; if (pageConfig.verifyElement) { try { @@ -267,46 +270,46 @@ const navigationStrategies: Record = { } } return true; - } - } + }, + }, ], // ShareData requires Share main page to be accessible first - 'ShareData': [ + ShareData: [ { name: 'close-dialogs', - action: async (state) => await closeOpenDialogs(state.page) + action: async state => closeOpenDialogs(state.page), }, { name: 'check-patient-context', - condition: async (state) => !(await isInPatientContext(state.nav, state.page)), - action: async (state) => { + condition: async state => !(await isInPatientContext(state.nav, state.page)), + action: async state => { console.log('Not in patient context, navigating to /data URL to reset'); - await state.page.goto(env.BASE_URL + '/data'); + await state.page.goto(`${env.BASE_URL}/data`); await state.page.waitForLoadState('domcontentloaded'); await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); console.log('Successfully reset to patient context via /data URL'); - } + }, }, { name: 'ensure-share-dependency', - action: async (state) => { + action: async state => { // First ensure Share main page is accessible try { await state.nav.pages.Share.link.waitFor({ state: 'visible', timeout: 3000 }); console.log('Share dependency met - Share button is accessible'); } catch { console.log('Share dependency not met - performing URL reset to /data'); - await state.page.goto(env.BASE_URL + '/data'); + await state.page.goto(`${env.BASE_URL}/data`); await state.page.waitForLoadState('domcontentloaded'); await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); console.log('URL reset completed, Share dependency should now be available'); } - } + }, }, { name: 'navigate-to-share-first', - action: async (state) => { + action: async state => { // Navigate to Share main page first to establish context try { await state.nav.pages.Share.link.click({ timeout: 3000 }); @@ -315,11 +318,11 @@ const navigationStrategies: Record = { } catch { console.log('Could not reach Share main page, staying in current state'); } - } + }, }, { name: 'navigate-to-sharedata', - action: async (state) => { + action: async state => { // Now try to navigate to ShareData sub-page try { await state.nav.pages.ShareData.link.click({ timeout: 5000 }); @@ -327,14 +330,17 @@ const navigationStrategies: Record = { } catch { console.log('ShareData button not available - this is expected and OK'); } - } + }, }, { name: 'verify-navigation', - verify: async (state) => { + verify: async state => { // Try to verify ShareData first, fall back to Share if not available try { - await state.nav.pages.ShareData.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + await state.nav.pages.ShareData.verifyElement.waitFor({ + state: 'visible', + timeout: 3000, + }); console.log('āœ… ShareData page verified'); return true; } catch { @@ -347,9 +353,9 @@ const navigationStrategies: Record = { return false; } } - } - } - ] + }, + }, + ], }; /** @@ -369,7 +375,7 @@ const pageStrategies: Record = { UploadData: 'default', ChartDateRange: 'modal', ChartDate: 'modal', - Print: 'modal' + Print: 'modal', }; /** @@ -378,9 +384,9 @@ const pageStrategies: Record = { async function executeNavigationStrategy(state: NavigationState): Promise { const strategyName = pageStrategies[state.targetPage] || 'default'; const strategy = navigationStrategies[strategyName]; - + console.log(`Executing ${strategyName} strategy for ${state.targetPage}`); - + for (const step of strategy) { try { // Check condition if present @@ -388,14 +394,14 @@ async function executeNavigationStrategy(state: NavigationState): Promise; +} + +interface XrayTest { + testKey?: string; + testInfo: { + summary: string; + type: 'Manual' | 'Cucumber' | 'Generic'; + projectKey: string; + labels?: string[]; + }; + status: 'PASS' | 'FAIL' | 'PENDING' | 'EXECUTING'; + comment?: string; + evidences?: Array<{ + data: string; + filename: string; + contentType: string; + }>; + steps?: XrayTestStep[]; + examples?: string[]; +} + +interface XrayExecutionResult { + info: { + summary: string; + description: string; + version?: string; + testPlanKey?: string; + testExecutionKey?: string; + startDate: string; + finishDate: string; + testEnvironments?: string[]; + }; + tests: XrayTest[]; +} + +/** + * Unified Xray JSON Reporter for Playwright + * Maps rich Playwright test data to Xray's JSON format with step-by-step evidence + */ +class XrayJsonReporter { + private styles = { + success: 'āœ…', + error: 'āŒ', + info: 'ā„¹ļø', + warning: 'ā›”ļø', + upload: 'šŸš€', + test: '🧪', + separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + }; + + private startTime: string = ''; + private endTime: string = ''; + + /** + * Authenticates with Xray API using client credentials + */ + async authenticateWithXray(): Promise { + try { + console.log(`${this.styles.info} Authenticating with Xray...`); + const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: env.XRAY_CLIENT_ID, + client_secret: env.XRAY_CLIENT_SECRET, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + } + + const token = await response.text(); + console.log(`${this.styles.success} Successfully authenticated with Xray`); + return token.replace(/"/g, ''); // Remove quotes from token + } catch (error) { + console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); + throw error; + } + } + + /** + * Converts file to base64 string for Xray evidence + */ + private async fileToBase64(filePath: string): Promise { + try { + const fileBuffer = fs.readFileSync(filePath); + return fileBuffer.toString('base64'); + } catch (error) { + console.warn(`${this.styles.warning} Could not read file ${filePath}:`, error); + return ''; + } + } + + /** + * Extracts step information from test annotations + */ + private async extractSteps(annotations: any[], attachments: any[]): Promise { + const steps: XrayTestStep[] = []; + const stepAnnotations = annotations.filter(ann => + ann.type.startsWith('Step Duration:') + ); + + for (const stepAnn of stepAnnotations) { + const stepName = stepAnn.type.replace('Step Duration: ', ''); + const duration = stepAnn.description; + + // Find associated step attachments + const stepAttachments = attachments.filter(att => + att.name.toLowerCase().includes(stepName.toLowerCase().substring(0, 20)) + ); + + const step: XrayTestStep = { + action: stepName, + data: `Duration: ${duration}`, + result: stepName.includes('Then') ? stepName : undefined, + status: 'PASS', // Will be updated based on test result + evidences: [] + }; + + // Add evidence for this step + for (const attachment of stepAttachments) { + if (attachment.path && fs.existsSync(attachment.path)) { + step.evidences?.push({ + data: await this.fileToBase64(attachment.path), + filename: path.basename(attachment.path), + contentType: attachment.contentType || 'application/octet-stream' + }); + } + } + + steps.push(step); + } + + return steps; + } + + /** + * Maps Playwright test result to Xray test format + */ + private async mapPlaywrightTestToXray( + testCase: TestCase, + testResult: TestResult + ): Promise { + const tags = (testCase as any).tags || []; + const annotations = testResult.annotations || []; + const attachments = testResult.attachments || []; + + // Extract steps from annotations + const steps = await this.extractSteps(annotations, attachments); + + // Mark failed steps if test failed + if (testResult.status !== 'passed' && steps.length > 0) { + steps[steps.length - 1].status = 'FAIL'; + steps[steps.length - 1].actualResult = testResult.error?.message || 'Test failed'; + } + + // Collect test-level evidence (screenshots, videos) + const testEvidences: Array<{data: string; filename: string; contentType: string}> = []; + for (const attachment of attachments) { + if (attachment.path && fs.existsSync(attachment.path)) { + // Add main test evidence (final screenshots, videos, etc.) + if (attachment.name.includes('screenshot') || attachment.name.includes('video')) { + testEvidences.push({ + data: await this.fileToBase64(attachment.path), + filename: attachment.name, + contentType: attachment.contentType || 'application/octet-stream' + }); + } + } + } + + const xrayTest: XrayTest = { + testInfo: { + summary: testCase.title, + type: 'Generic', + projectKey: 'XT', // Could be made configurable + labels: tags + }, + status: testResult.status === 'passed' ? 'PASS' : + testResult.status === 'skipped' ? 'PENDING' : 'FAIL', + comment: testResult.error?.message, + evidences: testEvidences, + steps: steps.length > 0 ? steps : undefined + }; + + return xrayTest; + } + + /** + * Converts Playwright JSON results to Xray format + */ + async convertPlaywrightJsonToXray(playwrightJsonPath: string): Promise { + const jsonContent = fs.readFileSync(playwrightJsonPath, 'utf8'); + const playwrightResult = JSON.parse(jsonContent); + + const tests: XrayTest[] = []; + + // Process all test suites + for (const suite of playwrightResult.suites || []) { + await this.processSuite(suite, tests); + } + + const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; + const targetEnv = process.env.TARGET_ENV || 'qa1'; + + const xrayResult: XrayExecutionResult = { + info: { + summary: `Playwright Test Execution - ${new Date().toISOString()}`, + description: `Automated test execution for ${targetEnv} environment`, + version: '1.0', + testExecutionKey: testExecKey !== 'none' ? testExecKey : undefined, + startDate: playwrightResult.stats?.startTime || new Date().toISOString(), + finishDate: new Date( + new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + + (playwrightResult.stats?.duration || 0) + ).toISOString(), + testEnvironments: [targetEnv] + }, + tests + }; + + return xrayResult; + } + + /** + * Recursively processes test suites + */ + private async processSuite(suite: any, tests: XrayTest[]): Promise { + // Process specs in this suite + for (const spec of suite.specs || []) { + for (const test of spec.tests || []) { + for (const result of test.results || []) { + const xrayTest = await this.mapPlaywrightTestToXray(spec, result); + tests.push(xrayTest); + } + } + } + + // Process nested suites + for (const nestedSuite of suite.suites || []) { + await this.processSuite(nestedSuite, tests); + } + } + + /** + * Uploads Xray execution result to Xray + */ + async uploadToXray(xrayResult: XrayExecutionResult): Promise { + try { + console.log(`${this.styles.info} Uploading test execution to Xray...`); + + const token = await this.authenticateWithXray(); + + const response = await fetch( + 'https://xray.cloud.getxray.app/api/v2/import/execution', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(xrayResult), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + } + + const result = await response.json(); + console.log(`${this.styles.success} Successfully uploaded to Xray. Execution Key: ${result.key}`); + } catch (error) { + console.error(`${this.styles.error} Failed to upload to Xray:`, error); + throw error; + } + } + + /** + * Main method to process and upload results + */ + async processAndUpload(playwrightJsonPath: string): Promise { + if (!(env.XRAY_CLIENT_ID && env.XRAY_CLIENT_SECRET)) { + console.log( + `${this.styles.warning} No Xray credentials found, skipping upload to JIRA Xray` + ); + return; + } + + try { + console.log(`${this.styles.info} Processing Playwright results...`); + const xrayResult = await this.convertPlaywrightJsonToXray(playwrightJsonPath); + + // Save converted result for debugging + fs.writeFileSync( + 'test-results/xray-execution.json', + JSON.stringify(xrayResult, null, 2) + ); + + await this.uploadToXray(xrayResult); + console.log(`${this.styles.upload} Xray upload completed successfully`); + } catch (error) { + console.error(`${this.styles.error} Failed to process and upload:`, error); + throw error; + } + } + + /** + * Reporter lifecycle methods for direct Playwright integration + */ + onBegin(_config: FullConfig, suite: Suite): void { + this.startTime = new Date().toISOString(); + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); + console.log(`${this.styles.separator}\n`); + } + + onTestBegin(test: TestCase, _result: TestResult): void { + console.log(`${this.styles.test} Starting: ${test.title}`); + } + + onTestEnd(test: TestCase, result: TestResult): void { + const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; + console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); + } + + async onEnd(result: FullResult): Promise { + this.endTime = new Date().toISOString(); + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.info} Test Run Summary:`); + console.log( + `Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}` + ); + console.log(`Duration: ${result.duration}ms`); + console.log(`${this.styles.separator}\n`); + + // Auto-upload if JSON results are available + const jsonPath = 'test-results/last-run.json'; + if (fs.existsSync(jsonPath)) { + await this.processAndUpload(jsonPath); + } + } +} + +export default XrayJsonReporter; \ No newline at end of file From 7f30cae5abe034dae857e41b1db4a401fb231714 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 25 Aug 2025 15:28:29 -0400 Subject: [PATCH 04/60] Refactor Xray reporter and update .gitignore Refactored xray-json-reporter to improve code style, add a helper for status mapping, and use consistent array type annotations. Updated upload-to-xray.js to use node: imports and improved formatting. Added build/ and dist/ to .gitignore. Minor comment and formatting improvements in Playwright config. --- .gitignore | 2 + build/playwright.config.js | 1 + build/utilities/xray-json-reporter.js | 27 ++++--- utilities/upload-to-xray.js | 16 ++-- utilities/xray-json-reporter.ts | 103 +++++++++++++------------- 5 files changed, 80 insertions(+), 69 deletions(-) diff --git a/.gitignore b/.gitignore index 90071ce..b0625d2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ tests_output .vscode vrt/diff vrt/latest +build/ +dist/ # ui screens test_evidence diff --git a/build/playwright.config.js b/build/playwright.config.js index 2e08ea5..57baf55 100644 --- a/build/playwright.config.js +++ b/build/playwright.config.js @@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); const test_1 = require("@playwright/test"); const node_path_1 = __importDefault(require("node:path")); const env_1 = __importDefault(require("./utilities/env")); +// Legacy XML options - can be removed when fully migrated to JSON const xrayOptions = { embedAnnotationsAsProperties: true, textContentAnnotations: ['test_description', 'testrun_comment'], diff --git a/build/utilities/xray-json-reporter.js b/build/utilities/xray-json-reporter.js index c6d7c4a..01ea7fe 100644 --- a/build/utilities/xray-json-reporter.js +++ b/build/utilities/xray-json-reporter.js @@ -53,6 +53,16 @@ class XrayJsonReporter { throw error; } } + /** + * Maps Playwright test status to Xray status + */ + getTestStatus(status) { + if (status === 'passed') + return 'PASS'; + if (status === 'skipped') + return 'PENDING'; + return 'FAIL'; + } /** * Converts file to base64 string for Xray evidence */ @@ -82,7 +92,7 @@ class XrayJsonReporter { data: `Duration: ${duration}`, result: stepName.includes('Then') ? stepName : undefined, status: 'PASS', // Will be updated based on test result - evidences: [] + evidences: [], }; // Add evidence for this step for (const attachment of stepAttachments) { @@ -90,7 +100,7 @@ class XrayJsonReporter { step.evidences?.push({ data: await this.fileToBase64(attachment.path), filename: node_path_1.default.basename(attachment.path), - contentType: attachment.contentType || 'application/octet-stream' + contentType: attachment.contentType || 'application/octet-stream', }); } } @@ -121,7 +131,7 @@ class XrayJsonReporter { testEvidences.push({ data: await this.fileToBase64(attachment.path), filename: attachment.name, - contentType: attachment.contentType || 'application/octet-stream' + contentType: attachment.contentType || 'application/octet-stream', }); } } @@ -131,13 +141,12 @@ class XrayJsonReporter { summary: testCase.title, type: 'Generic', projectKey: 'XT', // Could be made configurable - labels: tags + labels: tags, }, - status: testResult.status === 'passed' ? 'PASS' : - testResult.status === 'skipped' ? 'PENDING' : 'FAIL', + status: this.getTestStatus(testResult.status), comment: testResult.error?.message, evidences: testEvidences, - steps: steps.length > 0 ? steps : undefined + steps: steps.length > 0 ? steps : undefined, }; return xrayTest; } @@ -163,9 +172,9 @@ class XrayJsonReporter { startDate: playwrightResult.stats?.startTime || new Date().toISOString(), finishDate: new Date(new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + (playwrightResult.stats?.duration || 0)).toISOString(), - testEnvironments: [targetEnv] + testEnvironments: [targetEnv], }, - tests + tests, }; return xrayResult; } diff --git a/utilities/upload-to-xray.js b/utilities/upload-to-xray.js index be127b6..61a923b 100644 --- a/utilities/upload-to-xray.js +++ b/utilities/upload-to-xray.js @@ -1,31 +1,29 @@ -#!/usr/bin/env node - /** * Standalone utility to upload Playwright JSON results to Xray * Usage: node utilities/upload-to-xray.js [path-to-json-results] */ -const fs = require('fs'); -const path = require('path'); +const fs = require('node:fs'); +const path = require('node:path'); // Import the compiled TypeScript reporter async function uploadResults() { try { // Import compiled CommonJS module const XrayJsonReporter = require('../build/utilities/xray-json-reporter.js').default; - + const jsonPath = process.argv[2] || 'test-results/last-run.json'; - + if (!fs.existsSync(jsonPath)) { console.error(`āŒ JSON results file not found: ${jsonPath}`); process.exit(1); } console.log(`šŸš€ Processing Playwright results from: ${jsonPath}`); - + const reporter = new XrayJsonReporter(); await reporter.processAndUpload(jsonPath); - + console.log('āœ… Xray upload completed successfully'); } catch (error) { console.error('āŒ Failed to upload to Xray:', error); @@ -33,4 +31,4 @@ async function uploadResults() { } } -uploadResults(); \ No newline at end of file +uploadResults(); diff --git a/utilities/xray-json-reporter.ts b/utilities/xray-json-reporter.ts index de2803a..9e37d3d 100644 --- a/utilities/xray-json-reporter.ts +++ b/utilities/xray-json-reporter.ts @@ -9,11 +9,11 @@ interface XrayTestStep { result?: string; status: 'PASS' | 'FAIL' | 'PENDING'; actualResult?: string; - evidences?: Array<{ + evidences?: { data: string; filename: string; contentType: string; - }>; + }[]; } interface XrayTest { @@ -26,11 +26,11 @@ interface XrayTest { }; status: 'PASS' | 'FAIL' | 'PENDING' | 'EXECUTING'; comment?: string; - evidences?: Array<{ + evidences?: { data: string; filename: string; contentType: string; - }>; + }[]; steps?: XrayTestStep[]; examples?: string[]; } @@ -64,8 +64,9 @@ class XrayJsonReporter { separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', }; - private startTime: string = ''; - private endTime: string = ''; + private startTime = ''; + + private endTime = ''; /** * Authenticates with Xray API using client credentials @@ -98,6 +99,15 @@ class XrayJsonReporter { } } + /** + * Maps Playwright test status to Xray status + */ + private getTestStatus(status: string): 'PASS' | 'FAIL' | 'PENDING' { + if (status === 'passed') return 'PASS'; + if (status === 'skipped') return 'PENDING'; + return 'FAIL'; + } + /** * Converts file to base64 string for Xray evidence */ @@ -116,17 +126,15 @@ class XrayJsonReporter { */ private async extractSteps(annotations: any[], attachments: any[]): Promise { const steps: XrayTestStep[] = []; - const stepAnnotations = annotations.filter(ann => - ann.type.startsWith('Step Duration:') - ); + const stepAnnotations = annotations.filter(ann => ann.type.startsWith('Step Duration:')); for (const stepAnn of stepAnnotations) { const stepName = stepAnn.type.replace('Step Duration: ', ''); const duration = stepAnn.description; - + // Find associated step attachments - const stepAttachments = attachments.filter(att => - att.name.toLowerCase().includes(stepName.toLowerCase().substring(0, 20)) + const stepAttachments = attachments.filter(att => + att.name.toLowerCase().includes(stepName.toLowerCase().substring(0, 20)), ); const step: XrayTestStep = { @@ -134,7 +142,7 @@ class XrayJsonReporter { data: `Duration: ${duration}`, result: stepName.includes('Then') ? stepName : undefined, status: 'PASS', // Will be updated based on test result - evidences: [] + evidences: [], }; // Add evidence for this step @@ -143,7 +151,7 @@ class XrayJsonReporter { step.evidences?.push({ data: await this.fileToBase64(attachment.path), filename: path.basename(attachment.path), - contentType: attachment.contentType || 'application/octet-stream' + contentType: attachment.contentType || 'application/octet-stream', }); } } @@ -158,8 +166,8 @@ class XrayJsonReporter { * Maps Playwright test result to Xray test format */ private async mapPlaywrightTestToXray( - testCase: TestCase, - testResult: TestResult + testCase: TestCase, + testResult: TestResult, ): Promise { const tags = (testCase as any).tags || []; const annotations = testResult.annotations || []; @@ -175,7 +183,7 @@ class XrayJsonReporter { } // Collect test-level evidence (screenshots, videos) - const testEvidences: Array<{data: string; filename: string; contentType: string}> = []; + const testEvidences: { data: string; filename: string; contentType: string }[] = []; for (const attachment of attachments) { if (attachment.path && fs.existsSync(attachment.path)) { // Add main test evidence (final screenshots, videos, etc.) @@ -183,7 +191,7 @@ class XrayJsonReporter { testEvidences.push({ data: await this.fileToBase64(attachment.path), filename: attachment.name, - contentType: attachment.contentType || 'application/octet-stream' + contentType: attachment.contentType || 'application/octet-stream', }); } } @@ -194,13 +202,12 @@ class XrayJsonReporter { summary: testCase.title, type: 'Generic', projectKey: 'XT', // Could be made configurable - labels: tags + labels: tags, }, - status: testResult.status === 'passed' ? 'PASS' : - testResult.status === 'skipped' ? 'PENDING' : 'FAIL', + status: this.getTestStatus(testResult.status), comment: testResult.error?.message, evidences: testEvidences, - steps: steps.length > 0 ? steps : undefined + steps: steps.length > 0 ? steps : undefined, }; return xrayTest; @@ -231,12 +238,12 @@ class XrayJsonReporter { testExecutionKey: testExecKey !== 'none' ? testExecKey : undefined, startDate: playwrightResult.stats?.startTime || new Date().toISOString(), finishDate: new Date( - new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + - (playwrightResult.stats?.duration || 0) + new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + + (playwrightResult.stats?.duration || 0), ).toISOString(), - testEnvironments: [targetEnv] + testEnvironments: [targetEnv], }, - tests + tests, }; return xrayResult; @@ -268,20 +275,17 @@ class XrayJsonReporter { async uploadToXray(xrayResult: XrayExecutionResult): Promise { try { console.log(`${this.styles.info} Uploading test execution to Xray...`); - + const token = await this.authenticateWithXray(); - - const response = await fetch( - 'https://xray.cloud.getxray.app/api/v2/import/execution', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(xrayResult), - } - ); + + const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(xrayResult), + }); if (!response.ok) { const errorText = await response.text(); @@ -289,7 +293,9 @@ class XrayJsonReporter { } const result = await response.json(); - console.log(`${this.styles.success} Successfully uploaded to Xray. Execution Key: ${result.key}`); + console.log( + `${this.styles.success} Successfully uploaded to Xray. Execution Key: ${result.key}`, + ); } catch (error) { console.error(`${this.styles.error} Failed to upload to Xray:`, error); throw error; @@ -301,22 +307,17 @@ class XrayJsonReporter { */ async processAndUpload(playwrightJsonPath: string): Promise { if (!(env.XRAY_CLIENT_ID && env.XRAY_CLIENT_SECRET)) { - console.log( - `${this.styles.warning} No Xray credentials found, skipping upload to JIRA Xray` - ); + console.log(`${this.styles.warning} No Xray credentials found, skipping upload to JIRA Xray`); return; } try { console.log(`${this.styles.info} Processing Playwright results...`); const xrayResult = await this.convertPlaywrightJsonToXray(playwrightJsonPath); - + // Save converted result for debugging - fs.writeFileSync( - 'test-results/xray-execution.json', - JSON.stringify(xrayResult, null, 2) - ); - + fs.writeFileSync('test-results/xray-execution.json', JSON.stringify(xrayResult, null, 2)); + await this.uploadToXray(xrayResult); console.log(`${this.styles.upload} Xray upload completed successfully`); } catch (error) { @@ -349,7 +350,7 @@ class XrayJsonReporter { console.log(`\n${this.styles.separator}`); console.log(`${this.styles.info} Test Run Summary:`); console.log( - `Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}` + `Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`, ); console.log(`Duration: ${result.duration}ms`); console.log(`${this.styles.separator}\n`); @@ -362,4 +363,4 @@ class XrayJsonReporter { } } -export default XrayJsonReporter; \ No newline at end of file +export default XrayJsonReporter; From 71874d07e3976e49cfa122ab5f1325deb9c500b9 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 25 Aug 2025 15:35:24 -0400 Subject: [PATCH 05/60] Update CLAUDE.md with expanded test and config details Enhanced claude documentation to include new npm scripts for targeted test runs, expanded environment and tag filtering, and additional details on test tagging, reporting, and project structure. Updated environment and configuration file descriptions, added instructions for new test directories, and clarified integration with Xray and CircleCI. --- CLAUDE.md | 57 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 076be15..85d7421 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,19 +10,29 @@ This is a Playwright-based UI testing suite for Tidepool's web application, supp ### Testing Commands - `npm test` - Run all tests on qa1 environment -- `TARGET_ENV=qa2 playwright test` - Run tests on qa2 environment -- `TARGET_ENV=production playwright test` - Run tests on production +- `npm run test:qa2` - Run tests on qa2 environment +- `npm run test:prd` - Run tests on production +- `npm run test:smoke` - Run only smoke tests +- `npm run test:critical` - Run only critical tests +- `npm run test:api` - Run only API tests +- `npm run test:ui` - Run only UI tests +- `npm run test:patient` - Run only patient tests +- `npm run test:clinician` - Run only clinician tests - `npm run debug` - Debug tests with Playwright's debug mode -- `playwright test --project=chromium-patient` - Run only patient tests -- `playwright test --project=chromium-clinician` - Run only clinician tests +- `playwright test tests/specific-test.spec.ts` - Run a single test file +- `TARGET_ENV=qa2 TEST_TAGS='@smoke @critical' npm test` - Run tests with environment and tags ### Code Quality Commands +- `npm run check` - Run both linting and TypeScript checking - `npm run lint` - Run ESLint on TypeScript files - `npm run lint:fix` - Run ESLint with auto-fix +- `npm run typecheck` - Run TypeScript compiler check +- `npm run build` - Compile TypeScript files - `npm run format` - Format code with Prettier -### Report Generation +### Report Generation and Integration - `npm run merge-reports` - Merge XML test reports from different test suites +- `npm run upload-to-xray` - Upload test results to Xray (requires credentials) ## Architecture Overview @@ -43,13 +53,21 @@ The codebase follows the Page Object Model (POM) pattern with a clear separation ### Environment Management - **`utilities/env.ts`** - Centralized environment configuration using Zod validation -- Supports environments: qa1, qa2, qa3, qa4, qa5, production +- Supports environments: qa1, qa2, qa3, qa4, qa5, prd, int - Environment variables validated at startup +- **`utilities/test-runner.js`** - Dynamic test execution with environment and tag filtering ### Key Configuration Files -- **`playwright.config.ts`** - Playwright configuration with dual project setup (local + BrowserStack) +- **`playwright.config.ts`** - Playwright configuration with dual project setup (local + BrowserStack), includes JSON and Xray reporters - **`tsconfig.json`** - TypeScript configuration with path mapping for imports -- **`eslint.config.mjs`** - ESLint configuration using Airbnb Extended rules +- **`eslint.config.mjs`** - ESLint configuration using Airbnb Extended rules, includes test automation exceptions +- **`.circleci/config.yml`** - CI/CD pipeline with dynamic environment and tag support + +### Test Result Reporting +- **JSON Reporter**: Generates `test-results/last-run.json` with rich test data +- **Xray Integration**: `utilities/xray-json-reporter.ts` uploads test results with step-by-step evidence +- **HTML Reports**: Interactive reports in `playwright-report/` +- **CircleCI Integration**: Automated test result submission to Xray using testExecKey parameter ## Project-Specific Patterns @@ -78,13 +96,21 @@ Tests automatically detect BrowserStack environment variables and switch between - Dynamic test data generation (e.g., timestamps) to avoid test conflicts - Environment-specific URL mapping +### Test Tagging System +- **`tests/fixtures/test-tags.ts`** - Comprehensive tag system with validation +- **Required Categories**: User Types (@patient, @clinician), Test Types (@api, @ui, @smoke), Priorities (@critical, @high, @medium, @low) +- **Tag Filtering**: Supports AND logic (space-separated) and OR logic (comma-separated) +- **Dynamic Execution**: Use `TEST_TAGS` environment variable for selective test runs + ## Development Notes ### Adding New Tests -1. Create test files in appropriate directory (`tests/clinician/` or `tests/patient/`) +1. Create test files in appropriate directory (`tests/clinician/`, `tests/patient/`, `tests/claimed/`, `tests/personal/`) 2. Import custom fixtures: `import { expect, test } from '@fixtures/base'` 3. Use page objects with path aliases: `import LoginPage from '@pom/LoginPage'` 4. Follow the Given-When-Then pattern with `test.step()` blocks +5. Add test tags using `createValidatedTags()` from `@fixtures/test-tags` +6. Use project-specific imports for specialized fixtures (e.g., `network-helpers`, `patient-helpers`) ### Creating Page Objects 1. Extend the pattern established in existing page objects @@ -96,5 +122,14 @@ Tests automatically detect BrowserStack environment variables and switch between Required environment variables: - `PATIENT_USERNAME` / `PATIENT_PASSWORD` - `CLINICIAN_USERNAME` / `CLINICIAN_PASSWORD` -- `TARGET_ENV` (qa1, qa2, qa3, qa4, qa5, production) -- Optional: `BROWSERSTACK_USERNAME` / `BROWSERSTACK_ACCESS_KEY` \ No newline at end of file +- `TARGET_ENV` (qa1, qa2, qa3, qa4, qa5, prd, int) +- Optional: `BROWSERSTACK_USERNAME` / `BROWSERSTACK_ACCESS_KEY` +- Optional: `XRAY_CLIENT_ID` / `XRAY_CLIENT_SECRET` (for Xray integration) +- Optional: `TEST_TAGS` (for filtering tests by tags) + +### Project Structure Understanding +The test suite is organized by user authentication state: +- **`tests/personal/`** - Tests for personal (individual) patient accounts +- **`tests/claimed/`** - Tests for claimed patient accounts (connected to clinicians) +- **`tests/clinician/`** - Tests for clinician user flows +Each directory has separate authentication setup and isolated test execution. \ No newline at end of file From 93a784ce486b0e55e4fdafaa5e11c65a93bf66df Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 25 Aug 2025 15:59:06 -0400 Subject: [PATCH 06/60] Refactor helpers, fix globals, and update docs Refactored clinic-helpers to remove duplicate findAndAccessAnyPatient, improved global stepCounter naming for network helpers, and added ESLint disables for process.exit and global require. Updated documentation and prompt files for clarity and formatting. Minor bugfixes and code style improvements in patient ProfilePage, patient-helpers, and test-runner utility. (Linting) --- .circleci/config.yml | 2 +- CLAUDE.md | 26 +++++++++++++++-- Prompts/CLINICIAN_NAVIGATION_SUMMARY.md | 30 +++++++++++++------ Prompts/ENDPOINT_SCALABILITY_DEMO.md | 22 ++++++++++++-- Prompts/test-scribe.md | 4 +-- docs/XRAY_INTEGRATION.md | 38 +++++++++++++++++++------ endpoint-schema/README.md | 30 +++++++++++-------- page-objects/patient/ProfilePage.ts | 2 +- tests/fixtures/base.ts | 15 ++++++---- tests/fixtures/network-helpers.ts | 8 +++--- tests/fixtures/patient-helpers.ts | 1 + utilities/test-runner.js | 7 +++-- utilities/upload-to-xray.js | 3 ++ 13 files changed, 139 insertions(+), 49 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index dd9d9a6..926015c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -290,4 +290,4 @@ jobs: workflows: commit-workflow: jobs: - - code-quality-check \ No newline at end of file + - code-quality-check diff --git a/CLAUDE.md b/CLAUDE.md index 85d7421..bea9571 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ This is a Playwright-based UI testing suite for Tidepool's web application, supp ## Essential Commands ### Testing Commands + - `npm test` - Run all tests on qa1 environment - `npm run test:qa2` - Run tests on qa2 environment - `npm run test:prd` - Run tests on production @@ -23,6 +24,7 @@ This is a Playwright-based UI testing suite for Tidepool's web application, supp - `TARGET_ENV=qa2 TEST_TAGS='@smoke @critical' npm test` - Run tests with environment and tags ### Code Quality Commands + - `npm run check` - Run both linting and TypeScript checking - `npm run lint` - Run ESLint on TypeScript files - `npm run lint:fix` - Run ESLint with auto-fix @@ -31,12 +33,14 @@ This is a Playwright-based UI testing suite for Tidepool's web application, supp - `npm run format` - Format code with Prettier ### Report Generation and Integration + - `npm run merge-reports` - Merge XML test reports from different test suites - `npm run upload-to-xray` - Upload test results to Xray (requires credentials) ## Architecture Overview ### Page Object Model Structure + The codebase follows the Page Object Model (POM) pattern with a clear separation: - **`page-objects/`** - Contains all page object classes @@ -46,24 +50,28 @@ The codebase follows the Page Object Model (POM) pattern with a clear separation - `components/` - Reusable UI components shared across pages ### Test Organization + - **`tests/fixtures/base.ts`** - Custom Playwright fixtures with enhanced logging, timing, and exception handling - **`tests/global-setup.ts`** - Pre-authenticates users and stores session state - **`tests/clinician/`** - Tests for clinician user flows - **`tests/patient/`** - Tests for patient user flows ### Environment Management + - **`utilities/env.ts`** - Centralized environment configuration using Zod validation - Supports environments: qa1, qa2, qa3, qa4, qa5, prd, int - Environment variables validated at startup - **`utilities/test-runner.js`** - Dynamic test execution with environment and tag filtering ### Key Configuration Files + - **`playwright.config.ts`** - Playwright configuration with dual project setup (local + BrowserStack), includes JSON and Xray reporters - **`tsconfig.json`** - TypeScript configuration with path mapping for imports - **`eslint.config.mjs`** - ESLint configuration using Airbnb Extended rules, includes test automation exceptions - **`.circleci/config.yml`** - CI/CD pipeline with dynamic environment and tag support ### Test Result Reporting + - **JSON Reporter**: Generates `test-results/last-run.json` with rich test data - **Xray Integration**: `utilities/xray-json-reporter.ts` uploads test results with step-by-step evidence - **HTML Reports**: Interactive reports in `playwright-report/` @@ -72,31 +80,39 @@ The codebase follows the Page Object Model (POM) pattern with a clear separation ## Project-Specific Patterns ### Authentication Strategy + - Global setup pre-authenticates both patient and clinician users - Session state stored in `tests/.auth/` directory - Separate projects for patient vs clinician test isolation ### Path Aliases + Use these import aliases defined in tsconfig.json: + - `@pom/*` - Page objects (e.g., `@pom/LoginPage`) - `@components/*` - UI components - `@fixtures/*` - Test fixtures ### Custom Test Fixtures + The project includes enhanced fixtures in `tests/fixtures/base.ts`: + - `timeLogger` - Logs test start/end times - `stepTimer` - Times individual test steps - `exceptionLogger` - Captures and reports frontend exceptions ### BrowserStack Integration + Tests automatically detect BrowserStack environment variables and switch between local Chrome and cloud testing. BrowserStack projects are conditionally added based on credential availability. ### Test Data Management + - Patient/clinician credentials managed via environment variables - Dynamic test data generation (e.g., timestamps) to avoid test conflicts - Environment-specific URL mapping ### Test Tagging System + - **`tests/fixtures/test-tags.ts`** - Comprehensive tag system with validation - **Required Categories**: User Types (@patient, @clinician), Test Types (@api, @ui, @smoke), Priorities (@critical, @high, @medium, @low) - **Tag Filtering**: Supports AND logic (space-separated) and OR logic (comma-separated) @@ -105,6 +121,7 @@ Tests automatically detect BrowserStack environment variables and switch between ## Development Notes ### Adding New Tests + 1. Create test files in appropriate directory (`tests/clinician/`, `tests/patient/`, `tests/claimed/`, `tests/personal/`) 2. Import custom fixtures: `import { expect, test } from '@fixtures/base'` 3. Use page objects with path aliases: `import LoginPage from '@pom/LoginPage'` @@ -113,13 +130,16 @@ Tests automatically detect BrowserStack environment variables and switch between 6. Use project-specific imports for specialized fixtures (e.g., `network-helpers`, `patient-helpers`) ### Creating Page Objects + 1. Extend the pattern established in existing page objects 2. Use semantic locators (`getByRole`, `getByText`) over CSS selectors 3. Include JSDoc comments for public methods 4. Add `name` property for step decorator context ### Environment Setup + Required environment variables: + - `PATIENT_USERNAME` / `PATIENT_PASSWORD` - `CLINICIAN_USERNAME` / `CLINICIAN_PASSWORD` - `TARGET_ENV` (qa1, qa2, qa3, qa4, qa5, prd, int) @@ -128,8 +148,10 @@ Required environment variables: - Optional: `TEST_TAGS` (for filtering tests by tags) ### Project Structure Understanding + The test suite is organized by user authentication state: + - **`tests/personal/`** - Tests for personal (individual) patient accounts -- **`tests/claimed/`** - Tests for claimed patient accounts (connected to clinicians) +- **`tests/claimed/`** - Tests for claimed patient accounts (connected to clinicians) - **`tests/clinician/`** - Tests for clinician user flows -Each directory has separate authentication setup and isolated test execution. \ No newline at end of file + Each directory has separate authentication setup and isolated test execution. diff --git a/Prompts/CLINICIAN_NAVIGATION_SUMMARY.md b/Prompts/CLINICIAN_NAVIGATION_SUMMARY.md index 2389ba5..0e5f619 100644 --- a/Prompts/CLINICIAN_NAVIGATION_SUMMARY.md +++ b/Prompts/CLINICIAN_NAVIGATION_SUMMARY.md @@ -1,18 +1,20 @@ # Clinician Navigation Framework - Proper Page Object Implementation ## Overview + Successfully implemented a proper clinician navigation framework that correctly follows the PatientNavigation format with all test logic separated into fixtures. ## āœ… Proper Page Object Structure ### ClinicianNavigation.ts - Page Object Only + ```typescript // Location: /page-objects/clinician/ClinicianNavigation.ts export default class ClinicianNav { readonly page: Page; readonly workspaces: Record; readonly pages: Record; - + constructor(page: Page) { // Only locator definitions - NO test logic this.workspaces = { ... }; @@ -22,6 +24,7 @@ export default class ClinicianNav { ``` ### Clinic-Helpers.ts - Test Logic & Methods + ```typescript // Location: /tests/fixtures/clinic-helpers.ts export const test = base.extend({ @@ -38,11 +41,13 @@ export const test = base.extend({ ## šŸ—ļø Architecture ### Page Objects Define ONLY: + - āœ… Locators (`link`, `verifyElement`) - āœ… Configuration (`name`, `verifyURL`) - āœ… Type definitions (`WorkspaceKey`, `PageKey`) ### Fixtures Handle ONLY: + - āœ… Test logic (`click`, `expect`, `console.log`) - āœ… Navigation methods (`navigateToWorkspace`) - āœ… Multi-workspace execution (`executeAcrossWorkspaces`) @@ -50,8 +55,9 @@ export const test = base.extend({ ## šŸŽÆ Available Hardcoded Workspaces ### Workspace Keys (Type-Safe): + ```typescript -type WorkspaceKey = +type WorkspaceKey = | 'AdminClinicBase' | 'AdminClinicEnterprise' | 'MemberClinicBase' @@ -63,6 +69,7 @@ type WorkspaceKey = ``` ### Workspace Configuration: + ```typescript AdminClinicBase: { name: 'Admin Clinic (Base)', @@ -75,6 +82,7 @@ AdminClinicBase: { ## āœ… Working Test Examples ### Single Workspace Navigation: + ```typescript test('should navigate to specific workspace', async ({ clinic }) => { await clinic.navigateToWorkspace('AdminClinicBase'); @@ -83,14 +91,15 @@ test('should navigate to specific workspace', async ({ clinic }) => { ``` ### Multi-Workspace Testing: + ```typescript test('should test across multiple workspaces', async ({ clinic }) => { const workspaces = [ { workspaceKey: 'AdminClinicBase' as const }, - { workspaceKey: 'MemberClinicEnterprise' as const } + { workspaceKey: 'MemberClinicEnterprise' as const }, ]; - await clinic.executeAcrossWorkspaces(workspaces, async (config) => { + await clinic.executeAcrossWorkspaces(workspaces, async config => { console.log(`Testing workspace: ${config.workspaceKey}`); // Your test logic here }); @@ -100,19 +109,20 @@ test('should test across multiple workspaces', async ({ clinic }) => { ## šŸŽÆ Ready for Profile API Implementation ### Template Structure: + ```typescript test('should validate clinician profile API across workspaces', async ({ clinic }) => { const targetWorkspaces = [ { workspaceKey: 'AdminClinicBase' as const }, - { workspaceKey: 'AdminClinicEnterprise' as const } + { workspaceKey: 'AdminClinicEnterprise' as const }, ]; - await clinic.executeAcrossWorkspaces(targetWorkspaces, async (config) => { + await clinic.executeAcrossWorkspaces(targetWorkspaces, async config => { // 1. Navigate to profile page within workspace await clinic.navigateToPage('Profile'); - + // 2. Capture GET request for profile data - // 3. Edit profile fields (not email) + // 3. Edit profile fields (not email) // 4. Submit profile changes // 5. Capture PUT request for profile updates // 6. Validate API responses @@ -143,21 +153,25 @@ tests/ ## šŸš€ Benefits Achieved ### 1. **Proper Separation of Concerns** + - Page objects = Pure locator definitions - Fixtures = Test logic and execution - Matches existing PatientNavigation pattern ### 2. **Easy Maintenance** + - Update locators in one place (ClinicianNavigation.ts) - Update test logic in one place (clinic-helpers.ts) - Type-safe workspace keys prevent errors ### 3. **Consistent Testing** + - Hardcoded workspace configurations ensure repeatability - executeAcrossWorkspaces() enables systematic multi-workspace testing - URL verification provides reliable workspace confirmation ## āœ… All Tests Passing + - āœ… workspace-navigation-simple.spec.ts (3/3 tests) - āœ… Multi-workspace navigation working - āœ… URL verification with correct `clinic-workspace` pattern diff --git a/Prompts/ENDPOINT_SCALABILITY_DEMO.md b/Prompts/ENDPOINT_SCALABILITY_DEMO.md index 1a1b0bc..5ade5e8 100644 --- a/Prompts/ENDPOINT_SCALABILITY_DEMO.md +++ b/Prompts/ENDPOINT_SCALABILITY_DEMO.md @@ -1,11 +1,13 @@ # Scalable Network Helpers - Endpoint-Driven API Validation ## Overview + The network helpers now use a scalable endpoint-driven approach instead of hardcoded functions. This allows validation of any API endpoint defined in the endpoint-schema folder. ## Before vs After ### Before (Hardcoded Functions) + ```typescript // Hardcoded profile-specific functions await api.validateProfileGetResponse(saveToPath); @@ -13,6 +15,7 @@ await api.validateProfilePutResponse(saveToPath); ``` ### After (Endpoint-Driven Approach) + ```typescript // Generic function that works with any endpoint in the registry await api.validateEndpointResponse('profile-metadata-get', saveToPath); @@ -22,7 +25,9 @@ await api.validateEndpointResponse('profile-metadata-put', saveToPath); ## Architecture ### 1. Endpoint Schema Pattern + Each API endpoint is defined with a schema in `/endpoint-schema/`: + ```typescript // profile-endpoints.ts export const getProfileMetadataSchema: EndpointSchema = { @@ -33,7 +38,9 @@ export const getProfileMetadataSchema: EndpointSchema = { ``` ### 2. Centralized Registry + All endpoints are registered in `/endpoint-schema/endpoint-registry.ts`: + ```typescript export const ENDPOINT_REGISTRY = { 'profile-metadata-get': getProfileMetadataSchema, @@ -43,16 +50,18 @@ export const ENDPOINT_REGISTRY = { ``` ### 3. Generic Validation Function + The network helpers use the registry to validate any endpoint: + ```typescript async validateEndpointResponse(endpointName: EndpointName, saveToPath?: string): Promise { const schema = getEndpointSchema(endpointName); const request = this.getLatestCaptureMatching(schema.method, schema.url as RegExp); - + if (request?.responseBody && saveToPath) { await this.saveApiResponse(request.responseBody, request.url, schema.method, saveToPath); } - + return request; } ``` @@ -60,23 +69,28 @@ async validateEndpointResponse(endpointName: EndpointName, saveToPath?: string): ## Benefits ### 1. Scalability + - Add new endpoints by simply defining them in endpoint-schema folder - No need to create new hardcoded functions for each endpoint - Consistent validation pattern across all API endpoints ### 2. Type Safety + ```typescript // TypeScript ensures only valid endpoint names can be used type EndpointName = keyof typeof ENDPOINT_REGISTRY; ``` ### 3. Maintainability + - Single place to define endpoint specifications - DRY principle - no duplicated validation logic - Easy to update endpoint definitions without touching test code ### 4. Future Extensibility + Easy to add new endpoints by following the pattern: + 1. Define schema in appropriate endpoint file 2. Add to endpoint registry 3. Use in tests with `api.validateEndpointResponse('new-endpoint-name')` @@ -87,7 +101,7 @@ Easy to add new endpoints by following the pattern: // Validate any GET endpoint await api.validateEndpointResponse('profile-metadata-get', './responses/profile-get.json'); -// Validate any PUT endpoint +// Validate any PUT endpoint await api.validateEndpointResponse('profile-metadata-put', './responses/profile-put.json'); // Future: Easy to add more endpoints @@ -96,7 +110,9 @@ await api.validateEndpointResponse('auth-token-post', './responses/auth-token.js ``` ## Migration + The old hardcoded functions are still available but marked as deprecated: + ```typescript /** * @deprecated Use validateEndpointResponse('profile-metadata-get', saveToPath) instead diff --git a/Prompts/test-scribe.md b/Prompts/test-scribe.md index f7194e1..42a381f 100644 --- a/Prompts/test-scribe.md +++ b/Prompts/test-scribe.md @@ -6,5 +6,5 @@ Only after all steps are completed, emit a Playwright TypeScript test that uses Save generated test file in the tests directory Execute the test file and iterate until the test passes Make sure to store direct page object information like hard coded locators and urls are stored within the appropriate 'page' or 'navigation' script in the page-objects folder -contain all logic for checks within appropriately named helper scripts in the tests/fixtures folder. -Tests shoudld be made in the patient or clinician folders depending on the current login being used \ No newline at end of file +contain all logic for checks within appropriately named helper scripts in the tests/fixtures folder. +Tests shoudld be made in the patient or clinician folders depending on the current login being used diff --git a/docs/XRAY_INTEGRATION.md b/docs/XRAY_INTEGRATION.md index 706224c..92033c5 100644 --- a/docs/XRAY_INTEGRATION.md +++ b/docs/XRAY_INTEGRATION.md @@ -1,11 +1,13 @@ # Xray Integration Documentation ## Overview + This project uses a unified JSON-based Xray integration that captures rich test data from Playwright and uploads it to Xray with step-by-step evidence including screenshots, videos, and test annotations. ## Architecture ### 1. **Playwright Configuration** (`playwright.config.ts`) + - **JSON Reporter**: Generates `test-results/last-run.json` with complete test data - **Xray JSON Reporter**: Custom reporter that automatically uploads to Xray - **Legacy XML Reporter**: Still available for backward compatibility @@ -20,7 +22,9 @@ reporter: [ ``` ### 2. **Xray JSON Reporter** (`utilities/xray-json-reporter.ts`) + **Features:** + - Maps Playwright test steps to Xray test steps with individual evidence - Attaches screenshots per step (e.g., `step-01-given-clinician-has-been-logged-in.png`) - Includes test tags, annotations, and custom properties @@ -28,18 +32,22 @@ reporter: [ - Supports test execution key parameter for linking to existing test executions **Data Mapping:** + - **Test Steps**: Extracts from `Step Duration:` annotations - **Evidence**: Screenshots, videos, JSON responses per step - **Status**: Pass/Fail/Pending with detailed failure messages - **Metadata**: Environment, build info, test tags ### 3. **CircleCI Integration** (`.circleci/config.yml`) + **Simplified Workflow:** + 1. Run tests → Generate `test-results/last-run.json` 2. Build TypeScript utilities 3. Upload to Xray using `node utilities/upload-to-xray.js` **Environment Variables:** + - `TEST_EXECUTION_KEY`: Links results to existing Xray test execution - `XRAY_CLIENT_ID`: Xray API authentication - `XRAY_CLIENT_SECRET`: Xray API authentication @@ -48,6 +56,7 @@ reporter: [ ## Usage ### Local Development + ```bash # Run tests and auto-upload to Xray (if credentials available) npm test @@ -60,13 +69,17 @@ npm run build ``` ### CI/CD Pipeline + Tests automatically upload to Xray when: + - `XRAY_CLIENT_ID` and `XRAY_CLIENT_SECRET` are available - `TEST_EXECUTION_KEY` parameter is provided - JSON results file exists ### Test Tagging + Use test tags to organize and filter results in Xray: + ```typescript { tag: createValidatedTags([ @@ -81,6 +94,7 @@ Use test tags to organize and filter results in Xray: ## Xray JSON Format ### Test Execution Structure + ```json { "info": { @@ -95,6 +109,7 @@ Use test tags to organize and filter results in Xray: ``` ### Individual Test Structure + ```json { "testInfo": { @@ -130,24 +145,27 @@ Use test tags to organize and filter results in Xray: ## Benefits Over Legacy XML -| Feature | XML (Legacy) | JSON (New) | -|---------|-------------|------------| -| Test Steps | āŒ Basic only | āœ… Full step breakdown | -| Screenshots | āŒ Separate API calls | āœ… Embedded per step | -| Videos | āŒ Not supported | āœ… Embedded evidence | -| Custom Properties | āŒ Limited | āœ… Rich metadata | -| Test Tags | āŒ Basic | āœ… Full tag system | -| Debugging Info | āŒ Minimal | āœ… Comprehensive | +| Feature | XML (Legacy) | JSON (New) | +| ----------------- | --------------------- | ---------------------- | +| Test Steps | āŒ Basic only | āœ… Full step breakdown | +| Screenshots | āŒ Separate API calls | āœ… Embedded per step | +| Videos | āŒ Not supported | āœ… Embedded evidence | +| Custom Properties | āŒ Limited | āœ… Rich metadata | +| Test Tags | āŒ Basic | āœ… Full tag system | +| Debugging Info | āŒ Minimal | āœ… Comprehensive | ## Migration Notes ### Current State + - **JSON**: Primary integration with rich evidence - **XML**: Available for backward compatibility - **Duplicate Steps**: Removed from CircleCI ### Future Cleanup + Once fully validated, remove: + - `xrayOptions` configuration in `playwright.config.ts` - `['junit', xrayOptions]` reporter - Legacy `utilities/xray-reporter.ts` file @@ -155,12 +173,14 @@ Once fully validated, remove: ## Troubleshooting ### Common Issues + 1. **Missing JSON file**: Ensure `json` reporter is enabled in Playwright config 2. **Upload failures**: Check Xray credentials and network connectivity 3. **Step evidence missing**: Verify step naming conventions in test annotations 4. **TypeScript compilation**: Run `npm run build` before upload ### Debug Information + - Generated JSON saved to `test-results/xray-execution.json` - Full logs available in CircleCI build output -- Test step timing and evidence captured in annotations \ No newline at end of file +- Test step timing and evidence captured in annotations diff --git a/endpoint-schema/README.md b/endpoint-schema/README.md index b030ed5..0c6c824 100644 --- a/endpoint-schema/README.md +++ b/endpoint-schema/README.md @@ -22,6 +22,7 @@ tests/ ### 1. Network Helper (`tests/fixtures/network-helpers.ts`) The `NetworkHelper` class provides: + - **Request/Response Capture**: Automatically intercepts and captures all network traffic - **Schema Validation**: Validates API responses against predefined schemas - **Filtering**: Filter captures by URL patterns, HTTP methods, etc. @@ -31,6 +32,7 @@ The `NetworkHelper` class provides: ### 2. Endpoint Schemas (`endpoint-schema/`) Schema files define the expected structure of API endpoints: + - **URL Patterns**: Regular expressions to match API endpoints - **HTTP Methods**: Expected HTTP methods (GET, POST, PUT, DELETE) - **Status Codes**: Expected response status codes @@ -39,6 +41,7 @@ Schema files define the expected structure of API endpoints: ### 3. Test Implementation Tests can: + - Capture all network traffic during user interactions - Validate specific API calls against schemas - Assert on response data and structure @@ -53,19 +56,19 @@ import { getUserProfileSchema } from '../../../endpoint-schema/profile-endpoints test('should validate profile API', async ({ page }) => { const networkHelper = createNetworkHelper(page); - + // Register schemas networkHelper.registerSchema('getUserProfile', getUserProfileSchema); - + // Start capturing await networkHelper.startCapture(); - + // Perform user actions await test.patient.navigateTo('Profile', page); - + // Validate API calls await networkHelper.validateCapture('profileRequest', 'getUserProfile'); - + // Stop capturing await networkHelper.stopCapture(); }); @@ -82,6 +85,7 @@ test('should validate profile API', async ({ page }) => { ## Schema Definition Examples ### Profile GET Endpoint + ```typescript export const getUserProfileSchema: EndpointSchema = { url: /\/v1\/users\/[^\/]+$/, @@ -92,13 +96,14 @@ export const getUserProfileSchema: EndpointSchema = { username: 'string', profile: { fullName: 'string', - patient: 'object' - } - } + patient: 'object', + }, + }, }; ``` ### Profile Update Endpoint + ```typescript export const updateUserProfileSchema: EndpointSchema = { url: /\/v1\/users\/[^\/]+$/, @@ -107,13 +112,13 @@ export const updateUserProfileSchema: EndpointSchema = { requestSchema: { profile: { fullName: 'string', - patient: 'object' - } + patient: 'object', + }, }, responseSchema: { userid: 'string', - profile: 'object' - } + profile: 'object', + }, }; ``` @@ -129,6 +134,7 @@ export const updateUserProfileSchema: EndpointSchema = { - `clearCaptures()`: Clear all captured data This structure makes it easy to: + - Add new endpoint schemas as the API evolves - Create comprehensive API validation tests - Debug network-related issues diff --git a/page-objects/patient/ProfilePage.ts b/page-objects/patient/ProfilePage.ts index 6e9413b..e2971c3 100644 --- a/page-objects/patient/ProfilePage.ts +++ b/page-objects/patient/ProfilePage.ts @@ -45,7 +45,7 @@ export class ProfilePage { const options = await diagnosisCombo.locator('option').all(); // Find current index by checking option values - for (let i = 0; i < options.length; i++) { + for (let i = 0; i < options.length; i += 1) { const optionValue = await options[i].getAttribute('value'); if (optionValue === currentValue) { return i; diff --git a/tests/fixtures/base.ts b/tests/fixtures/base.ts index e052842..e7a5885 100644 --- a/tests/fixtures/base.ts +++ b/tests/fixtures/base.ts @@ -143,9 +143,12 @@ export const test: TestType< let currentStepName = ''; // Make step counter accessible globally for network helper - (globalThis as any).__stepCounter = { + (globalThis as any).stepCounter = { get: () => stepCounter, - increment: () => ++stepCounter, + increment: () => { + stepCounter += 1; + return stepCounter; + }, getDirectory: () => screenshotDir, getCurrentStepName: () => currentStepName, setCurrentStepName: (name: string) => { @@ -169,7 +172,7 @@ export const test: TestType< ) { return originalStep.call(this, name, async (stepInfo: TestStepInfo) => { // Set current step name for network helpers (clean name without [no-screenshot]) - const stepCounterObj = (globalThis as any).__stepCounter; + const stepCounterObj = (globalThis as any).stepCounter; if (stepCounterObj) { const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); stepCounterObj.setCurrentStepName(cleanName); @@ -209,7 +212,9 @@ export const test: TestType< await fs.promises.writeFile(screenshotPath, screenshot); } } - } catch (error) {} + } catch (error) { + // Screenshot capture failed, continue without screenshot + } return result; }); @@ -231,7 +236,7 @@ export const test: TestType< ) { return originalStep.call(this, name, async (stepInfo: TestStepInfo) => { // Set current step name for network helpers (clean name) - const stepCounterObj = (globalThis as any).__stepCounter; + const stepCounterObj = (globalThis as any).stepCounter; if (stepCounterObj) { stepCounterObj.setCurrentStepName(name); } diff --git a/tests/fixtures/network-helpers.ts b/tests/fixtures/network-helpers.ts index 953af7c..fe9dee7 100644 --- a/tests/fixtures/network-helpers.ts +++ b/tests/fixtures/network-helpers.ts @@ -283,7 +283,7 @@ export class NetworkHelper { if (request?.responseBody) { // Access the shared step counter from the stepScreenshoter fixture - const stepCounterObj = (globalThis as any).__stepCounter; + const stepCounterObj = (globalThis as any).stepCounter; if (stepCounterObj) { const stepNumber = stepCounterObj.increment(); const currentStepName = stepCounterObj.getCurrentStepName(); @@ -446,8 +446,8 @@ export class NetworkHelper { * @param path - The dot-notation path (e.g., 'patient.birthday') * @returns The value at the path or undefined */ - private getNestedValue(obj: any, path: string): any { - return path.split('.').reduce((current, key) => current?.[key], obj); + private getNestedValue(obj: any, propertyPath: string): any { + return propertyPath.split('.').reduce((current, key) => current?.[key], obj); } /** @@ -544,7 +544,7 @@ export class NetworkHelper { } // Generate comparison JSON file similar to validateEndpointResponse - const stepCounterObj = (globalThis as any).__stepCounter; + const stepCounterObj = (globalThis as any).stepCounter; if (stepCounterObj) { // Increment for JSON file naming (this is correct behavior) const stepNumber = stepCounterObj.increment(); diff --git a/tests/fixtures/patient-helpers.ts b/tests/fixtures/patient-helpers.ts index 865eb81..5971ac1 100644 --- a/tests/fixtures/patient-helpers.ts +++ b/tests/fixtures/patient-helpers.ts @@ -392,6 +392,7 @@ async function executeNavigationStrategy(state: NavigationState): Promise { console.error(`āŒ Failed to start Playwright: ${error.message}`); - process.exit(1); + throw new Error(`Failed to start Playwright: ${error.message}`); }); playwrightProcess.on('close', code => { const emoji = code === 0 ? 'āœ…' : 'āŒ'; console.log(`${emoji} Playwright tests completed with exit code: ${code}`); - process.exit(code); + if (code !== 0) { + throw new Error(`Playwright tests failed with exit code: ${code}`); + } }); // Handle graceful shutdown diff --git a/utilities/upload-to-xray.js b/utilities/upload-to-xray.js index 61a923b..f635b1e 100644 --- a/utilities/upload-to-xray.js +++ b/utilities/upload-to-xray.js @@ -10,12 +10,14 @@ const path = require('node:path'); async function uploadResults() { try { // Import compiled CommonJS module + // eslint-disable-next-line n/global-require, import-x/extensions const XrayJsonReporter = require('../build/utilities/xray-json-reporter.js').default; const jsonPath = process.argv[2] || 'test-results/last-run.json'; if (!fs.existsSync(jsonPath)) { console.error(`āŒ JSON results file not found: ${jsonPath}`); + // eslint-disable-next-line n/no-process-exit process.exit(1); } @@ -27,6 +29,7 @@ async function uploadResults() { console.log('āœ… Xray upload completed successfully'); } catch (error) { console.error('āŒ Failed to upload to Xray:', error); + // eslint-disable-next-line n/no-process-exit process.exit(1); } } From a7118e8459882aa2ababd166e47bd23d4f4b6ddf Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 25 Aug 2025 16:02:15 -0400 Subject: [PATCH 07/60] let's actually test on ci --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 926015c..1e5d8bb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -291,3 +291,4 @@ workflows: commit-workflow: jobs: - code-quality-check + - test From 070a25ca13f465f1ef25b61f9b5d445f07c42b8b Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 25 Aug 2025 16:14:02 -0400 Subject: [PATCH 08/60] Remove test evidence steps from CircleCI config Eliminated steps for gathering and sending test evidence in the CircleCI pipeline. This streamlines the job by removing calls to browserstackEvidenceDownload.js and sendTestEvidenceToJira.js. --- .circleci/config.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1e5d8bb..fed1c8f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -266,12 +266,8 @@ jobs: - unless: condition: and: - - equal: ['testParallel', << pipeline.parameters.testEnvironment >>] + - equal: [<< pipeline.parameters.testEnvironment >>] steps: - - run: - name: Gather Test Evidence - command: node utilities/browserstackEvidenceDownload.js - when: always - run: name: Build TypeScript utilities command: npm run build @@ -282,10 +278,6 @@ jobs: when: always environment: TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> - - run: - name: Add Test Evidence to JIRA - command: node utilities/sendTestEvidenceToJira.js - when: always workflows: commit-workflow: From 5cfc4b3ec8f4381db9cff6aed37dc7b08b021ff2 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 25 Aug 2025 16:28:35 -0400 Subject: [PATCH 09/60] Send Slack notifications only from first parallel node Updated CircleCI config to ensure Slack notifications are sent only from the first parallel node to avoid duplicate messages. Also adjusted Playwright test shard indexing and removed the JIRA test evidence step. --- .circleci/config.yml | 210 ++++++++++++++++++++++--------------------- 1 file changed, 107 insertions(+), 103 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index fed1c8f..e213199 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -64,7 +64,7 @@ jobs: # Run tests with parallel execution - run: name: Run Playwright Tests - command: npm run test --shard=$CIRCLE_NODE_INDEX/$CIRCLE_NODE_TOTAL + command: npm run test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL # Store test results and artifacts - store_artifacts: @@ -73,69 +73,73 @@ jobs: path: test-results - store_test_results: path: test-output/test-results.xml - # Main and Develop branch notifications - always notify with branch name - - slack/notify: - event: fail - branch_pattern: main - mentions: '<@UG56AQFK2>' - custom: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":x: *Tidepool Web UI Tests Failed on Main Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + # Only send notifications from the first node to avoid duplicates + - when: + condition: + equal: ["0", "${CIRCLE_NODE_INDEX}"] + steps: + - slack/notify: + event: fail + branch_pattern: main + mentions: '<@UG56AQFK2>' + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":x: *Tidepool Web UI Tests Failed on Main Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + } + } + ] } - } - ] - } - - slack/notify: - event: pass - branch_pattern: main - custom: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":white_check_mark: *Tidepool Web UI Tests Passed on Main Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + - slack/notify: + event: pass + branch_pattern: main + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":white_check_mark: *Tidepool Web UI Tests Passed on Main Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + } + } + ] } - } - ] - } - - slack/notify: - event: fail - branch_pattern: develop - mentions: '<@UG56AQFK2>' - custom: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":x: *Tidepool Web UI Tests Failed on Develop Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + - slack/notify: + event: fail + branch_pattern: develop + mentions: '<@UG56AQFK2>' + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":x: *Tidepool Web UI Tests Failed on Develop Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + } + } + ] } - } - ] - } - - slack/notify: - event: pass - branch_pattern: develop - custom: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":white_check_mark: *Tidepool Web UI Tests Passed on Develop Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + - slack/notify: + event: pass + branch_pattern: develop + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":white_check_mark: *Tidepool Web UI Tests Passed on Develop Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + } + } + ] } - } - ] - } - unless: condition: and: @@ -155,10 +159,6 @@ jobs: when: always environment: TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> - - run: - name: Add Test Evidence to JIRA - command: node utilities/sendTestEvidenceToJira.js - when: always scheduled-test: working_directory: ~/tidepool-org/webuitests @@ -191,7 +191,7 @@ jobs: # Run tests with parallel execution - run: name: Run Playwright Tests - command: npm run test --shard=$CIRCLE_NODE_INDEX/$CIRCLE_NODE_TOTAL + command: npm run test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL # Store test results and artifacts - store_artifacts: @@ -222,46 +222,50 @@ jobs: echo "export TIME=$TIME" >> $BASH_ENV when: always - # Detailed Slack notifications for scheduled runs - - slack/notify: - event: fail - mentions: '<@UG56AQFK2>' - custom: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*:x: Scheduled Tidepool Web UI Tests Failed* :sad_tapani: \n\n :pencil:*Summary* \n\n *$PASSED_TESTS/$TOTAL_TESTS* tests passed \n *$FAILURES* tests failed \n Total time: *$TIME* seconds" - } - }, - { - "type": "divider" - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":link: *<${CIRCLE_BUILD_URL}|View CircleCI Build Details>*" + # Only send notifications from the first node to avoid duplicates + - when: + condition: + equal: ["0", "${CIRCLE_NODE_INDEX}"] + steps: + - slack/notify: + event: fail + mentions: '<@UG56AQFK2>' + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*:x: Scheduled Tidepool Web UI Tests Failed* :sad_tapani: \n\n :pencil:*Summary* \n\n *$PASSED_TESTS/$TOTAL_TESTS* tests passed \n *$FAILURES* tests failed \n Total time: *$TIME* seconds" + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":link: *<${CIRCLE_BUILD_URL}|View CircleCI Build Details>*" + } + } + ] } - } - ] - } - - slack/notify: - event: pass - custom: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*:white_check_mark: Scheduled Tidepool Web UI Tests Passed!* :catjam: \n\n :pencil:*Summary:* \n *$PASSED_TESTS/$TOTAL_TESTS* tests passed \n Total time: *$TIME* seconds \n\n :link: *<${CIRCLE_BUILD_URL}|View CircleCI Build Details>*" + - slack/notify: + event: pass + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*:white_check_mark: Scheduled Tidepool Web UI Tests Passed!* :catjam: \n\n :pencil:*Summary:* \n *$PASSED_TESTS/$TOTAL_TESTS* tests passed \n Total time: *$TIME* seconds \n\n :link: *<${CIRCLE_BUILD_URL}|View CircleCI Build Details>*" + } + } + ] } - } - ] - } - unless: condition: From e8bf054a2da40f932e1526f0b456b886bac31372 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Tue, 10 Feb 2026 19:29:40 -0500 Subject: [PATCH 10/60] Improve Xray integration and CI pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add configurable Xray project key and modernize test-to-Xray upload flow. Key changes: - CircleCI: add xrayProjectKey pipeline parameter and propagate XRAY_PROJECT_KEY; simplify test run command and remove duplicated CI upload steps to rely on reporter. - Playwright config: remove legacy JUnit XML reporter and rely on JSON + custom Xray JSON reporter. - Xray reporter: major enhancements in utilities/xray-json-reporter.js — evidence classification (inline vs deferred vs skip), file-size threshold, video handling (only for failed tests), deferred-evidence plumbing (GraphQL upload stub), improved authentication/error messages, project key usage, and logging/stats. Reporter only attempts upload when credentials/execution key are configured. - Environment schema: default XRAY_PROJECT_KEY and XRAY_EVIDENCE_SIZE_THRESHOLD_KB added; optional JIRA fields added. - Utilities: add Xray types and JSON schema files; remove legacy xray-reporter and upload-to-xray scripts; include GraphQL evidence client hook. - Tests & fixtures: adopt validated tag usage in tests, fix step counter API (globalThis.stepCounter), rename/getNestedValue param, small bug fixes (loop increment, continue lint suppression), and other stability improvements in clinic/patient/network helpers. - Docs: update XRAY_INTEGRATION.md and CLAUDE.md to document new behavior, env vars, and usage examples. Also includes various build/test wiring updates and minor fixes to make the new reporter and CI flow work end-to-end. --- .circleci/config.yml | 74 +- CLAUDE.md | 48 +- build/page-objects/patient/ProfilePage.js | 2 +- build/playwright.config.js | 8 - build/tests/fixtures/base.js | 15 +- build/tests/fixtures/clinic-helpers.js | 72 +- build/tests/fixtures/network-helpers.js | 8 +- build/tests/fixtures/patient-helpers.js | 1 + build/tests/global-setup.js | 2 +- build/tests/personal/login.spec.js | 37 +- build/utilities/env.js | 4 + build/utilities/xray-json-reporter.js | 286 ++- docs/XRAY_INTEGRATION.md | 237 ++- package-lock.json | 1923 ++++++++++----------- package.json | 27 +- playwright.config.ts | 9 - tests/global-setup.ts | 74 +- tests/personal/login.spec.ts | 185 +- utilities/env.ts | 3 + utilities/test-runner.js | 13 +- utilities/upload-to-xray.js | 37 - utilities/xray-json-reporter.ts | 436 +++-- utilities/xray-json-schema.json | 392 +++++ utilities/xray-reporter.ts | 161 -- utilities/xray-types.ts | 100 ++ 25 files changed, 2400 insertions(+), 1754 deletions(-) delete mode 100644 utilities/upload-to-xray.js create mode 100644 utilities/xray-json-schema.json delete mode 100644 utilities/xray-reporter.ts create mode 100644 utilities/xray-types.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index e213199..6784f5f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,6 +13,9 @@ parameters: testTags: type: string default: '' + xrayProjectKey: + type: string + default: 'SAND' jobs: code-quality-check: working_directory: ~/tidepool-org/webuitests @@ -42,6 +45,7 @@ jobs: TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> TARGET_ENV: << pipeline.parameters.testEnvironment >> TEST_TAGS: << pipeline.parameters.testTags >> + XRAY_PROJECT_KEY: << pipeline.parameters.xrayProjectKey >> steps: - checkout - node/install @@ -62,18 +66,18 @@ jobs: command: npx playwright install --with-deps # Run tests with parallel execution + # TARGET_ENV and TEST_TAGS are already set as environment variables above - run: name: Run Playwright Tests - command: npm run test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL + command: npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL # Store test results and artifacts - store_artifacts: path: playwright-report - store_artifacts: path: test-results - - store_test_results: - path: test-output/test-results.xml - # Only send notifications from the first node to avoid duplicates + + # Only send notifications from the first node to avoid duplicates - when: condition: equal: ["0", "${CIRCLE_NODE_INDEX}"] @@ -140,25 +144,6 @@ jobs: } ] } - - unless: - condition: - and: - - equal: ['testParallel', << pipeline.parameters.testEnvironment >>] - steps: - - run: - name: Gather Test Evidence - command: node utilities/browserstackEvidenceDownload.js - when: always - - run: - name: Build TypeScript utilities - command: npm run build - when: always - - run: - name: Upload Results to Xray (JSON) - command: node utilities/upload-to-xray.js test-results/last-run.json - when: always - environment: - TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> scheduled-test: working_directory: ~/tidepool-org/webuitests @@ -169,6 +154,7 @@ jobs: TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> TARGET_ENV: << pipeline.parameters.testEnvironment >> TEST_TAGS: << pipeline.parameters.testTags >> + XRAY_PROJECT_KEY: << pipeline.parameters.xrayProjectKey >> steps: - checkout - node/install @@ -189,38 +175,16 @@ jobs: command: npx playwright install --with-deps # Run tests with parallel execution + # TARGET_ENV and TEST_TAGS are already set as environment variables above - run: name: Run Playwright Tests - command: npm run test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL + command: npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL # Store test results and artifacts - store_artifacts: path: playwright-report - store_artifacts: path: test-results - - store_test_results: - path: test-output/test-results.xml - - - run: - name: Install xmllint - command: apt-get update && apt-get install -y libxml2-utils - when: always - - # Parse test results for detailed Slack notification - - run: - name: Parse Test Results - command: | - TOTAL_TESTS=$(xmllint --xpath "string(/testsuites/@tests)" test-output/test-results.xml) - FAILURES=$(xmllint --xpath "string(/testsuites/@failures)" test-output/test-results.xml) - ERRORS=$(xmllint --xpath "string(/testsuites/@errors)" test-output/test-results.xml) - PASSED_TESTS=$((TOTAL_TESTS - FAILURES - ERRORS)) - TIME=$(xmllint --xpath "string(/testsuites/@time)" test-output/test-results.xml) - echo "export TOTAL_TESTS=$TOTAL_TESTS" >> $BASH_ENV - echo "export PASSED_TESTS=$PASSED_TESTS" >> $BASH_ENV - echo "export FAILURES=$FAILURES" >> $BASH_ENV - echo "export ERRORS=$ERRORS" >> $BASH_ENV - echo "export TIME=$TIME" >> $BASH_ENV - when: always # Only send notifications from the first node to avoid duplicates - when: @@ -267,22 +231,6 @@ jobs: ] } - - unless: - condition: - and: - - equal: [<< pipeline.parameters.testEnvironment >>] - steps: - - run: - name: Build TypeScript utilities - command: npm run build - when: always - - run: - name: Upload Results to Xray (JSON) - command: node utilities/upload-to-xray.js test-results/last-run.json - when: always - environment: - TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> - workflows: commit-workflow: jobs: diff --git a/CLAUDE.md b/CLAUDE.md index bea9571..a280f6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,18 +10,21 @@ This is a Playwright-based UI testing suite for Tidepool's web application, supp ### Testing Commands -- `npm test` - Run all tests on qa1 environment -- `npm run test:qa2` - Run tests on qa2 environment -- `npm run test:prd` - Run tests on production +- `npm test` - Run all tests (uses TARGET_ENV from .env file) - `npm run test:smoke` - Run only smoke tests - `npm run test:critical` - Run only critical tests - `npm run test:api` - Run only API tests - `npm run test:ui` - Run only UI tests - `npm run test:patient` - Run only patient tests - `npm run test:clinician` - Run only clinician tests +- `npm run test:regression` - Run only regression tests - `npm run debug` - Debug tests with Playwright's debug mode -- `playwright test tests/specific-test.spec.ts` - Run a single test file -- `TARGET_ENV=qa2 TEST_TAGS='@smoke @critical' npm test` - Run tests with environment and tags +- `npx playwright test tests/specific-test.spec.ts` - Run a single test file + +**Advanced Tag Filtering:** +- Combine tags with AND logic: `npx playwright test --grep "(?=.*@smoke)(?=.*@ui)"` +- Combine tags with OR logic: `npx playwright test --grep "@smoke|@critical"` +- Change environment: Set `TARGET_ENV` in your .env file or export it before running tests ### Code Quality Commands @@ -59,9 +62,10 @@ The codebase follows the Page Object Model (POM) pattern with a clear separation ### Environment Management - **`utilities/env.ts`** - Centralized environment configuration using Zod validation +- **`.env` file** - Local environment configuration (set TARGET_ENV and credentials) - Supports environments: qa1, qa2, qa3, qa4, qa5, prd, int -- Environment variables validated at startup -- **`utilities/test-runner.js`** - Dynamic test execution with environment and tag filtering +- Environment variables validated at startup via Zod schema +- CircleCI uses pipeline parameters to set environment variables ### Key Configuration Files @@ -73,9 +77,13 @@ The codebase follows the Page Object Model (POM) pattern with a clear separation ### Test Result Reporting - **JSON Reporter**: Generates `test-results/last-run.json` with rich test data -- **Xray Integration**: `utilities/xray-json-reporter.ts` uploads test results with step-by-step evidence +- **Xray Integration**: `utilities/xray-json-reporter.ts` uploads test results with intelligent evidence handling + - Videos only for failed tests (saves storage) + - Screenshots and JSON responses for all tests + - Configurable project key via `XRAY_PROJECT_KEY` (default: SAND) + - Step-level evidence properly mapped to test steps - **HTML Reports**: Interactive reports in `playwright-report/` -- **CircleCI Integration**: Automated test result submission to Xray using testExecKey parameter +- **CircleCI Integration**: Automated test result submission to Xray with configurable project key ## Project-Specific Patterns @@ -115,8 +123,11 @@ Tests automatically detect BrowserStack environment variables and switch between - **`tests/fixtures/test-tags.ts`** - Comprehensive tag system with validation - **Required Categories**: User Types (@patient, @clinician), Test Types (@api, @ui, @smoke), Priorities (@critical, @high, @medium, @low) -- **Tag Filtering**: Supports AND logic (space-separated) and OR logic (comma-separated) +- **Tag Filtering**: + - Space-separated tags = AND logic (test must have ALL tags): `TEST_TAGS='@smoke @ui'` + - Comma-separated tags = OR logic (test must have ANY tag): `TEST_TAGS='@smoke,@critical'` - **Dynamic Execution**: Use `TEST_TAGS` environment variable for selective test runs +- **Implementation**: Uses Playwright's `--grep` flag with regex patterns to filter tests by tag metadata ## Development Notes @@ -140,12 +151,19 @@ Tests automatically detect BrowserStack environment variables and switch between Required environment variables: -- `PATIENT_USERNAME` / `PATIENT_PASSWORD` -- `CLINICIAN_USERNAME` / `CLINICIAN_PASSWORD` +- `PERSONAL_USERNAME` / `PERSONAL_PASSWORD` - Personal patient account +- `CLAIMED_USERNAME` / `CLAIMED_PASSWORD` - Claimed patient account +- `SHARED_USERNAME` / `SHARED_PASSWORD` - Shared patient account +- `CLINICIAN_USERNAME` / `CLINICIAN_PASSWORD` - Clinician account - `TARGET_ENV` (qa1, qa2, qa3, qa4, qa5, prd, int) -- Optional: `BROWSERSTACK_USERNAME` / `BROWSERSTACK_ACCESS_KEY` -- Optional: `XRAY_CLIENT_ID` / `XRAY_CLIENT_SECRET` (for Xray integration) -- Optional: `TEST_TAGS` (for filtering tests by tags) +- Optional: `BROWSERSTACK_USERNAME` / `BROWSERSTACK_ACCESS_KEY` (for BrowserStack cloud testing) + +**Xray Integration (Optional):** +- `XRAY_CLIENT_ID` / `XRAY_CLIENT_SECRET` - Required for automatic Xray upload after test runs +- `XRAY_PROJECT_KEY` - Jira project key (default: SAND) +- `TEST_EXECUTION_KEY` - Link to existing Xray execution, or 'none' to auto-create + +**Note:** If `XRAY_CLIENT_ID` and `XRAY_CLIENT_SECRET` are not provided, the Xray reporter will silently skip upload and only generate local JSON reports. ### Project Structure Understanding diff --git a/build/page-objects/patient/ProfilePage.js b/build/page-objects/patient/ProfilePage.js index ef565d8..003f029 100644 --- a/build/page-objects/patient/ProfilePage.js +++ b/build/page-objects/patient/ProfilePage.js @@ -39,7 +39,7 @@ class ProfilePage { const currentValue = await diagnosisCombo.inputValue(); const options = await diagnosisCombo.locator('option').all(); // Find current index by checking option values - for (let i = 0; i < options.length; i++) { + for (let i = 0; i < options.length; i += 1) { const optionValue = await options[i].getAttribute('value'); if (optionValue === currentValue) { return i; diff --git a/build/playwright.config.js b/build/playwright.config.js index 57baf55..d6b290c 100644 --- a/build/playwright.config.js +++ b/build/playwright.config.js @@ -6,13 +6,6 @@ Object.defineProperty(exports, "__esModule", { value: true }); const test_1 = require("@playwright/test"); const node_path_1 = __importDefault(require("node:path")); const env_1 = __importDefault(require("./utilities/env")); -// Legacy XML options - can be removed when fully migrated to JSON -const xrayOptions = { - embedAnnotationsAsProperties: true, - textContentAnnotations: ['test_description', 'testrun_comment'], - embedAttachmentsAsProperty: 'testrun_evidence', - outputFile: 'test-output/test-results.xml', -}; // Helper to detect BrowserStack run const isBrowserStack = Boolean(process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESS_KEY); function buildBrowserStackEndpoint(testName) { @@ -43,7 +36,6 @@ exports.default = (0, test_1.defineConfig)({ reporter: [ ['html', { open: 'never', outputFolder: 'playwright-report' }], ['json', { outputFile: 'test-results/last-run.json' }], - ['junit', xrayOptions], ['./utilities/xray-json-reporter.ts'], ], use: { diff --git a/build/tests/fixtures/base.js b/build/tests/fixtures/base.js index b21e7bc..2c7e91d 100644 --- a/build/tests/fixtures/base.js +++ b/build/tests/fixtures/base.js @@ -130,9 +130,12 @@ exports.test = test_1.test.extend({ // Store current step name for network helpers let currentStepName = ''; // Make step counter accessible globally for network helper - globalThis.__stepCounter = { + globalThis.stepCounter = { get: () => stepCounter, - increment: () => ++stepCounter, + increment: () => { + stepCounter += 1; + return stepCounter; + }, getDirectory: () => screenshotDir, getCurrentStepName: () => currentStepName, setCurrentStepName: (name) => { @@ -151,7 +154,7 @@ exports.test = test_1.test.extend({ const newStep = function newStepScreenshot(name, fn) { return originalStep.call(this, name, async (stepInfo) => { // Set current step name for network helpers (clean name without [no-screenshot]) - const stepCounterObj = globalThis.__stepCounter; + const stepCounterObj = globalThis.stepCounter; if (stepCounterObj) { const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); stepCounterObj.setCurrentStepName(cleanName); @@ -186,7 +189,9 @@ exports.test = test_1.test.extend({ } } } - catch (error) { } + catch (error) { + // Screenshot capture failed, continue without screenshot + } return result; }); }; @@ -198,7 +203,7 @@ exports.test = test_1.test.extend({ const stepNoScreenshot = function stepNoScreenshot(name, fn) { return originalStep.call(this, name, async (stepInfo) => { // Set current step name for network helpers (clean name) - const stepCounterObj = globalThis.__stepCounter; + const stepCounterObj = globalThis.stepCounter; if (stepCounterObj) { stepCounterObj.setCurrentStepName(name); } diff --git a/build/tests/fixtures/clinic-helpers.js b/build/tests/fixtures/clinic-helpers.js index 17b2e56..b328d86 100644 --- a/build/tests/fixtures/clinic-helpers.js +++ b/build/tests/fixtures/clinic-helpers.js @@ -128,6 +128,42 @@ async function executeAcrossWorkspaces(workspaceConfigs, action, page) { } } } +/** + * Find and access any available patient (fastest option) + * @param page - The Playwright page object + * @returns The full name of the first patient that was accessed + */ +async function findAndAccessAnyPatient(page) { + const dashboard = new ClinicianDashboardPage_1.default(page); + try { + // Clear search to show all patients + await dashboard.searchInput.click(); + await dashboard.searchInput.fill(' '); + await page.waitForTimeout(500); + await dashboard.searchInput.fill(''); + await page.waitForTimeout(1500); + let allCells = await dashboard.patientListTable.getByRole('cell').all(); + // If no cells, try pressing Enter on empty search + if (allCells.length === 0) { + await dashboard.searchInput.press('Enter'); + await page.waitForTimeout(1500); + allCells = await dashboard.patientListTable.getByRole('cell').all(); + } + // Find the first cell that looks like a patient name + for (const cell of allCells) { + const cellText = await cell.textContent(); + if (cellText && cellText.trim().length > 3 && cellText.includes(' ')) { + await cell.click(); + await page.waitForTimeout(800); + return cellText.trim(); + } + } + throw new Error('No patient names found in table'); + } + catch (error) { + throw new Error(`Failed to find any patient: ${error}`); + } +} /** * Find and access any patient whose name contains the search term (optimized version) * @param searchTerm - Partial name to search for (e.g., "Custodial") @@ -191,42 +227,6 @@ async function findAndAccessPatientByPartialName(searchTerm, page) { throw new Error(`No patient found containing "${searchTerm}" and no fallback patients available`); } } -/** - * Find and access any available patient (fastest option) - * @param page - The Playwright page object - * @returns The full name of the first patient that was accessed - */ -async function findAndAccessAnyPatient(page) { - const dashboard = new ClinicianDashboardPage_1.default(page); - try { - // Clear search to show all patients - await dashboard.searchInput.click(); - await dashboard.searchInput.fill(' '); - await page.waitForTimeout(500); - await dashboard.searchInput.fill(''); - await page.waitForTimeout(1500); - let allCells = await dashboard.patientListTable.getByRole('cell').all(); - // If no cells, try pressing Enter on empty search - if (allCells.length === 0) { - await dashboard.searchInput.press('Enter'); - await page.waitForTimeout(1500); - allCells = await dashboard.patientListTable.getByRole('cell').all(); - } - // Find the first cell that looks like a patient name - for (const cell of allCells) { - const cellText = await cell.textContent(); - if (cellText && cellText.trim().length > 3 && cellText.includes(' ')) { - await cell.click(); - await page.waitForTimeout(800); - return cellText.trim(); - } - } - throw new Error('No patient names found in table'); - } - catch (error) { - throw new Error(`Failed to find any patient: ${error}`); - } -} /** * Access a specific patient by name and navigate to their summary page * @param patientName - The name of the patient to access diff --git a/build/tests/fixtures/network-helpers.js b/build/tests/fixtures/network-helpers.js index d5a0ebb..ea7dd18 100644 --- a/build/tests/fixtures/network-helpers.js +++ b/build/tests/fixtures/network-helpers.js @@ -252,7 +252,7 @@ class NetworkHelper { const request = this.getLatestCaptureMatching(schema.method, schema.url); if (request?.responseBody) { // Access the shared step counter from the stepScreenshoter fixture - const stepCounterObj = globalThis.__stepCounter; + const stepCounterObj = globalThis.stepCounter; if (stepCounterObj) { const stepNumber = stepCounterObj.increment(); const currentStepName = stepCounterObj.getCurrentStepName(); @@ -371,8 +371,8 @@ class NetworkHelper { * @param path - The dot-notation path (e.g., 'patient.birthday') * @returns The value at the path or undefined */ - getNestedValue(obj, path) { - return path.split('.').reduce((current, key) => current?.[key], obj); + getNestedValue(obj, propertyPath) { + return propertyPath.split('.').reduce((current, key) => current?.[key], obj); } /** * Validate producer-consumer data consistency for profile endpoints @@ -435,7 +435,7 @@ class NetworkHelper { throw new Error('No base endpoint found'); } // Generate comparison JSON file similar to validateEndpointResponse - const stepCounterObj = globalThis.__stepCounter; + const stepCounterObj = globalThis.stepCounter; if (stepCounterObj) { // Increment for JSON file naming (this is correct behavior) const stepNumber = stepCounterObj.increment(); diff --git a/build/tests/fixtures/patient-helpers.js b/build/tests/fixtures/patient-helpers.js index 0b68151..b47b24c 100644 --- a/build/tests/fixtures/patient-helpers.js +++ b/build/tests/fixtures/patient-helpers.js @@ -368,6 +368,7 @@ async function executeNavigationStrategy(state) { // Check condition if present if (step.condition && !(await step.condition(state))) { console.log(`Skipping step ${step.name} - condition not met`); + // eslint-disable-next-line no-continue continue; } console.log(`Executing step: ${step.name}`); diff --git a/build/tests/global-setup.js b/build/tests/global-setup.js index 2550db3..03e5990 100644 --- a/build/tests/global-setup.js +++ b/build/tests/global-setup.js @@ -12,7 +12,7 @@ const env_1 = __importDefault(require("../utilities/env")); async function loginUserType(role) { const browser = await test_1.chromium.launch(); const context = await browser.newContext({ - baseURL: process.env.BASE_URL, + baseURL: env_1.default.BASE_URL, }); const page = await context.newPage(); await page.goto(env_1.default.BASE_URL); diff --git a/build/tests/personal/login.spec.js b/build/tests/personal/login.spec.js index 9855597..8c78393 100644 --- a/build/tests/personal/login.spec.js +++ b/build/tests/personal/login.spec.js @@ -8,11 +8,19 @@ const base_1 = require("@fixtures/base"); const LoginPage_1 = __importDefault(require("page-objects/LoginPage")); const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); const env_1 = __importDefault(require("../../utilities/env")); +const test_tags_1 = require("../fixtures/test-tags"); // make sure we don't have any cookies or origins base_1.test.use({ storageState: { cookies: [], origins: [] } }); // Possible testcases: https://tidepool.atlassian.net/jira/software/c/projects/WEB/issues/?jql=project%20%3D%20%22WEB%22%20AND%20type%20%3D%20Test%20AND%20textfields%20~%20%22login%22%20ORDER%20BY%20created%20DESC base_1.test.describe('Login into application', () => { - (0, base_1.test)('should work with valid credentials for clinician with multiple clinics', async ({ page, }) => { + (0, base_1.test)('should work with valid credentials for clinician with multiple clinics', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.CLINICIAN, + test_tags_1.TEST_TAGS.UI, + test_tags_1.TEST_TAGS.SMOKE, + test_tags_1.TEST_TAGS.CRITICAL, + ]), + }, async ({ page }) => { const loginPage = new LoginPage_1.default(page); await base_1.test.step('When user is logged into application', async () => { await loginPage.goto(); @@ -24,7 +32,14 @@ base_1.test.describe('Login into application', () => { await (0, base_1.expect)(workspacesPage.header).toBeVisible(); }); }); - (0, base_1.test)('should show error message with invalid credentials', async ({ page }) => { + (0, base_1.test)('should show error message with invalid credentials', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.CLINICIAN, + test_tags_1.TEST_TAGS.UI, + test_tags_1.TEST_TAGS.SMOKE, + test_tags_1.TEST_TAGS.HIGH, + ]), + }, async ({ page }) => { const loginPage = new LoginPage_1.default(page); await base_1.test.step('When user attempts to login with invalid credentials', async () => { await loginPage.goto(); @@ -38,7 +53,14 @@ base_1.test.describe('Login into application', () => { await (0, base_1.expect)(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); }); }); - (0, base_1.test)('should validate email format', async ({ page }) => { + (0, base_1.test)('should validate email format', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.CLINICIAN, + test_tags_1.TEST_TAGS.UI, + test_tags_1.TEST_TAGS.REGRESSION, + test_tags_1.TEST_TAGS.MEDIUM, + ]), + }, async ({ page }) => { const loginPage = new LoginPage_1.default(page); await base_1.test.step('When user attempts to login with invalid email format', async () => { await loginPage.goto(); @@ -52,7 +74,14 @@ base_1.test.describe('Login into application', () => { await (0, base_1.expect)(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); }); }); - (0, base_1.test)('should show error message with invalid credentials 1', async ({ page }) => { + (0, base_1.test)('should show error message with invalid credentials 1', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.CLINICIAN, + test_tags_1.TEST_TAGS.UI, + test_tags_1.TEST_TAGS.SMOKE, + test_tags_1.TEST_TAGS.HIGH, + ]), + }, async ({ page }) => { const loginPage = new LoginPage_1.default(page); await base_1.test.step('When user is logged into application', async () => { await loginPage.goto(); diff --git a/build/utilities/env.js b/build/utilities/env.js index 1c8b960..123e678 100644 --- a/build/utilities/env.js +++ b/build/utilities/env.js @@ -20,6 +20,10 @@ const envSchema = zod_1.default.object({ TARGET_ENV: zod_1.default.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production', 'prd', 'int']), XRAY_CLIENT_ID: zod_1.default.string().optional(), XRAY_CLIENT_SECRET: zod_1.default.string().optional(), + XRAY_PROJECT_KEY: zod_1.default.string().default('SAND'), + XRAY_EVIDENCE_SIZE_THRESHOLD_KB: zod_1.default.coerce.number().default(100), + JIRA_EMAIL: zod_1.default.string().optional(), + JIRA_API_KEY: zod_1.default.string().optional(), }); const env = envSchema.safeParse(process.env); if (!env.success) { diff --git a/build/utilities/xray-json-reporter.js b/build/utilities/xray-json-reporter.js index 01ea7fe..56046f6 100644 --- a/build/utilities/xray-json-reporter.js +++ b/build/utilities/xray-json-reporter.js @@ -6,9 +6,10 @@ Object.defineProperty(exports, "__esModule", { value: true }); const node_fs_1 = __importDefault(require("node:fs")); const node_path_1 = __importDefault(require("node:path")); const env_1 = __importDefault(require("./env")); +const xray_graphql_evidence_1 = require("./xray-graphql-evidence"); /** * Unified Xray JSON Reporter for Playwright - * Maps rich Playwright test data to Xray's JSON format with step-by-step evidence + * Maps rich Playwright test data to Xray's JSON format with intelligent evidence handling */ class XrayJsonReporter { constructor() { @@ -16,20 +17,26 @@ class XrayJsonReporter { success: 'āœ…', error: 'āŒ', info: 'ā„¹ļø', - warning: 'ā›”ļø', + warning: 'āš ļø', upload: 'šŸš€', test: '🧪', + evidence: 'šŸ“Ž', separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', }; this.startTime = ''; this.endTime = ''; + this.deferredEvidenceUploads = []; } /** * Authenticates with Xray API using client credentials */ async authenticateWithXray() { + const startAuth = Date.now(); try { - console.log(`${this.styles.info} Authenticating with Xray...`); + console.log(`${this.styles.info} Authenticating with Xray Cloud API...`); + if (!env_1.default.XRAY_CLIENT_ID || !env_1.default.XRAY_CLIENT_SECRET) { + throw new Error('XRAY_CLIENT_ID and XRAY_CLIENT_SECRET are required for authentication'); + } const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { method: 'POST', headers: { @@ -42,11 +49,16 @@ class XrayJsonReporter { }); if (!response.ok) { const errorText = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + throw new Error(`Authentication failed (HTTP ${response.status}): ${errorText || 'No error details'}`); } const token = await response.text(); - console.log(`${this.styles.success} Successfully authenticated with Xray`); - return token.replace(/"/g, ''); // Remove quotes from token + const cleanToken = token.replace(/"/g, ''); // Remove quotes from token + if (!cleanToken || cleanToken.length < 10) { + throw new Error(`Invalid token received: ${cleanToken.substring(0, 20)}...`); + } + const authDuration = Date.now() - startAuth; + console.log(`${this.styles.success} Successfully authenticated with Xray (${authDuration}ms)`); + return cleanToken; } catch (error) { console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); @@ -77,36 +89,112 @@ class XrayJsonReporter { } } /** - * Extracts step information from test annotations + * Get file size in bytes + */ + getFileSize(filePath) { + try { + const stats = node_fs_1.default.statSync(filePath); + return stats.size; + } + catch (error) { + return 0; + } + } + /** + * Classifies evidence based on type, size, and test result + */ + classifyEvidence(attachment, testStatus, contentType) { + const filePath = attachment.path; + if (!filePath || !node_fs_1.default.existsSync(filePath)) { + return 'skip'; + } + const sizeBytes = this.getFileSize(filePath); + const sizeKB = sizeBytes / 1024; + const thresholdKB = env_1.default.XRAY_EVIDENCE_SIZE_THRESHOLD_KB || 100; + // Videos: Only for failed tests + if (contentType.includes('video')) { + if (testStatus !== 'passed') { + return 'deferred'; // Always defer videos (large files) + } + return 'skip'; + } + // Screenshots (PNG/JPEG): Always include + if (contentType.includes('image')) { + if (sizeKB < thresholdKB) { + return 'inline'; + } + return 'deferred'; + } + // JSON responses: Always inline (small) + if (contentType.includes('json')) { + return 'inline'; + } + // Other attachments: Check size + if (sizeKB < thresholdKB) { + return 'inline'; + } + return 'deferred'; + } + /** + * Extracts step information from test annotations and maps evidence */ - async extractSteps(annotations, attachments) { + async extractSteps(annotations, attachments, testStatus) { const steps = []; + const classifiedEvidence = []; const stepAnnotations = annotations.filter(ann => ann.type.startsWith('Step Duration:')); - for (const stepAnn of stepAnnotations) { + for (let i = 0; i < stepAnnotations.length; i += 1) { + const stepAnn = stepAnnotations[i]; const stepName = stepAnn.type.replace('Step Duration: ', ''); const duration = stepAnn.description; - // Find associated step attachments - const stepAttachments = attachments.filter(att => att.name.toLowerCase().includes(stepName.toLowerCase().substring(0, 20))); + const stepNumber = i + 1; + // Find associated step attachments using step number pattern + const stepPattern = `step-${stepNumber.toString().padStart(2, '0')}`; + const stepAttachments = attachments.filter(att => att.name.toLowerCase().includes(stepPattern)); const step = { action: stepName, data: `Duration: ${duration}`, result: stepName.includes('Then') ? stepName : undefined, - status: 'PASS', // Will be updated based on test result + status: 'PASS', // Will be updated if test failed evidences: [], }; - // Add evidence for this step + // Classify and process step evidence for (const attachment of stepAttachments) { if (attachment.path && node_fs_1.default.existsSync(attachment.path)) { - step.evidences?.push({ - data: await this.fileToBase64(attachment.path), - filename: node_path_1.default.basename(attachment.path), - contentType: attachment.contentType || 'application/octet-stream', - }); + const contentType = attachment.contentType || 'application/octet-stream'; + const classification = this.classifyEvidence(attachment, testStatus, contentType); + if (classification !== 'skip') { + const sizeBytes = this.getFileSize(attachment.path); + if (classification === 'inline') { + // Embed in Xray JSON + const base64Data = await this.fileToBase64(attachment.path); + if (base64Data) { + step.evidences?.push({ + data: base64Data, + filename: node_path_1.default.basename(attachment.path), + contentType, + }); + } + } + else { + // Mark for deferred upload + classifiedEvidence.push({ + evidence: { + data: '', // Will be loaded during GraphQL upload + filename: node_path_1.default.basename(attachment.path), + contentType, + }, + classification: 'deferred', + stepIndex: i, + filePath: attachment.path, + fileSize: sizeBytes, + }); + } + } } } steps.push(step); } - return steps; + return { steps, classified: classifiedEvidence }; } /** * Maps Playwright test result to Xray test format @@ -115,24 +203,48 @@ class XrayJsonReporter { const tags = testCase.tags || []; const annotations = testResult.annotations || []; const attachments = testResult.attachments || []; + const testStatus = testResult.status; // Extract steps from annotations - const steps = await this.extractSteps(annotations, attachments); + const { steps, classified: stepDeferred } = await this.extractSteps(annotations, attachments, testStatus); // Mark failed steps if test failed - if (testResult.status !== 'passed' && steps.length > 0) { + if (testStatus !== 'passed' && steps.length > 0) { steps[steps.length - 1].status = 'FAIL'; steps[steps.length - 1].actualResult = testResult.error?.message || 'Test failed'; } // Collect test-level evidence (screenshots, videos) const testEvidences = []; + const testLevelDeferred = []; for (const attachment of attachments) { - if (attachment.path && node_fs_1.default.existsSync(attachment.path)) { - // Add main test evidence (final screenshots, videos, etc.) - if (attachment.name.includes('screenshot') || attachment.name.includes('video')) { - testEvidences.push({ - data: await this.fileToBase64(attachment.path), - filename: attachment.name, - contentType: attachment.contentType || 'application/octet-stream', - }); + // Only process test-level evidence (not step-level) + if (attachment.path && + node_fs_1.default.existsSync(attachment.path) && + !attachment.name.toLowerCase().includes('step-')) { + const contentType = attachment.contentType || 'application/octet-stream'; + const classification = this.classifyEvidence(attachment, testStatus, contentType); + if (classification !== 'skip') { + const sizeBytes = this.getFileSize(attachment.path); + if (classification === 'inline') { + const base64Data = await this.fileToBase64(attachment.path); + if (base64Data) { + testEvidences.push({ + data: base64Data, + filename: attachment.name, + contentType, + }); + } + } + else { + testLevelDeferred.push({ + evidence: { + data: '', + filename: attachment.name, + contentType, + }, + classification: 'deferred', + filePath: attachment.path, + fileSize: sizeBytes, + }); + } } } } @@ -140,15 +252,18 @@ class XrayJsonReporter { testInfo: { summary: testCase.title, type: 'Generic', - projectKey: 'XT', // Could be made configurable + projectKey: env_1.default.XRAY_PROJECT_KEY || 'SAND', labels: tags, }, - status: this.getTestStatus(testResult.status), + status: this.getTestStatus(testStatus), comment: testResult.error?.message, - evidences: testEvidences, + evidences: testEvidences.length > 0 ? testEvidences : undefined, steps: steps.length > 0 ? steps : undefined, }; - return xrayTest; + return { + test: xrayTest, + deferred: [...stepDeferred, ...testLevelDeferred], + }; } /** * Converts Playwright JSON results to Xray format @@ -157,18 +272,25 @@ class XrayJsonReporter { const jsonContent = node_fs_1.default.readFileSync(playwrightJsonPath, 'utf8'); const playwrightResult = JSON.parse(jsonContent); const tests = []; + this.deferredEvidenceUploads = []; // Reset deferred uploads // Process all test suites for (const suite of playwrightResult.suites || []) { await this.processSuite(suite, tests); } const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; const targetEnv = process.env.TARGET_ENV || 'qa1'; + // Calculate statistics + const passedCount = tests.filter(t => t.status === 'PASS').length; + const failedCount = tests.filter(t => t.status === 'FAIL').length; + const pendingCount = tests.filter(t => t.status === 'PENDING').length; const xrayResult = { info: { summary: `Playwright Test Execution - ${new Date().toISOString()}`, - description: `Automated test execution for ${targetEnv} environment`, + description: `Automated test execution for ${targetEnv} environment\n\nResults: ${passedCount} passed, ${failedCount} failed, ${pendingCount} pending`, version: '1.0', - testExecutionKey: testExecKey !== 'none' ? testExecKey : undefined, + testExecutionKey: testExecKey && testExecKey !== 'none' && testExecKey.trim() !== '' + ? testExecKey + : undefined, startDate: playwrightResult.stats?.startTime || new Date().toISOString(), finishDate: new Date(new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + (playwrightResult.stats?.duration || 0)).toISOString(), @@ -176,6 +298,11 @@ class XrayJsonReporter { }, tests, }; + // Log deferred evidence summary + if (this.deferredEvidenceUploads.length > 0) { + const totalSizeKB = this.deferredEvidenceUploads.reduce((sum, d) => sum + this.getFileSize(d.filePath) / 1024, 0); + console.log(`${this.styles.evidence} ${this.deferredEvidenceUploads.length} evidence files marked for deferred upload (${totalSizeKB.toFixed(1)} KB)`); + } return xrayResult; } /** @@ -186,8 +313,22 @@ class XrayJsonReporter { for (const spec of suite.specs || []) { for (const test of spec.tests || []) { for (const result of test.results || []) { - const xrayTest = await this.mapPlaywrightTestToXray(spec, result); + const { test: xrayTest, deferred } = await this.mapPlaywrightTestToXray(spec, result); tests.push(xrayTest); + // Store deferred evidence for later upload + // Note: We'll need test run IDs from import response to upload these + for (const evidence of deferred) { + this.deferredEvidenceUploads.push({ + testRunId: '', // Will be populated after import + testRunStepId: evidence.stepIndex !== undefined ? '' : undefined, + filePath: evidence.filePath, + filename: evidence.evidence.filename, + contentType: evidence.evidence.contentType, + stepAction: evidence.stepIndex !== undefined + ? xrayTest.steps?.[evidence.stepIndex]?.action + : undefined, + }); + } } } } @@ -201,7 +342,11 @@ class XrayJsonReporter { */ async uploadToXray(xrayResult) { try { + const uploadStart = Date.now(); + const payloadSize = JSON.stringify(xrayResult).length; + const payloadSizeKB = (payloadSize / 1024).toFixed(1); console.log(`${this.styles.info} Uploading test execution to Xray...`); + console.log(`${this.styles.info} Payload: ${xrayResult.tests.length} tests, ${payloadSizeKB} KB`); const token = await this.authenticateWithXray(); const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution', { method: 'POST', @@ -213,16 +358,46 @@ class XrayJsonReporter { }); if (!response.ok) { const errorText = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + throw new Error(`Upload failed (HTTP ${response.status}): ${errorText}`); } const result = await response.json(); - console.log(`${this.styles.success} Successfully uploaded to Xray. Execution Key: ${result.key}`); + const uploadDuration = Date.now() - uploadStart; + console.log(`${this.styles.success} Successfully uploaded to Xray (${uploadDuration}ms)`); + console.log(`${this.styles.success} Test Execution Key: ${result.testExecIssue?.key || 'N/A'}`); + return result; } catch (error) { console.error(`${this.styles.error} Failed to upload to Xray:`, error); throw error; } } + /** + * Upload deferred evidence via GraphQL + */ + async uploadDeferredEvidenceViaGraphQL(importResponse) { + try { + console.log(`${this.styles.evidence} Uploading ${this.deferredEvidenceUploads.length} deferred evidence files via GraphQL...`); + // Get fresh token for GraphQL + const token = await this.authenticateWithXray(); + // Create GraphQL client + const graphqlClient = new xray_graphql_evidence_1.XrayGraphQLClient(); + graphqlClient.setAuthToken(token); + // Note: The import response doesn't directly provide test run IDs + // For now, we'll skip GraphQL upload and log a warning + // This requires additional API calls to fetch test run details + console.log(`${this.styles.warning} GraphQL evidence upload requires test run ID mapping`); + console.log(`${this.styles.info} Test Execution: ${importResponse.testExecIssue?.key}`); + console.log(`${this.styles.info} Deferred uploads will be enhanced in a future update to fetch test run IDs`); + // TODO: Implement test run ID fetching via GraphQL query + // Query: { getTestExecution(issueId: "...") { testRuns { id, test { summary } } } } + // Then map playwright test titles to test run IDs + // Then call graphqlClient.uploadBatch(uploadsWithIds) + } + catch (error) { + console.error(`${this.styles.error} Failed to upload deferred evidence:`, error); + // Don't throw - evidence upload is non-critical + } + } /** * Main method to process and upload results */ @@ -232,12 +407,30 @@ class XrayJsonReporter { return; } try { - console.log(`${this.styles.info} Processing Playwright results...`); + const processStart = Date.now(); + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.info} Processing Playwright results for Xray...`); + console.log(`${this.styles.info} Project Key: ${env_1.default.XRAY_PROJECT_KEY || 'SAND'}`); + console.log(`${this.styles.info} Environment: ${process.env.TARGET_ENV || 'qa1'}`); + const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; + if (testExecKey && testExecKey !== 'none' && testExecKey.trim() !== '') { + console.log(`${this.styles.info} Linking to Test Execution: ${testExecKey}`); + } + else { + console.log(`${this.styles.info} Creating new Test Execution`); + } const xrayResult = await this.convertPlaywrightJsonToXray(playwrightJsonPath); // Save converted result for debugging node_fs_1.default.writeFileSync('test-results/xray-execution.json', JSON.stringify(xrayResult, null, 2)); - await this.uploadToXray(xrayResult); - console.log(`${this.styles.upload} Xray upload completed successfully`); + console.log(`${this.styles.info} Saved Xray JSON to: test-results/xray-execution.json`); + const importResponse = await this.uploadToXray(xrayResult); + // Phase 3 - Upload deferred evidence via GraphQL + if (this.deferredEvidenceUploads.length > 0 && importResponse) { + await this.uploadDeferredEvidenceViaGraphQL(importResponse); + } + const totalDuration = Date.now() - processStart; + console.log(`${this.styles.upload} Xray upload completed successfully (${totalDuration}ms)`); + console.log(`${this.styles.separator}\n`); } catch (error) { console.error(`${this.styles.error} Failed to process and upload:`, error); @@ -267,10 +460,13 @@ class XrayJsonReporter { console.log(`Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`); console.log(`Duration: ${result.duration}ms`); console.log(`${this.styles.separator}\n`); - // Auto-upload if JSON results are available - const jsonPath = 'test-results/last-run.json'; - if (node_fs_1.default.existsSync(jsonPath)) { - await this.processAndUpload(jsonPath); + // Only attempt upload if Xray credentials and execution key are configured + const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; + if (env_1.default.XRAY_CLIENT_ID && env_1.default.XRAY_CLIENT_SECRET && testExecKey && testExecKey !== 'none') { + const jsonPath = 'test-results/last-run.json'; + if (node_fs_1.default.existsSync(jsonPath)) { + await this.processAndUpload(jsonPath); + } } } } diff --git a/docs/XRAY_INTEGRATION.md b/docs/XRAY_INTEGRATION.md index 92033c5..d72e063 100644 --- a/docs/XRAY_INTEGRATION.md +++ b/docs/XRAY_INTEGRATION.md @@ -2,105 +2,108 @@ ## Overview -This project uses a unified JSON-based Xray integration that captures rich test data from Playwright and uploads it to Xray with step-by-step evidence including screenshots, videos, and test annotations. +This project uses a JSON-based Xray integration that captures Playwright test data and uploads it to JIRA Xray Cloud with evidence handling including screenshots and videos (failed tests only). ## Architecture -### 1. **Playwright Configuration** (`playwright.config.ts`) +### 1. **Playwright Configuration** ([playwright.config.ts](../playwright.config.ts)) - **JSON Reporter**: Generates `test-results/last-run.json` with complete test data -- **Xray JSON Reporter**: Custom reporter that automatically uploads to Xray -- **Legacy XML Reporter**: Still available for backward compatibility +- **Xray JSON Reporter**: Custom reporter that automatically uploads to Xray Cloud ```typescript reporter: [ ['html', { open: 'never', outputFolder: 'playwright-report' }], - ['json', { outputFile: 'test-results/last-run.json' }], // New JSON format - ['junit', xrayOptions], // Legacy XML format - ['./utilities/xray-json-reporter.ts'], // Auto-upload to Xray + ['json', { outputFile: 'test-results/last-run.json' }], + ['./utilities/xray-json-reporter.ts'], // Auto-upload to Xray ], ``` -### 2. **Xray JSON Reporter** (`utilities/xray-json-reporter.ts`) +### 2. **Xray JSON Reporter** ([utilities/xray-json-reporter.ts](../utilities/xray-json-reporter.ts)) -**Features:** +**Core Features:** -- Maps Playwright test steps to Xray test steps with individual evidence +- Maps Playwright test steps to Xray test steps with evidence - Attaches screenshots per step (e.g., `step-01-given-clinician-has-been-logged-in.png`) -- Includes test tags, annotations, and custom properties -- Embeds video evidence for failed tests +- Embeds video evidence for failed tests only +- Supports configurable project keys - Supports test execution key parameter for linking to existing test executions +**Evidence Handling:** + +- **Videos**: Only uploaded for failed tests (saves storage) +- **Screenshots**: Always included as base64-encoded inline evidence +- **JSON responses**: Always included inline +- Passing test videos are skipped entirely + **Data Mapping:** - **Test Steps**: Extracts from `Step Duration:` annotations - **Evidence**: Screenshots, videos, JSON responses per step -- **Status**: Pass/Fail/Pending with detailed failure messages -- **Metadata**: Environment, build info, test tags +- **Status**: PASSED/FAILED/TODO with detailed failure messages -### 3. **CircleCI Integration** (`.circleci/config.yml`) +### 3. **CircleCI Integration** ([.circleci/config.yml](../.circleci/config.yml)) -**Simplified Workflow:** +The Xray reporter uploads automatically during `onEnd` — no separate CI step needed. -1. Run tests → Generate `test-results/last-run.json` -2. Build TypeScript utilities -3. Upload to Xray using `node utilities/upload-to-xray.js` +**Pipeline Parameters:** -**Environment Variables:** - -- `TEST_EXECUTION_KEY`: Links results to existing Xray test execution -- `XRAY_CLIENT_ID`: Xray API authentication -- `XRAY_CLIENT_SECRET`: Xray API authentication -- `TARGET_ENV`: Test environment (qa1, qa2, etc.) +- `testEnvironment` - Target environment (qa1, qa2, qa3, qa4, qa5, prd, int) +- `testExecKey` - Test Execution Key to link results to (or 'none' for auto-create) +- `testTags` - Filter tests by tags +- `xrayProjectKey` - Xray project key (default: 'SAND') ## Usage ### Local Development ```bash -# Run tests and auto-upload to Xray (if credentials available) +# Set required environment variables in .env +XRAY_CLIENT_ID=your_client_id +XRAY_CLIENT_SECRET=your_client_secret +XRAY_PROJECT_KEY=SAND # Optional, defaults to SAND +TARGET_ENV=qa1 +TEST_EXECUTION_KEY=SAND-1245 # Or 'none' for auto-create + +# Run tests — reporter auto-uploads to Xray if credentials are set npm test - -# Manual upload of existing results -npm run upload-to-xray test-results/last-run.json - -# Build TypeScript utilities -npm run build ``` ### CI/CD Pipeline Tests automatically upload to Xray when: -- `XRAY_CLIENT_ID` and `XRAY_CLIENT_SECRET` are available -- `TEST_EXECUTION_KEY` parameter is provided -- JSON results file exists - -### Test Tagging +- `XRAY_CLIENT_ID` and `XRAY_CLIENT_SECRET` are available in environment +- `TEST_EXECUTION_KEY` is set (and not 'none') +- JSON results file exists (`test-results/last-run.json`) -Use test tags to organize and filter results in Xray: +**CircleCI Pipeline Triggers:** -```typescript -{ - tag: createValidatedTags([ - TEST_TAGS.PATIENT, - TEST_TAGS.API, - TEST_TAGS.HIGH, - TEST_TAGS.API_USER, - ]), -} +```bash +# Run tests on qa2 and link to existing test execution +curl -X POST \ + --url https://circleci.com/api/v2/project/github/your-org/your-repo/pipeline \ + -H "Circle-Token: $CIRCLE_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "parameters": { + "testEnvironment": "qa2", + "testExecKey": "SAND-123", + "xrayProjectKey": "SAND" + } + }' ``` ## Xray JSON Format -### Test Execution Structure +### Execution Structure ```json { + "testExecutionKey": "SAND-1245", "info": { "summary": "Playwright Test Execution - 2025-08-22T19:50:15.680Z", - "testExecutionKey": "XT-123", - "testEnvironments": ["qa1"], + "description": "Automated test execution for qa1 environment\n\nResults: 45 passed, 2 failed, 1 skipped", "startDate": "2025-08-22T19:50:15.680Z", "finishDate": "2025-08-22T19:50:56.408Z" }, @@ -108,18 +111,26 @@ Use test tags to organize and filter results in Xray: } ``` +**Note:** `testExecutionKey` is at the root level. When linking to an existing execution, `testEnvironments` and `version` are omitted to avoid validation errors. + ### Individual Test Structure ```json { "testInfo": { "summary": "should allow navigation to account settings", - "type": "Generic", - "projectKey": "XT", - "labels": ["patient", "api", "high"] + "type": "Manual", + "projectKey": "SAND", + "steps": [ + { + "action": "When user navigates to settings", + "data": "Duration: 5193ms", + "result": "Then the settings page is displayed" + } + ] }, - "status": "PASS", - "evidences": [ + "status": "PASSED", + "evidence": [ { "data": "base64-encoded-screenshot", "filename": "final-screenshot.png", @@ -128,13 +139,11 @@ Use test tags to organize and filter results in Xray: ], "steps": [ { - "action": "Given clinician has been logged in", - "data": "Duration: 5193ms", - "status": "PASS", - "evidences": [ + "status": "PASSED", + "evidence": [ { "data": "base64-encoded-step-screenshot", - "filename": "step-01-given-clinician-has-been-logged-in.png", + "filename": "step-01-screenshot.png", "contentType": "image/png" } ] @@ -143,44 +152,100 @@ Use test tags to organize and filter results in Xray: } ``` -## Benefits Over Legacy XML +**Key details:** -| Feature | XML (Legacy) | JSON (New) | -| ----------------- | --------------------- | ---------------------- | -| Test Steps | āŒ Basic only | āœ… Full step breakdown | -| Screenshots | āŒ Separate API calls | āœ… Embedded per step | -| Videos | āŒ Not supported | āœ… Embedded evidence | -| Custom Properties | āŒ Limited | āœ… Rich metadata | -| Test Tags | āŒ Basic | āœ… Full tag system | -| Debugging Info | āŒ Minimal | āœ… Comprehensive | +- `testInfo.steps` contains step **definitions** (action, data, result) +- `test.steps` contains step **execution results** (status, evidence, actualResult) +- Status values are `PASSED`, `FAILED`, `TODO`, `EXECUTING` (Xray Cloud format) +- Evidence field is singular `evidence` (not `evidences`) -## Migration Notes +### Step Mapping Logic -### Current State +Given/When/Then steps are mapped as follows: -- **JSON**: Primary integration with rich evidence -- **XML**: Available for backward compatibility -- **Duplicate Steps**: Removed from CircleCI +- **Given** → Standalone step (action only) +- **When** → Step action; consecutive Then/And steps become its `result` +- **Then/And** → Combined as the result of the preceding When step -### Future Cleanup +**Example:** +``` +When user logs in → action: "When user logs in" +Then user sees dashboard result: "Then user sees dashboard\nAnd user sees welcome" +And user sees welcome +``` -Once fully validated, remove: +## Configuration Reference -- `xrayOptions` configuration in `playwright.config.ts` -- `['junit', xrayOptions]` reporter -- Legacy `utilities/xray-reporter.ts` file +### Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `XRAY_CLIENT_ID` | Yes | - | Xray Cloud API client ID | +| `XRAY_CLIENT_SECRET` | Yes | - | Xray Cloud API client secret | +| `XRAY_PROJECT_KEY` | No | `SAND` | Jira project key for Xray tests | +| `TARGET_ENV` | Yes | `qa1` | Test environment | +| `TEST_EXECUTION_KEY` | No | `none` | Link to existing test execution (or 'none' to auto-create) | +| `TEST_TAGS` | No | - | Filter tests by tags | + +### File Locations + +| File | Purpose | +|------|---------| +| `test-results/last-run.json` | Playwright JSON results (source data) | +| `test-results/xray-execution.json` | Converted Xray JSON format (debug) | +| `playwright-report/` | HTML test report | ## Troubleshooting ### Common Issues -1. **Missing JSON file**: Ensure `json` reporter is enabled in Playwright config -2. **Upload failures**: Check Xray credentials and network connectivity -3. **Step evidence missing**: Verify step naming conventions in test annotations -4. **TypeScript compilation**: Run `npm run build` before upload +1. **No upload happening** + - Check `XRAY_CLIENT_ID` and `XRAY_CLIENT_SECRET` are set + - Verify `TEST_EXECUTION_KEY` is set and not 'none' + - Check console output for authentication errors + +2. **Tests not appearing in correct project** + - Verify `XRAY_PROJECT_KEY` is set to correct project + - Ensure project key is uppercase (e.g., 'SAND', not 'sand') + +3. **"Result is not valid Xray Format" error** + - Check `test-results/xray-execution.json` for the actual payload + - Verify `testExecutionKey` is at root level (not inside `info`) + - Ensure status values are `PASSED`/`FAILED` (not `PASS`/`FAIL`) + +4. **"environments dont exist" or "Version name not valid" errors** + - These occur when `testEnvironments` or `version` don't match Jira project config + - When linking to existing executions, these fields are automatically omitted + +5. **Steps showing as TODO instead of PASSED** + - Verify status values use Xray Cloud format: `PASSED`, `FAILED`, `TODO` + - Xray Server uses `PASS`/`FAIL` but Cloud uses `PASSED`/`FAILED` ### Debug Information -- Generated JSON saved to `test-results/xray-execution.json` -- Full logs available in CircleCI build output -- Test step timing and evidence captured in annotations +- Check console output during test run for upload status +- Review `test-results/xray-execution.json` for the converted payload +- Check CircleCI build logs for upload details + +## API Reference + +### Xray Cloud Endpoints Used + +1. **Authentication** + - Endpoint: `POST https://xray.cloud.getxray.app/api/v1/authenticate` + - Input: `{ client_id, client_secret }` + - Output: Token string + +2. **Import Execution Results** + - Endpoint: `POST https://xray.cloud.getxray.app/api/v2/import/execution` + - Auth: Bearer token + - Input: Xray JSON format + - Output: Test execution details + +## Support + +For issues or questions: +- Check this documentation first +- Review CircleCI build logs +- Inspect `test-results/xray-execution.json` for payload details +- Verify environment variables are set correctly diff --git a/package-lock.json b/package-lock.json index 14e612b..8c143cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,32 +44,32 @@ } }, "node_modules/@dabh/diagnostics": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", - "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", "license": "MIT", "dependencies": { - "colorspace": "1.1.x", + "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "node_modules/@emnapi/core": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", - "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.0.4", + "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", - "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "dev": true, "license": "MIT", "optional": true, @@ -78,9 +78,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz", - "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "dev": true, "license": "MIT", "optional": true, @@ -89,9 +89,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -121,9 +121,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -131,11 +131,14 @@ } }, "node_modules/@eslint/compat": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.3.1.tgz", - "integrity": "sha512-k8MHony59I5EPic6EQTCNOuPoVBnoYXkP+20xvwFjN7t0qI3ImyvyBgg+hIVPwC8JaxVjjUZld+cLfBLFDLucg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.1.tgz", + "integrity": "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -149,13 +152,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -188,19 +191,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", - "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -211,9 +217,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -223,7 +229,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -295,23 +301,10 @@ "node": "*" } }, - "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@eslint/js": { - "version": "9.32.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz", - "integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -322,9 +315,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -332,13 +325,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", - "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.1", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -382,18 +375,36 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", - "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", "dependencies": { - "@grpc/proto-loader": "^0.7.13", + "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" }, "engines": { "node": ">=12.10.0" } }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@grpc/proto-loader": { "version": "0.7.15", "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", @@ -425,6 +436,19 @@ "@grpc/grpc-js": "^1.8.21" } }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -436,33 +460,19 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -491,29 +501,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", @@ -549,36 +536,54 @@ } }, "node_modules/@kubernetes/client-node": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-1.3.0.tgz", - "integrity": "sha512-IE0yrIpOT97YS5fg2QpzmPzm8Wmcdf4ueWMn+FiJSI3jgTTQT1u+LUhoYpdfhdHAVxdrNsaBg2C0UXSnOgMoCQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-1.4.0.tgz", + "integrity": "sha512-Zge3YvF7DJi264dU1b3wb/GmzR99JhUpqTvp+VGHfwZT+g7EOOYNScDJNZwXy9cszyIGPIs0VHr+kk8e95qqrA==", "license": "Apache-2.0", "dependencies": { "@types/js-yaml": "^4.0.1", - "@types/node": "^22.0.0", - "@types/node-fetch": "^2.6.9", + "@types/node": "^24.0.0", + "@types/node-fetch": "^2.6.13", "@types/stream-buffers": "^3.0.3", "form-data": "^4.0.0", "hpagent": "^1.2.0", "isomorphic-ws": "^5.0.0", "js-yaml": "^4.1.0", "jsonpath-plus": "^10.3.0", - "node-fetch": "^2.6.9", + "node-fetch": "^2.7.0", "openid-client": "^6.1.3", "rfc4648": "^1.3.0", "socks-proxy-agent": "^8.0.4", "stream-buffers": "^3.0.2", - "tar-fs": "^3.0.8", + "tar-fs": "^3.0.9", "ws": "^8.18.2" } }, + "node_modules/@kubernetes/client-node/node_modules/@types/node": { + "version": "24.10.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.8.tgz", + "integrity": "sha512-r0bBaXu5Swb05doFYO2kTWHMovJnNVbCsII0fhesM8bNRlLhXIuckley4a2DaD+vOdmm5G+zGkQZAPZsF80+YQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@kubernetes/client-node/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.1.tgz", - "integrity": "sha512-CPle1OQehbWqd25La9Ack5B07StKIxh4+Bf19qnpZKJC1oI22Y0czZHbifjw1UoczIfKBwBDAp/dFxvHG13B5A==", + "version": "1.25.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", + "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", "dev": true, + "license": "MIT", "dependencies": { - "ajv": "^6.12.6", + "@hono/node-server": "^1.19.7", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -586,37 +591,29 @@ "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" }, "engines": { "node": ">=18" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -630,44 +627,6 @@ "@tybys/wasm-util": "^0.10.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@open-draft/until": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz", @@ -688,18 +647,21 @@ } }, "node_modules/@percy/sdk-utils": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/@percy/sdk-utils/-/sdk-utils-1.31.1.tgz", - "integrity": "sha512-OU+n/TGEPt7ZikJOwau9S0X0bCfKNTxHIry9dX57amL82PysCrzEfcKUJIAf1BTaVqDH4In8GPssjLVhut95Ag==", + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/sdk-utils/-/sdk-utils-1.31.8.tgz", + "integrity": "sha512-S+qxi4TIOvToAD5j89nkdDj0Xj5CH8YJxpI6ZRVJE/UQE+amHIP34KiTdrWKw5aPlYEwNPeNn9UlXz5HUr5Z9g==", "license": "MIT", + "dependencies": { + "pac-proxy-agent": "^7.0.2" + }, "engines": { "node": ">=14" } }, "node_modules/@percy/selenium-webdriver": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@percy/selenium-webdriver/-/selenium-webdriver-2.2.3.tgz", - "integrity": "sha512-dVUsgKkDUYvv7+jN4S4HuwSoYxb7Up0U7dM3DRj3/XzLp3boZiyTWAdFdOGS8R5eSsiY5UskTcGQKmGqHRle1Q==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@percy/selenium-webdriver/-/selenium-webdriver-2.2.5.tgz", + "integrity": "sha512-Bb8PtXwkE7Fu2oQAKBUMxejsC5+BOyo08vVM13NgdjJooNr7JeqbfZ6wbpzkG34HRjqu2C+ihXj8naYJE1OKlA==", "license": "MIT", "dependencies": { "@percy/sdk-utils": "^1.30.9", @@ -727,6 +689,7 @@ "resolved": "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.32.tgz", "integrity": "sha512-1pEULH5zF+NuUCBGRDEei7+Qv1pbkscaR0z3fKjAp7CsrS1DAgu62DHwCSbkTulXkd5nY1TdCCr4oK+shCB7Eg==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.16.0", "commander": "^13.1.0", @@ -746,10 +709,11 @@ } }, "node_modules/@playwright/mcp/node_modules/dotenv": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", - "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -757,46 +721,46 @@ "url": "https://dotenvx.com" } }, - "node_modules/@playwright/mcp/node_modules/playwright": { - "version": "1.55.0-alpha-1752701791000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-1752701791000.tgz", - "integrity": "sha512-PA3TvDz7uQ+Pde0uaii5/WpU5vntRJsYFsaSPoBzywIqzYFO1ugk1ZZ0q6z4/xHq0ha1UClvsv3P77B+u1fi+w==", - "dev": true, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.55.0-alpha-1752701791000" + "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" }, "engines": { "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" } }, - "node_modules/@playwright/mcp/node_modules/playwright-core": { - "version": "1.55.0-alpha-1752701791000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-1752701791000.tgz", - "integrity": "sha512-mQhzhjJMiqnGNnYZv7M4yk1OcNTt1E72jrTLO7EqZuoeat4+qpcU0/mbK+RcTEass5a9YheoVFh6OIhruFMGVg==", - "dev": true, + "node_modules/@playwright/test/node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, "bin": { - "playwright-core": "cli.js" + "playwright": "cli.js" }, "engines": { "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" } }, - "node_modules/@playwright/test": { - "version": "1.54.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz", - "integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==", + "node_modules/@playwright/test/node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "license": "Apache-2.0", - "dependencies": { - "playwright": "1.54.1" - }, "bin": { - "playwright": "cli.js" + "playwright-core": "cli.js" }, "engines": { "node": ">=18" @@ -919,6 +883,16 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, "node_modules/@stylistic/eslint-plugin": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-3.1.0.tgz", @@ -939,19 +913,6 @@ "eslint": ">=8.40.0" } }, - "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -980,9 +941,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", - "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", "optional": true, @@ -1050,9 +1011,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.17.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz", - "integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==", + "version": "22.19.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz", + "integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1097,6 +1058,27 @@ "node": ">= 0.12" } }, + "node_modules/@types/request/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@types/request/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -1107,9 +1089,9 @@ } }, "node_modules/@types/stream-buffers": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/stream-buffers/-/stream-buffers-3.0.7.tgz", - "integrity": "sha512-azOCy05sXVXrO+qklf0c/B07H/oHaIuDDAiHPVwlk3A9Ek+ksHyTeMajLZl3r76FxpPpxem//4Te61G1iW3Giw==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/stream-buffers/-/stream-buffers-3.0.8.tgz", + "integrity": "sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==", "license": "MIT", "dependencies": { "@types/node": "*" @@ -1128,21 +1110,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", - "integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", + "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/type-utils": "8.38.0", - "@typescript-eslint/utils": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/type-utils": "8.53.0", + "@typescript-eslint/utils": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1152,9 +1133,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.38.0", + "@typescript-eslint/parser": "^8.53.0", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -1168,17 +1149,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz", - "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz", + "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1189,19 +1170,19 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", - "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz", + "integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.38.0", - "@typescript-eslint/types": "^8.38.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.53.0", + "@typescript-eslint/types": "^8.53.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1211,18 +1192,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz", - "integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz", + "integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0" + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1233,9 +1214,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", - "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz", + "integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==", "dev": true, "license": "MIT", "engines": { @@ -1246,21 +1227,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz", - "integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.0.tgz", + "integrity": "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/utils": "8.38.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/utils": "8.53.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1271,13 +1252,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", - "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz", + "integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==", "dev": true, "license": "MIT", "engines": { @@ -1289,22 +1270,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", - "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz", + "integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.38.0", - "@typescript-eslint/tsconfig-utils": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.53.0", + "@typescript-eslint/tsconfig-utils": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1314,36 +1294,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz", - "integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz", + "integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1354,17 +1318,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz", - "integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz", + "integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/types": "8.53.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -1661,6 +1625,7 @@ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "dev": true, + "license": "MIT", "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" @@ -1669,27 +1634,6 @@ "node": ">= 0.6" } }, - "node_modules/accepts/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1741,6 +1685,24 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-align": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", @@ -1864,9 +1826,9 @@ } }, "node_modules/aws-sdk": { - "version": "2.1692.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1692.0.tgz", - "integrity": "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw==", + "version": "2.1693.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1693.0.tgz", + "integrity": "sha512-cJmb8xEnVLT+R6fBS5sn/EFJiX7tUnDaPtOPZ1vFbOJtd0fnZn/Ky2XGgsvvoeliWeH7mL3TWSX5zXXGSQV6gQ==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1895,10 +1857,18 @@ } }, "node_modules/b4a": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", - "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", - "license": "Apache-2.0" + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } }, "node_modules/balanced-match": { "version": "1.0.2", @@ -1907,22 +1877,31 @@ "license": "MIT" }, "node_modules/bare-events": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.0.tgz", - "integrity": "sha512-EKZ5BTXYExaNqi3I3f9RtEsaI/xBSGjE0XZCZilPzFAV/goswFHuPd9jEZlPIZ/iNZJwDSao9qRiScySz7MbQg==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", - "optional": true + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } }, "node_modules/bare-fs": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.6.tgz", - "integrity": "sha512-25RsLF33BqooOEFNdMcEhMpJy8EoR88zSMrnOQOaM3USnOK2VmaJ1uaQEwPA6AQjrv1lXChScosN6CzbwbO9OQ==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", + "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", "license": "Apache-2.0", "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", - "bare-stream": "^2.6.4" + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" }, "engines": { "bare": ">=1.16.0" @@ -1937,9 +1916,9 @@ } }, "node_modules/bare-os": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", - "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", "license": "Apache-2.0", "optional": true, "engines": { @@ -1957,9 +1936,9 @@ } }, "node_modules/bare-stream": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", - "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1978,6 +1957,16 @@ } } }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1999,9 +1988,9 @@ "license": "MIT" }, "node_modules/basic-ftp": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", - "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", + "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -2023,23 +2012,28 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "dev": true, + "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/boolbase": { @@ -2078,9 +2072,9 @@ } }, "node_modules/boxen/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -2090,9 +2084,9 @@ } }, "node_modules/boxen/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -2125,9 +2119,9 @@ } }, "node_modules/boxen/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -2160,19 +2154,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserstack-local": { "version": "1.5.8", "resolved": "https://registry.npmjs.org/browserstack-local/-/browserstack-local-1.5.8.tgz", @@ -2187,9 +2168,9 @@ } }, "node_modules/browserstack-node-sdk": { - "version": "1.40.3", - "resolved": "https://registry.npmjs.org/browserstack-node-sdk/-/browserstack-node-sdk-1.40.3.tgz", - "integrity": "sha512-KrY6LLRsr2kOgm/QSromgIMbG9+ICAy6sandv/xdlqi4GjLCi6gksIomCQdkrAGVfHFXSFOY8PPpupgwi4uSGA==", + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/browserstack-node-sdk/-/browserstack-node-sdk-1.49.1.tgz", + "integrity": "sha512-nb5O2rO8Zww339KagvsLATJ4KaUokItpptZn9C0BT3NFQcmAWm0Rj+MkHqV0FXw2pRRc0CE6+im7FZnqPjDjCQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@google-cloud/compute": "^4.0.1", @@ -2219,6 +2200,7 @@ "google-protobuf": "^3.20.1", "googleapis": "^126.0.1", "got": "^11.8.6", + "https-proxy-agent": "^5.0.1", "jest-worker": "^28.1.0", "js-yaml": "^4.1.0", "js-yaml-cloudformation-schema": "^1.0.0", @@ -2235,7 +2217,7 @@ "update-notifier": "6.0.2", "uuid": "^8.3.2", "windows-release": "^5.1.0", - "winston": "^3.8.2", + "winston": "^3.18.3", "winston-transport": "^4.5.0", "ws": "^8.17.1", "yargs": "^17.5.1", @@ -2278,6 +2260,7 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -2504,13 +2487,16 @@ } }, "node_modules/color": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", - "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", "license": "MIT", "dependencies": { - "color-convert": "^1.9.3", - "color-string": "^1.6.0" + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" } }, "node_modules/color-convert": { @@ -2532,38 +2518,45 @@ "license": "MIT" }, "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", "license": "MIT", "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" } }, "node_modules/color/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", "license": "MIT", "dependencies": { - "color-name": "1.1.3" + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" } }, "node_modules/color/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/colorspace": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", - "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", "license": "MIT", - "dependencies": { - "color": "^3.1.3", - "text-hex": "1.0.x" + "engines": { + "node": ">=12.20" } }, "node_modules/combined-stream": { @@ -2583,6 +2576,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" } @@ -2655,15 +2649,17 @@ "license": "MIT" }, "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "dev": true, - "dependencies": { - "safe-buffer": "5.2.1" - }, + "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -2671,6 +2667,7 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -2680,6 +2677,7 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -2689,6 +2687,7 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.6.0" } @@ -2704,6 +2703,7 @@ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", "dev": true, + "license": "MIT", "dependencies": { "object-assign": "^4", "vary": "^1" @@ -2816,15 +2816,15 @@ } }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "license": "MIT" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2952,6 +2952,7 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -3095,7 +3096,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/emittery": { "version": "0.11.0", @@ -3126,6 +3128,7 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -3140,9 +3143,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", - "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3241,7 +3244,8 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/escape-string-regexp": { "version": "4.0.0", @@ -3277,25 +3281,24 @@ } }, "node_modules/eslint": { - "version": "9.32.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", - "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.0", - "@eslint/core": "^0.15.0", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.32.0", - "@eslint/plugin-kit": "^0.3.4", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", @@ -3354,28 +3357,31 @@ } }, "node_modules/eslint-config-airbnb-extended": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/eslint-config-airbnb-extended/-/eslint-config-airbnb-extended-2.1.2.tgz", - "integrity": "sha512-hcph8OvzwNfLifdw1nPBYFTsJwWF1ESf7JgYwpe0u4ZWflsfUE9EonDNAk9dGEJqkS3PDBGaVhf1u60uF7coTg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/eslint-config-airbnb-extended/-/eslint-config-airbnb-extended-2.3.3.tgz", + "integrity": "sha512-1p/dQedg2lzPx/PlX5EpVlIY1UvZ5eflrO18rGs9DbhmVKCRv4/47irOekmPrrEGAZhbqrcmpzJIA+DE6yzHTQ==", "dev": true, "license": "MIT", "dependencies": { "confusing-browser-globals": "^1.0.11", - "globals": "^16.3.0" + "globals": "^16.5.0" + }, + "engines": { + "node": ">=16.0.0" }, "peerDependencies": { - "@next/eslint-plugin-next": "15.x", - "@stylistic/eslint-plugin": "3.x", - "@types/eslint-plugin-jsx-a11y": "6.x", - "eslint": "9.x", - "eslint-import-resolver-typescript": "4.x", - "eslint-plugin-import": "2.x", - "eslint-plugin-import-x": "4.x", - "eslint-plugin-jsx-a11y": "6.x", - "eslint-plugin-n": "17.x", - "eslint-plugin-react": "7.x", - "eslint-plugin-react-hooks": "5.x", - "typescript-eslint": "8.x" + "@next/eslint-plugin-next": "^15.0.0 || ^16.0.0", + "@stylistic/eslint-plugin": "^3.0.0", + "@types/eslint-plugin-jsx-a11y": "^6.0.0", + "eslint": "^9.0.0", + "eslint-import-resolver-typescript": "^4.0.0", + "eslint-plugin-import": "^2.0.0", + "eslint-plugin-import-x": "^4.0.0", + "eslint-plugin-jsx-a11y": "^6.0.0", + "eslint-plugin-n": "^17.0.0", + "eslint-plugin-react": "^7.0.0", + "eslint-plugin-react-hooks": "^5.0.0 || ^6.0.0 || ^7.0.0", + "typescript-eslint": "^8.0.0" }, "peerDependenciesMeta": { "@next/eslint-plugin-next": { @@ -3551,26 +3557,10 @@ } } }, - "node_modules/eslint-plugin-import-x/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/eslint-plugin-n": { - "version": "17.21.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.21.3.tgz", - "integrity": "sha512-MtxYjDZhMQgsWRm/4xYLL0i2EhusWT7itDxlJ80l1NND2AL2Vi5Mvneqv/ikG9+zpran0VsVRXTEHrpLmUZRNw==", + "version": "17.23.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.23.2.tgz", + "integrity": "sha512-RhWBeb7YVPmNa2eggvJooiuehdL76/bbfj/OJewyoGT80qn5PXdz8zMOTO6YHOsI7byPt7+Ighh/i/4a5/v7hw==", "dev": true, "license": "MIT", "dependencies": { @@ -3608,14 +3598,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.3.tgz", - "integrity": "sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==", + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", "dev": true, "license": "MIT", "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.11.7" + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -3748,9 +3738,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3796,6 +3786,7 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -3833,11 +3824,21 @@ "node": ">=0.4.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "dev": true, + "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" }, @@ -3846,12 +3847,13 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.3.tgz", - "integrity": "sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", "dev": true, + "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=18.0.0" } }, "node_modules/execa": { @@ -3890,18 +3892,20 @@ } }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, + "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -3936,6 +3940,7 @@ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 16" }, @@ -3946,27 +3951,6 @@ "express": ">= 4.11" } }, - "node_modules/express/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3992,36 +3976,6 @@ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4037,9 +3991,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "funding": [ { "type": "github", @@ -4052,16 +4006,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -4071,6 +4015,24 @@ "pend": "~1.2.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -4090,24 +4052,12 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", @@ -4117,7 +4067,11 @@ "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/find-up": { @@ -4165,9 +4119,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -4200,9 +4154,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -4224,11 +4178,33 @@ "node": ">= 14.17" } }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -4238,6 +4214,7 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -4342,6 +4319,15 @@ "node": ">=14" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -4404,9 +4390,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", - "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4496,6 +4482,18 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/global-agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", @@ -4538,9 +4536,9 @@ } }, "node_modules/globals": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", - "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", "engines": { @@ -4727,13 +4725,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/gtoken": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", @@ -4825,6 +4816,17 @@ "integrity": "sha512-DAzV5P/pk3wTU/8TLZN+zFTDv4Xa1QDTU8pRvovPetcOMbmqq8CwsAvZBLPZHH6usxyy31zMp7I4aCYb6XIf6w==", "license": "MIT" }, + "node_modules/hono": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hpagent": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", @@ -4860,28 +4862,24 @@ "license": "BSD-2-Clause" }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "dev": true, + "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "engines": { - "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-proxy-agent": { @@ -4942,15 +4940,20 @@ } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "dev": true, + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ieee754": { @@ -5028,14 +5031,10 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, "engines": { "node": ">= 12" } @@ -5045,6 +5044,7 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.10" } @@ -5065,12 +5065,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "license": "MIT" - }, "node_modules/is-bun-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", @@ -5125,13 +5119,14 @@ } }, "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, @@ -5172,9 +5167,9 @@ } }, "node_modules/is-npm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.0.0.tgz", - "integrity": "sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", + "integrity": "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==", "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -5183,16 +5178,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-obj": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", @@ -5215,7 +5200,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/is-regex": { "version": "1.2.1", @@ -5334,9 +5320,9 @@ } }, "node_modules/jiti": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", - "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", "bin": { @@ -5353,18 +5339,18 @@ } }, "node_modules/jose": { - "version": "6.0.12", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.12.tgz", - "integrity": "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5392,9 +5378,9 @@ } }, "node_modules/js-yaml-cloudformation-schema/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -5422,12 +5408,6 @@ "js-yaml": "4.x" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "license": "MIT" - }, "node_modules/jsep": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", @@ -5458,6 +5438,13 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -5501,12 +5488,12 @@ } }, "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -5700,6 +5687,7 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -5709,6 +5697,7 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -5722,38 +5711,15 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz", - "integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", "dev": true, "funding": [ "https://github.com/sponsors/broofa" ], + "license": "MIT", "bin": { "mime": "bin/cli.js" }, @@ -5762,24 +5728,30 @@ } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mimic-fn": { @@ -5801,15 +5773,19 @@ } }, "node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -5834,9 +5810,9 @@ "license": "MIT" }, "node_modules/napi-postinstall": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.2.tgz", - "integrity": "sha512-tWVJxJHmBWLy69PvO96TZMZDrzmw5KeiZBz3RHmiM2XZ9grBJ2WgMAFVVg25nqp3ZjTFUs2Ftw1JhscL3Teliw==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", "dev": true, "license": "MIT", "bin": { @@ -5861,6 +5837,7 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -5952,9 +5929,9 @@ } }, "node_modules/oauth4webapi": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.6.0.tgz", - "integrity": "sha512-OwXPTXjKPOldTpAa19oksrX9TYHA0rt+VcUFTkJ7QKwgmevPpNm9Cn5vFZUtIo96FiU6AfPuUUGzoXqgOzibWg==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.3.tgz", + "integrity": "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -5965,6 +5942,7 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -6004,6 +5982,7 @@ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "dev": true, + "license": "MIT", "dependencies": { "ee-first": "1.1.1" }, @@ -6045,13 +6024,13 @@ } }, "node_modules/openid-client": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.6.2.tgz", - "integrity": "sha512-Xya5TNMnnZuTM6DbHdB4q0S3ig2NTAELnii/ASie1xDEr8iiB8zZbO871OWBdrw++sd3hW6bqWjgcmSy1RTWHA==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.1.tgz", + "integrity": "sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==", "license": "MIT", "dependencies": { - "jose": "^6.0.11", - "oauth4webapi": "^3.5.4" + "jose": "^6.1.0", + "oauth4webapi": "^3.8.2" }, "funding": { "url": "https://github.com/sponsors/panva" @@ -6313,9 +6292,9 @@ } }, "node_modules/package-json/node_modules/normalize-url": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.2.tgz", - "integrity": "sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", + "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", "license": "MIT", "engines": { "node": ">=14.16" @@ -6403,6 +6382,7 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -6436,12 +6416,14 @@ } }, "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "dev": true, - "engines": { - "node": ">=16" + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/pause-stream": { @@ -6463,34 +6445,36 @@ "license": "MIT" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=16.20.0" } }, "node_modules/playwright": { - "version": "1.54.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz", - "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==", + "version": "1.55.0-alpha-1752701791000", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-1752701791000.tgz", + "integrity": "sha512-PA3TvDz7uQ+Pde0uaii5/WpU5vntRJsYFsaSPoBzywIqzYFO1ugk1ZZ0q6z4/xHq0ha1UClvsv3P77B+u1fi+w==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.54.1" + "playwright-core": "1.55.0-alpha-1752701791000" }, "bin": { "playwright": "cli.js" @@ -6503,9 +6487,10 @@ } }, "node_modules/playwright-core": { - "version": "1.54.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz", - "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==", + "version": "1.55.0-alpha-1752701791000", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-1752701791000.tgz", + "integrity": "sha512-mQhzhjJMiqnGNnYZv7M4yk1OcNTt1E72jrTLO7EqZuoeat4+qpcU0/mbK+RcTEass5a9YheoVFh6OIhruFMGVg==", + "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -6534,9 +6519,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", "bin": { @@ -6550,9 +6535,9 @@ } }, "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", "dev": true, "license": "MIT", "dependencies": { @@ -6598,9 +6583,9 @@ } }, "node_modules/protobufjs": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", - "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -6639,6 +6624,7 @@ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "dev": true, + "license": "MIT", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -6679,9 +6665,9 @@ "license": "MIT" }, "node_modules/pupa": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz", - "integrity": "sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", + "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", "license": "MIT", "dependencies": { "escape-goat": "^4.0.0" @@ -6694,9 +6680,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -6717,27 +6703,6 @@ "node": ">=0.4.x" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -6755,23 +6720,25 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "dev": true, + "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/rc": { @@ -6789,6 +6756,15 @@ "rc": "cli.js" } }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -6812,6 +6788,18 @@ "minimatch": "^5.1.0" } }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/reconnecting-websocket": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz", @@ -6924,17 +6912,6 @@ "node": ">=14" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rfc4648": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", @@ -7019,6 +6996,7 @@ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", @@ -7030,30 +7008,6 @@ "node": ">= 18" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -7104,7 +7058,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/sax": { "version": "1.2.1", @@ -7113,9 +7068,9 @@ "license": "ISC" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7146,46 +7101,30 @@ } }, "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "dev": true, + "license": "MIT", "dependencies": { - "debug": "^4.3.5", + "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", - "statuses": "^2.0.1" + "statuses": "^2.0.2" }, "engines": { "node": ">= 18" - } - }, - "node_modules/send/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "dependencies": { - "mime-db": "^1.54.0" }, - "engines": { - "node": ">= 0.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/serialize-error": { @@ -7204,10 +7143,11 @@ } }, "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "dev": true, + "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", @@ -7216,6 +7156,10 @@ }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/set-function-length": { @@ -7239,7 +7183,8 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -7340,15 +7285,6 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/slice-ansi": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", @@ -7377,12 +7313,12 @@ } }, "node_modules/socks": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.6.tgz", - "integrity": "sha512-pe4Y2yzru68lXCb38aAqRf5gvN8YdjP1lok5o0J7BOHljkyCGKVz7H3vpVIXKD27rj2giOJ7DwVyk/GWrPHDWA==", + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "license": "MIT", "dependencies": { - "ip-address": "^9.0.5", + "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -7465,6 +7401,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -7503,16 +7440,14 @@ "license": "MIT" }, "node_modules/streamx": { - "version": "2.22.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", - "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", "license": "MIT", "dependencies": { + "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" } }, "node_modules/strict-event-emitter": { @@ -7566,12 +7501,16 @@ } }, "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/stubs": { @@ -7593,9 +7532,9 @@ } }, "node_modules/synckit": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", - "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7625,19 +7564,23 @@ } }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar-fs": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz", - "integrity": "sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -7736,14 +7679,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -7752,61 +7695,21 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "license": "MIT", "engines": { "node": ">=14.14" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.6" } @@ -7827,9 +7730,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -7862,19 +7765,6 @@ "typescript": ">=4.0.0" } }, - "node_modules/ts-declaration-location/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -7911,6 +7801,7 @@ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "dev": true, + "license": "MIT", "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", @@ -7920,27 +7811,6 @@ "node": ">= 0.6" } }, - "node_modules/type-is/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/type-is/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -7951,9 +7821,9 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -7966,16 +7836,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.38.0.tgz", - "integrity": "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.53.0.tgz", + "integrity": "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.38.0", - "@typescript-eslint/parser": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/utils": "8.38.0" + "@typescript-eslint/eslint-plugin": "8.53.0", + "@typescript-eslint/parser": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/utils": "8.53.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7986,7 +7856,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/undici-types": { @@ -8015,6 +7885,7 @@ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -8083,9 +7954,9 @@ } }, "node_modules/update-notifier/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -8163,6 +8034,7 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -8235,9 +8107,9 @@ } }, "node_modules/widest-line/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -8270,9 +8142,9 @@ } }, "node_modules/widest-line/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -8300,13 +8172,13 @@ } }, "node_modules/winston": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", - "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", "dependencies": { "@colors/colors": "^1.6.0", - "@dabh/diagnostics": "^2.0.2", + "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", @@ -8363,9 +8235,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -8375,9 +8247,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -8410,9 +8282,9 @@ } }, "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -8443,9 +8315,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -8579,12 +8451,13 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.24.6", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", - "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "dev": true, + "license": "ISC", "peerDependencies": { - "zod": "^3.24.1" + "zod": "^3.25 || ^4" } } } diff --git a/package.json b/package.json index 06e1194..7352bca 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,22 @@ { "name": "webuitests", "version": "1.0.0", - "description": "Tidepool UI Testing with playwright and browserstack", + "description": "Tidepool UI Testing with playwright", "main": "index.js", "scripts": { - "merge-reports": "jrm tests_output/testresults.xml \"tests_output/e2e/*.xml\" \"tests_output/ui/*.xml\"", "lint": "eslint --ext .ts . --max-warnings 999999", "lint:fix": "eslint --ext .ts . --fix", "typecheck": "tsc --noEmit", "check": "npm run lint && npm run typecheck", - "debug": "node utilities/test-runner.js --debug", - "test": "node utilities/test-runner.js", - "test:qa1": "TARGET_ENV=qa1 node utilities/test-runner.js", - "test:qa2": "TARGET_ENV=qa2 node utilities/test-runner.js", - "test:qa3": "TARGET_ENV=qa3 node utilities/test-runner.js", - "test:qa4": "TARGET_ENV=qa4 node utilities/test-runner.js", - "test:prd": "TARGET_ENV=prd node utilities/test-runner.js", - "test:int": "TARGET_ENV=int node utilities/test-runner.js", - "test:smoke": "TEST_TAGS='@smoke' node utilities/test-runner.js", - "test:critical": "TEST_TAGS='@critical' node utilities/test-runner.js", - "test:api": "TEST_TAGS='@api' node utilities/test-runner.js", - "test:ui": "TEST_TAGS='@ui' node utilities/test-runner.js", - "test:patient": "TEST_TAGS='@patient' node utilities/test-runner.js", - "test:clinician": "TEST_TAGS='@clinician' node utilities/test-runner.js", - "upload-to-xray": "node utilities/upload-to-xray.js", + "debug": "npx playwright test --debug", + "test": "npx playwright test", + "test:smoke": "npx playwright test --grep @smoke", + "test:critical": "npx playwright test --grep @critical", + "test:api": "npx playwright test --grep @api", + "test:ui": "npx playwright test --grep @ui", + "test:patient": "npx playwright test --grep @patient", + "test:clinician": "npx playwright test --grep @clinician", + "test:regression": "npx playwright test --grep @regression", "build": "tsc", "format": "prettier --write ." }, diff --git a/playwright.config.ts b/playwright.config.ts index b43418e..6bc3197 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,14 +2,6 @@ import { defineConfig, devices } from '@playwright/test'; import path from 'node:path'; import env from './utilities/env'; -// Legacy XML options - can be removed when fully migrated to JSON -const xrayOptions = { - embedAnnotationsAsProperties: true, - textContentAnnotations: ['test_description', 'testrun_comment'], - embedAttachmentsAsProperty: 'testrun_evidence', - outputFile: 'test-output/test-results.xml', -}; - // Helper to detect BrowserStack run const isBrowserStack = Boolean( process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESS_KEY, @@ -46,7 +38,6 @@ export default defineConfig({ reporter: [ ['html', { open: 'never', outputFolder: 'playwright-report' }], ['json', { outputFile: 'test-results/last-run.json' }], - ['junit', xrayOptions], ['./utilities/xray-json-reporter.ts'], ], diff --git a/tests/global-setup.ts b/tests/global-setup.ts index 17b9b53..2675952 100644 --- a/tests/global-setup.ts +++ b/tests/global-setup.ts @@ -7,29 +7,63 @@ import env from '../utilities/env'; async function loginUserType(role: 'personal' | 'claimed' | 'shared' | 'clinician') { const browser = await chromium.launch(); const context = await browser.newContext({ - baseURL: process.env.BASE_URL, + baseURL: env.BASE_URL, }); const page = await context.newPage(); - await page.goto(env.BASE_URL); - const loginPage = new LoginPage(page); - if (role === 'personal') { - await loginPage.login(env.PERSONAL_USERNAME, env.PERSONAL_PASSWORD); - await page.waitForURL('**/data'); - } else if (role === 'claimed') { - await loginPage.login(env.CLAIMED_USERNAME, env.CLAIMED_PASSWORD); - await page.waitForURL('**/data'); - } else if (role === 'shared') { - await loginPage.login(env.SHARED_USERNAME, env.SHARED_PASSWORD); - await page.waitForURL('**/data'); - } else { - await loginPage.login(env.CLINICIAN_USERNAME, env.CLINICIAN_PASSWORD); - await page.waitForURL('**/workspaces'); - } - const authDir = path.resolve(process.cwd(), 'tests', '.auth'); - await fs.promises.mkdir(authDir, { recursive: true }); - const filePath = path.join(authDir, `${role}.json`); - await context.storageState({ path: filePath }); + try { + console.log(`\nšŸ” Authenticating ${role} user on ${env.BASE_URL}...`); + await page.goto(env.BASE_URL); + const loginPage = new LoginPage(page); + + let username: string; + let password: string; + let expectedURL: string; + + if (role === 'personal') { + username = env.PERSONAL_USERNAME; + password = env.PERSONAL_PASSWORD; + expectedURL = '**/data'; + } else if (role === 'claimed') { + username = env.CLAIMED_USERNAME; + password = env.CLAIMED_PASSWORD; + expectedURL = '**/data'; + } else if (role === 'shared') { + username = env.SHARED_USERNAME; + password = env.SHARED_PASSWORD; + expectedURL = '**/data'; + } else { + username = env.CLINICIAN_USERNAME; + password = env.CLINICIAN_PASSWORD; + expectedURL = '**/workspaces'; + } + + await loginPage.login(username, password); + await page.waitForURL(expectedURL, { timeout: 15000 }); + + const authDir = path.resolve(process.cwd(), 'tests', '.auth'); + await fs.promises.mkdir(authDir, { recursive: true }); + const filePath = path.join(authDir, `${role}.json`); + await context.storageState({ path: filePath }); + + console.log(`āœ… ${role} authentication successful`); + } catch (error) { + console.error(`\nāŒ GLOBAL SETUP FAILED: Unable to authenticate ${role} user`); + console.error(`\nPossible causes:`); + console.error(` 1. Invalid credentials for ${role} user`); + console.error(` 2. Wrong environment (currently: ${env.TARGET_ENV} -> ${env.BASE_URL})`); + console.error(` 3. User account doesn't exist on this environment`); + console.error(` 4. Network issues or environment is down`); + console.error(`\nPlease verify:`); + console.error(` - TARGET_ENV in .env file is set to the correct environment`); + console.error(` - Credentials in .env file match the environment`); + console.error(` - The ${env.BASE_URL} environment is accessible\n`); + + await browser.close(); + throw new Error( + `Global setup failed: Could not authenticate ${role} user. Check credentials and environment configuration.`, + ); + } await browser.close(); } diff --git a/tests/personal/login.spec.ts b/tests/personal/login.spec.ts index 20c744e..534f803 100644 --- a/tests/personal/login.spec.ts +++ b/tests/personal/login.spec.ts @@ -3,81 +3,124 @@ import { expect, test } from '@fixtures/base'; import LoginPage from 'page-objects/LoginPage'; import WorkspacesPage from '@pom/clinician/WorkspacesPage'; import env from '../../utilities/env'; +import { TEST_TAGS, createValidatedTags } from '../fixtures/test-tags'; // make sure we don't have any cookies or origins test.use({ storageState: { cookies: [], origins: [] } }); // Possible testcases: https://tidepool.atlassian.net/jira/software/c/projects/WEB/issues/?jql=project%20%3D%20%22WEB%22%20AND%20type%20%3D%20Test%20AND%20textfields%20~%20%22login%22%20ORDER%20BY%20created%20DESC test.describe('Login into application', () => { - test('should work with valid credentials for clinician with multiple clinics', async ({ - page, - }) => { - const loginPage = new LoginPage(page); - - await test.step('When user is logged into application', async () => { - await loginPage.goto(); - await loginPage.login(env.CLINICIAN_USERNAME, env.CLINICIAN_PASSWORD); - }); - - await test.step('Then the user is redirected to workspaces page', async () => { - const workspacesPage = new WorkspacesPage(page); - await page.waitForURL(workspacesPage.url); - - await expect(workspacesPage.header).toBeVisible(); - }); - }); - - test('should show error message with invalid credentials', async ({ page }) => { - const loginPage = new LoginPage(page); - - await test.step('When user attempts to login with invalid credentials', async () => { - await loginPage.goto(); - - // Enter email - await page.fill('#username', 'invalid@email.com'); - await page.click('#kc-login'); - }); - - await test.step('Then error message should be displayed', async () => { - // Wait for the error message to appear - await expect(page.locator('#input-error-username')).toBeVisible(); - await expect(page.locator('#input-error-username')).toContainText( - "This email doesn't belong to an account yet.", - ); - }); - }); - - test('should validate email format', async ({ page }) => { - const loginPage = new LoginPage(page); - - await test.step('When user attempts to login with invalid email format', async () => { - await loginPage.goto(); - - // Enter invalid email format - await page.fill('#username', 'invalidemail'); - await page.click('#kc-login'); - }); - - await test.step('Then email validation error should be displayed', async () => { - // Check for email validation error message - await expect(page.locator('#input-error-username')).toBeVisible(); - await expect(page.locator('#input-error-username')).toContainText( - "This email doesn't belong to an account yet.", - ); - }); - }); - - test('should show error message with invalid credentials 1', async ({ page }) => { - const loginPage = new LoginPage(page); - - await test.step('When user is logged into application', async () => { - await loginPage.goto(); - await loginPage.login(env.CLINICIAN_USERNAME, `${env.CLINICIAN_PASSWORD}1`); - }); - - await test.step('Then error message should be displayed', async () => { - await expect(page.locator('#input-error')).toBeVisible(); - await expect(page.locator('#input-error')).toContainText('Invalid password.'); - }); - }); + test( + 'should work with valid credentials for clinician with multiple clinics', + { + tag: createValidatedTags([ + TEST_TAGS.CLINICIAN, + TEST_TAGS.UI, + TEST_TAGS.SMOKE, + TEST_TAGS.CRITICAL, + ]), + }, + async ({ page }) => { + const loginPage = new LoginPage(page); + + await test.step('When user is logged into application', async () => { + await loginPage.goto(); + await loginPage.login(env.CLINICIAN_USERNAME, env.CLINICIAN_PASSWORD); + }); + + await test.step('Then the user is redirected to workspaces page', async () => { + const workspacesPage = new WorkspacesPage(page); + await page.waitForURL(workspacesPage.url); + + await expect(workspacesPage.header).toBeVisible(); + }); + }, + ); + + test( + 'should show error message with invalid credentials', + { + tag: createValidatedTags([ + TEST_TAGS.CLINICIAN, + TEST_TAGS.UI, + TEST_TAGS.SMOKE, + TEST_TAGS.HIGH, + ]), + }, + async ({ page }) => { + const loginPage = new LoginPage(page); + + await test.step('When user attempts to login with invalid username', async () => { + await loginPage.goto(); + + // Enter email + await page.fill('#username', 'invalid@email.com'); + await page.click('#kc-login'); + }); + + await test.step('Then error message should be displayed', async () => { + // Wait for the error message to appear + await expect(page.locator('#input-error-username')).toBeVisible(); + await expect(page.locator('#input-error-username')).toContainText( + "This email doesn't belong to an accountz yet.", + ); + }); + }, + ); + + test( + 'should validate email format', + { + tag: createValidatedTags([ + TEST_TAGS.CLINICIAN, + TEST_TAGS.UI, + TEST_TAGS.REGRESSION, + TEST_TAGS.MEDIUM, + ]), + }, + async ({ page }) => { + const loginPage = new LoginPage(page); + + await test.step('When user attempts to login with unrecognized email', async () => { + await loginPage.goto(); + + // Enter unrecognized email + await page.fill('#username', 'invalidemail'); + await page.click('#kc-login'); + }); + + await test.step('Then email validation error should be displayed', async () => { + // Check for email validation error message + await expect(page.locator('#input-error-username')).toBeVisible(); + await expect(page.locator('#input-error-username')).toContainText( + "This email doesn't belong to an account yet.", + ); + }); + }, + ); + + test( + 'should show error message with invalid password', + { + tag: createValidatedTags([ + TEST_TAGS.CLINICIAN, + TEST_TAGS.UI, + TEST_TAGS.SMOKE, + TEST_TAGS.HIGH, + ]), + }, + async ({ page }) => { + const loginPage = new LoginPage(page); + + await test.step('When user is logged into application', async () => { + await loginPage.goto(); + await loginPage.login(env.CLINICIAN_USERNAME, `${env.CLINICIAN_PASSWORD}1`); + }); + + await test.step('Then error message should be displayed', async () => { + await expect(page.locator('#input-error')).toBeVisible(); + await expect(page.locator('#input-error')).toContainText('Invalid password.'); + }); + }, + ); }); diff --git a/utilities/env.ts b/utilities/env.ts index 9323afe..d246243 100644 --- a/utilities/env.ts +++ b/utilities/env.ts @@ -17,6 +17,9 @@ const envSchema = z.object({ TARGET_ENV: z.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production', 'prd', 'int']), XRAY_CLIENT_ID: z.string().optional(), XRAY_CLIENT_SECRET: z.string().optional(), + XRAY_PROJECT_KEY: z.string().default('SAND'), + JIRA_EMAIL: z.string().optional(), + JIRA_API_KEY: z.string().optional(), }); const env = envSchema.safeParse(process.env); diff --git a/utilities/test-runner.js b/utilities/test-runner.js index 2789dee..9b8673e 100644 --- a/utilities/test-runner.js +++ b/utilities/test-runner.js @@ -6,11 +6,17 @@ * - TEST_TAGS environment variable (space or comma separated) * - Command line arguments for additional Playwright flags * + * Tag Filtering Logic: + * - Uses Playwright's --grep-tag flag to filter tests by tag metadata + * - Space-separated tags = AND logic (test must have ALL tags) + * - Comma-separated tags = OR logic (test must have ANY tag) + * * Usage: * node utilities/test-runner.js # Run all tests on qa1 * TARGET_ENV=qa2 node utilities/test-runner.js # Run all tests on qa2 - * TEST_TAGS="@smoke @critical" node utilities/test-runner.js # Run smoke AND critical tests - * TEST_TAGS="@api,@ui" node utilities/test-runner.js # Run api OR ui tests (comma-separated = OR) + * TEST_TAGS="@smoke" node utilities/test-runner.js # Run smoke tests + * TEST_TAGS="@smoke @critical" node utilities/test-runner.js # Run tests with BOTH smoke AND critical tags + * TEST_TAGS="@api,@ui" node utilities/test-runner.js # Run tests with EITHER api OR ui tags * node utilities/test-runner.js --debug # Pass additional flags to Playwright */ @@ -49,7 +55,7 @@ function buildGrepArgs(tags) { } if (tagList.length === 1) { - // Single tag: simple grep + // Single tag: simple grep with @ prefix return ['--grep', `@${tagList[0]}`]; } @@ -62,6 +68,7 @@ function buildGrepArgs(tags) { return ['--grep', orPattern]; } // Space-separated = AND logic: (?=.*@tag1)(?=.*@tag2)(?=.*@tag3) + // Uses positive lookahead regex for AND logic const andPattern = tagList.map(tag => `(?=.*@${tag})`).join(''); return ['--grep', andPattern]; } diff --git a/utilities/upload-to-xray.js b/utilities/upload-to-xray.js deleted file mode 100644 index f635b1e..0000000 --- a/utilities/upload-to-xray.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Standalone utility to upload Playwright JSON results to Xray - * Usage: node utilities/upload-to-xray.js [path-to-json-results] - */ - -const fs = require('node:fs'); -const path = require('node:path'); - -// Import the compiled TypeScript reporter -async function uploadResults() { - try { - // Import compiled CommonJS module - // eslint-disable-next-line n/global-require, import-x/extensions - const XrayJsonReporter = require('../build/utilities/xray-json-reporter.js').default; - - const jsonPath = process.argv[2] || 'test-results/last-run.json'; - - if (!fs.existsSync(jsonPath)) { - console.error(`āŒ JSON results file not found: ${jsonPath}`); - // eslint-disable-next-line n/no-process-exit - process.exit(1); - } - - console.log(`šŸš€ Processing Playwright results from: ${jsonPath}`); - - const reporter = new XrayJsonReporter(); - await reporter.processAndUpload(jsonPath); - - console.log('āœ… Xray upload completed successfully'); - } catch (error) { - console.error('āŒ Failed to upload to Xray:', error); - // eslint-disable-next-line n/no-process-exit - process.exit(1); - } -} - -uploadResults(); diff --git a/utilities/xray-json-reporter.ts b/utilities/xray-json-reporter.ts index 9e37d3d..1a5d60e 100644 --- a/utilities/xray-json-reporter.ts +++ b/utilities/xray-json-reporter.ts @@ -2,78 +2,44 @@ import fs from 'node:fs'; import path from 'node:path'; import { FullConfig, FullResult, Suite, TestCase, TestResult } from '@playwright/test/reporter'; import env from './env'; - -interface XrayTestStep { - action: string; - data?: string; - result?: string; - status: 'PASS' | 'FAIL' | 'PENDING'; - actualResult?: string; - evidences?: { - data: string; - filename: string; - contentType: string; - }[]; -} - -interface XrayTest { - testKey?: string; - testInfo: { - summary: string; - type: 'Manual' | 'Cucumber' | 'Generic'; - projectKey: string; - labels?: string[]; - }; - status: 'PASS' | 'FAIL' | 'PENDING' | 'EXECUTING'; - comment?: string; - evidences?: { - data: string; - filename: string; - contentType: string; - }[]; - steps?: XrayTestStep[]; - examples?: string[]; -} - -interface XrayExecutionResult { - info: { - summary: string; - description: string; - version?: string; - testPlanKey?: string; - testExecutionKey?: string; - startDate: string; - finishDate: string; - testEnvironments?: string[]; - }; - tests: XrayTest[]; -} +import { + XrayTestStepDefinition, + XrayTestStepResult, + XrayTest, + XrayExecutionResult, + XrayEvidence, + XrayImportResponse, +} from './xray-types'; /** - * Unified Xray JSON Reporter for Playwright - * Maps rich Playwright test data to Xray's JSON format with step-by-step evidence + * Xray JSON Reporter for Playwright + * Maps Playwright test data to Xray Cloud JSON format and uploads results */ class XrayJsonReporter { private styles = { - success: 'āœ…', - error: 'āŒ', - info: 'ā„¹ļø', - warning: 'ā›”ļø', - upload: 'šŸš€', - test: '🧪', - separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + success: '\u2705', + error: '\u274C', + info: '\u2139\uFE0F', + warning: '\u26A0\uFE0F', + upload: '\uD83D\uDE80', + test: '\uD83E\uDDEA', + evidence: '\uD83D\uDCCE', + separator: + '\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501', }; - private startTime = ''; - - private endTime = ''; - /** * Authenticates with Xray API using client credentials */ async authenticateWithXray(): Promise { + const startAuth = Date.now(); try { - console.log(`${this.styles.info} Authenticating with Xray...`); + console.log(`${this.styles.info} Authenticating with Xray Cloud API...`); + + if (!env.XRAY_CLIENT_ID || !env.XRAY_CLIENT_SECRET) { + throw new Error('XRAY_CLIENT_ID and XRAY_CLIENT_SECRET are required for authentication'); + } + const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { method: 'POST', headers: { @@ -87,12 +53,23 @@ class XrayJsonReporter { if (!response.ok) { const errorText = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + throw new Error( + `Authentication failed (HTTP ${response.status}): ${errorText || 'No error details'}`, + ); } const token = await response.text(); - console.log(`${this.styles.success} Successfully authenticated with Xray`); - return token.replace(/"/g, ''); // Remove quotes from token + const cleanToken = token.replace(/"/g, ''); + + if (!cleanToken || cleanToken.length < 10) { + throw new Error(`Invalid token received: ${cleanToken.substring(0, 20)}...`); + } + + const authDuration = Date.now() - startAuth; + console.log( + `${this.styles.success} Successfully authenticated with Xray (${authDuration}ms)`, + ); + return cleanToken; } catch (error) { console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); throw error; @@ -100,12 +77,13 @@ class XrayJsonReporter { } /** - * Maps Playwright test status to Xray status + * Maps Playwright test status to Xray Cloud status + * Note: Xray Cloud uses PASSED/FAILED, Xray Server uses PASS/FAIL */ - private getTestStatus(status: string): 'PASS' | 'FAIL' | 'PENDING' { - if (status === 'passed') return 'PASS'; - if (status === 'skipped') return 'PENDING'; - return 'FAIL'; + private getTestStatus(status: string): 'PASSED' | 'FAILED' | 'TODO' | 'EXECUTING' { + if (status === 'passed') return 'PASSED'; + if (status === 'skipped') return 'TODO'; + return 'FAILED'; } /** @@ -122,44 +100,175 @@ class XrayJsonReporter { } /** - * Extracts step information from test annotations + * Determines if an attachment should be included as evidence + * Videos are only included for failed tests; other files check size threshold */ - private async extractSteps(annotations: any[], attachments: any[]): Promise { - const steps: XrayTestStep[] = []; - const stepAnnotations = annotations.filter(ann => ann.type.startsWith('Step Duration:')); + private shouldIncludeEvidence(attachment: any, testStatus: string, contentType: string): boolean { + const filePath = attachment.path; + if (!filePath || !fs.existsSync(filePath)) { + return false; + } - for (const stepAnn of stepAnnotations) { - const stepName = stepAnn.type.replace('Step Duration: ', ''); - const duration = stepAnn.description; + // Videos: Only for failed tests + if (contentType.includes('video')) { + return testStatus !== 'passed'; + } - // Find associated step attachments + return true; + } + + private isGivenStep(stepName: string): boolean { + return stepName.toLowerCase().startsWith('given '); + } + + private isWhenStep(stepName: string): boolean { + return stepName.toLowerCase().startsWith('when '); + } + + private isThenStep(stepName: string): boolean { + const lower = stepName.toLowerCase(); + return lower.startsWith('then ') || lower.startsWith('and '); + } + + private parseDuration(duration: string): number { + const match = duration.match(/(\d+)/); + return match ? parseInt(match[1], 10) : 0; + } + + /** + * Collects inline evidence for given step indices + */ + private async collectStepEvidence( + indices: number[], + attachments: any[], + testStatus: string, + ): Promise { + const evidence: XrayEvidence[] = []; + + for (const stepIndex of indices) { + const stepNumber = stepIndex + 1; + const stepPattern = `step-${stepNumber.toString().padStart(2, '0')}`; const stepAttachments = attachments.filter(att => - att.name.toLowerCase().includes(stepName.toLowerCase().substring(0, 20)), + att.name.toLowerCase().includes(stepPattern), ); - const step: XrayTestStep = { - action: stepName, - data: `Duration: ${duration}`, - result: stepName.includes('Then') ? stepName : undefined, - status: 'PASS', // Will be updated based on test result - evidences: [], - }; - - // Add evidence for this step for (const attachment of stepAttachments) { if (attachment.path && fs.existsSync(attachment.path)) { - step.evidences?.push({ - data: await this.fileToBase64(attachment.path), - filename: path.basename(attachment.path), - contentType: attachment.contentType || 'application/octet-stream', - }); + const contentType = attachment.contentType || 'application/octet-stream'; + + if (this.shouldIncludeEvidence(attachment, testStatus, contentType)) { + const base64Data = await this.fileToBase64(attachment.path); + if (base64Data) { + evidence.push({ + data: base64Data, + filename: path.basename(attachment.path), + contentType, + }); + } + } } } + } + + return evidence; + } - steps.push(step); + /** + * Extracts step information from test annotations with Given/When/Then logic: + * - Given: standalone step (action only) + * - When: step with action, result = all consecutive Then steps that follow + * - Then/And: combined as result of the preceding When step + * + * Returns both step definitions (for testInfo.steps) and step results (for test.steps) + */ + private async extractSteps( + annotations: any[], + attachments: any[], + testStatus: string, + ): Promise<{ + stepDefinitions: XrayTestStepDefinition[]; + stepResults: XrayTestStepResult[]; + }> { + const stepDefinitions: XrayTestStepDefinition[] = []; + const stepResults: XrayTestStepResult[] = []; + const stepAnnotations = annotations.filter(ann => ann.type.startsWith('Step Duration:')); + + if (stepAnnotations.length === 0) { + return { stepDefinitions, stepResults }; } - return steps; + let pendingWhen: { name: string; duration: number; index: number } | null = null; + let pendingThens: { name: string; duration: number; index: number }[] = []; + + const flushPendingWhen = async () => { + if (!pendingWhen) return; + + const stepDef: XrayTestStepDefinition = { + action: pendingWhen.name, + data: `Duration: ${pendingWhen.duration + pendingThens.reduce((sum, t) => sum + t.duration, 0)}ms`, + }; + + if (pendingThens.length > 0) { + stepDef.result = pendingThens.map(t => t.name).join('\n'); + } + + stepDefinitions.push(stepDef); + + const stepResult: XrayTestStepResult = { + status: 'PASSED', + }; + + const allIndices = [pendingWhen.index, ...pendingThens.map(t => t.index)]; + const evidence = await this.collectStepEvidence(allIndices, attachments, testStatus); + if (evidence.length > 0) { + stepResult.evidence = evidence; + } + + stepResults.push(stepResult); + pendingWhen = null; + pendingThens = []; + }; + + const addStandaloneStep = async (stepName: string, duration: number, index: number) => { + stepDefinitions.push({ + action: stepName, + data: `Duration: ${duration}ms`, + }); + + const stepResult: XrayTestStepResult = { + status: 'PASSED', + }; + + const evidence = await this.collectStepEvidence([index], attachments, testStatus); + if (evidence.length > 0) { + stepResult.evidence = evidence; + } + + stepResults.push(stepResult); + }; + + for (let i = 0; i < stepAnnotations.length; i += 1) { + const stepAnn = stepAnnotations[i]; + const stepName = stepAnn.type.replace('Step Duration: ', ''); + const duration = this.parseDuration(stepAnn.description); + + if (this.isGivenStep(stepName)) { + await flushPendingWhen(); + await addStandaloneStep(stepName, duration, i); + } else if (this.isWhenStep(stepName)) { + await flushPendingWhen(); + pendingWhen = { name: stepName, duration, index: i }; + } else if (this.isThenStep(stepName)) { + pendingThens.push({ name: stepName, duration, index: i }); + } else { + await flushPendingWhen(); + await addStandaloneStep(stepName, duration, i); + } + } + + await flushPendingWhen(); + + return { stepDefinitions, stepResults }; } /** @@ -169,48 +278,58 @@ class XrayJsonReporter { testCase: TestCase, testResult: TestResult, ): Promise { - const tags = (testCase as any).tags || []; const annotations = testResult.annotations || []; const attachments = testResult.attachments || []; + const testStatus = testResult.status; - // Extract steps from annotations - const steps = await this.extractSteps(annotations, attachments); + const { stepDefinitions, stepResults } = await this.extractSteps( + annotations, + attachments, + testStatus, + ); - // Mark failed steps if test failed - if (testResult.status !== 'passed' && steps.length > 0) { - steps[steps.length - 1].status = 'FAIL'; - steps[steps.length - 1].actualResult = testResult.error?.message || 'Test failed'; + // Mark last step as failed if test failed + if (testStatus !== 'passed' && stepResults.length > 0) { + stepResults[stepResults.length - 1].status = 'FAILED'; + stepResults[stepResults.length - 1].actualResult = testResult.error?.message || 'Test failed'; } - // Collect test-level evidence (screenshots, videos) - const testEvidences: { data: string; filename: string; contentType: string }[] = []; + // Collect test-level evidence (not step-level) + const testEvidence: XrayEvidence[] = []; + for (const attachment of attachments) { - if (attachment.path && fs.existsSync(attachment.path)) { - // Add main test evidence (final screenshots, videos, etc.) - if (attachment.name.includes('screenshot') || attachment.name.includes('video')) { - testEvidences.push({ - data: await this.fileToBase64(attachment.path), - filename: attachment.name, - contentType: attachment.contentType || 'application/octet-stream', - }); + if ( + attachment.path && + fs.existsSync(attachment.path) && + !attachment.name.toLowerCase().includes('step-') + ) { + const contentType = attachment.contentType || 'application/octet-stream'; + + if (this.shouldIncludeEvidence(attachment, testStatus, contentType)) { + const base64Data = await this.fileToBase64(attachment.path); + if (base64Data) { + testEvidence.push({ + data: base64Data, + filename: attachment.name, + contentType, + }); + } } } } - const xrayTest: XrayTest = { + return { testInfo: { summary: testCase.title, - type: 'Generic', - projectKey: 'XT', // Could be made configurable - labels: tags, + type: 'Manual', + projectKey: env.XRAY_PROJECT_KEY || 'SAND', + steps: stepDefinitions.length > 0 ? stepDefinitions : undefined, }, - status: this.getTestStatus(testResult.status), + status: this.getTestStatus(testStatus), comment: testResult.error?.message, - evidences: testEvidences, - steps: steps.length > 0 ? steps : undefined, + evidence: testEvidence.length > 0 ? testEvidence : undefined, + steps: stepResults.length > 0 ? stepResults : undefined, }; - - return xrayTest; } /** @@ -222,7 +341,6 @@ class XrayJsonReporter { const tests: XrayTest[] = []; - // Process all test suites for (const suite of playwrightResult.suites || []) { await this.processSuite(suite, tests); } @@ -230,30 +348,31 @@ class XrayJsonReporter { const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; const targetEnv = process.env.TARGET_ENV || 'qa1'; - const xrayResult: XrayExecutionResult = { + const passedCount = tests.filter(t => t.status === 'PASSED').length; + const failedCount = tests.filter(t => t.status === 'FAILED').length; + const todoCount = tests.filter(t => t.status === 'TODO').length; + + const hasExistingExecution = testExecKey && testExecKey !== 'none' && testExecKey.trim() !== ''; + + return { + testExecutionKey: hasExistingExecution ? testExecKey : undefined, info: { summary: `Playwright Test Execution - ${new Date().toISOString()}`, - description: `Automated test execution for ${targetEnv} environment`, - version: '1.0', - testExecutionKey: testExecKey !== 'none' ? testExecKey : undefined, + description: `Automated test execution for ${targetEnv} environment\n\nResults: ${passedCount} passed, ${failedCount} failed, ${todoCount} skipped`, startDate: playwrightResult.stats?.startTime || new Date().toISOString(), finishDate: new Date( new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + (playwrightResult.stats?.duration || 0), ).toISOString(), - testEnvironments: [targetEnv], }, tests, }; - - return xrayResult; } /** * Recursively processes test suites */ private async processSuite(suite: any, tests: XrayTest[]): Promise { - // Process specs in this suite for (const spec of suite.specs || []) { for (const test of spec.tests || []) { for (const result of test.results || []) { @@ -263,18 +382,23 @@ class XrayJsonReporter { } } - // Process nested suites for (const nestedSuite of suite.suites || []) { await this.processSuite(nestedSuite, tests); } } /** - * Uploads Xray execution result to Xray + * Uploads Xray execution result to Xray Cloud */ - async uploadToXray(xrayResult: XrayExecutionResult): Promise { + async uploadToXray(xrayResult: XrayExecutionResult): Promise { try { + const uploadStart = Date.now(); + const payloadSizeKB = (JSON.stringify(xrayResult).length / 1024).toFixed(1); + console.log(`${this.styles.info} Uploading test execution to Xray...`); + console.log( + `${this.styles.info} Payload: ${xrayResult.tests.length} tests, ${payloadSizeKB} KB`, + ); const token = await this.authenticateWithXray(); @@ -289,13 +413,18 @@ class XrayJsonReporter { if (!response.ok) { const errorText = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + throw new Error(`Upload failed (HTTP ${response.status}): ${errorText}`); } - const result = await response.json(); + const result: XrayImportResponse = await response.json(); + const uploadDuration = Date.now() - uploadStart; + + console.log(`${this.styles.success} Successfully uploaded to Xray (${uploadDuration}ms)`); console.log( - `${this.styles.success} Successfully uploaded to Xray. Execution Key: ${result.key}`, + `${this.styles.success} Test Execution Key: ${result.testExecIssue?.key || 'N/A'}`, ); + + return result; } catch (error) { console.error(`${this.styles.error} Failed to upload to Xray:`, error); throw error; @@ -312,14 +441,35 @@ class XrayJsonReporter { } try { - console.log(`${this.styles.info} Processing Playwright results...`); + const processStart = Date.now(); + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.info} Processing Playwright results for Xray...`); + console.log(`${this.styles.info} Project Key: ${env.XRAY_PROJECT_KEY || 'SAND'}`); + console.log(`${this.styles.info} Environment: ${process.env.TARGET_ENV || 'qa1'}`); + + const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; + if (testExecKey && testExecKey !== 'none' && testExecKey.trim() !== '') { + console.log(`${this.styles.info} Linking to Test Execution: ${testExecKey}`); + } else { + console.log(`${this.styles.info} Creating new Test Execution`); + } + const xrayResult = await this.convertPlaywrightJsonToXray(playwrightJsonPath); // Save converted result for debugging fs.writeFileSync('test-results/xray-execution.json', JSON.stringify(xrayResult, null, 2)); + console.log(`${this.styles.info} Saved Xray JSON to: test-results/xray-execution.json`); + + if (xrayResult.tests.length === 0) { + console.log(`${this.styles.warning} No tests to upload, skipping Xray upload`); + return; + } await this.uploadToXray(xrayResult); - console.log(`${this.styles.upload} Xray upload completed successfully`); + + const totalDuration = Date.now() - processStart; + console.log(`${this.styles.upload} Xray upload completed successfully (${totalDuration}ms)`); + console.log(`${this.styles.separator}\n`); } catch (error) { console.error(`${this.styles.error} Failed to process and upload:`, error); throw error; @@ -327,10 +477,9 @@ class XrayJsonReporter { } /** - * Reporter lifecycle methods for direct Playwright integration + * Reporter lifecycle methods for Playwright integration */ onBegin(_config: FullConfig, suite: Suite): void { - this.startTime = new Date().toISOString(); console.log(`\n${this.styles.separator}`); console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); console.log(`${this.styles.separator}\n`); @@ -346,7 +495,6 @@ class XrayJsonReporter { } async onEnd(result: FullResult): Promise { - this.endTime = new Date().toISOString(); console.log(`\n${this.styles.separator}`); console.log(`${this.styles.info} Test Run Summary:`); console.log( @@ -355,10 +503,12 @@ class XrayJsonReporter { console.log(`Duration: ${result.duration}ms`); console.log(`${this.styles.separator}\n`); - // Auto-upload if JSON results are available - const jsonPath = 'test-results/last-run.json'; - if (fs.existsSync(jsonPath)) { - await this.processAndUpload(jsonPath); + const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; + if (env.XRAY_CLIENT_ID && env.XRAY_CLIENT_SECRET && testExecKey && testExecKey !== 'none') { + const jsonPath = 'test-results/last-run.json'; + if (fs.existsSync(jsonPath)) { + await this.processAndUpload(jsonPath); + } } } } diff --git a/utilities/xray-json-schema.json b/utilities/xray-json-schema.json new file mode 100644 index 0000000..70d770c --- /dev/null +++ b/utilities/xray-json-schema.json @@ -0,0 +1,392 @@ +{ + "$id": "XraySchema", + "type": "object", + "properties": { + "testExecutionKey": { + "type": "string" + }, + "info": { + "type": "object", + "properties": { + "project": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "version": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "user": { + "type": "string" + }, + "startDate": { + "type": "string", + "format": "date-time" + }, + "finishDate": { + "type": "string", + "format": "date-time" + }, + "testPlanKey": { + "type": "string" + }, + "testEnvironments": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "tests": { + "type": "array", + "items": { + "$ref": "#/definitions/Test" + }, + "minItems": 1 + } + }, + "additionalProperties": false, + + "definitions": { + + "Test": { + "type": "object", + "properties": { + "testKey": { + "type": "string" + }, + "testInfo": { + "$ref": "#/definitions/TestInfo" + }, + "start": { + "type": "string", + "format": "date-time" + }, + "finish": { + "type": "string", + "format": "date-time" + }, + "comment": { + "type": "string" + }, + "executedBy": { + "type": "string" + }, + "assignee": { + "type": "string" + }, + "status": { + "type": "string" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/ManualTestStepResult" + } + }, + "examples": { + "type": "array", + "items": { + "type": "string", + "enum": ["TODO", "FAILED", "PASSED", "EXECUTING"] + } + }, + "iterations": { + "type": "array", + "items": { + "$ref": "#/definitions/IterationResult" + } + }, + "defects": { + "type": "array", + "items": { + "type": "string" + } + }, + "evidence": { + "type": "array", + "items": { + "$ref": "#/definitions/EvidenceItem" + } + }, + "customFields": { + "$ref": "#/definitions/CustomField" + } + }, + "required": ["status"], + "dependencies": { + "evidence": { + "not": { "required": ["evidences"] } + }, + "evidences": { + "not": { "required": ["evidence"] } + }, + "steps": { + "allOf": [ + { + "not": { "required": ["examples"] } + }, + { + "not": { "required": ["results"] } + }, + { + "not": { "required": ["iterations"] } + } + ] + }, + "examples": { + "allOf": [ + { + "not": { "required": ["steps"] } + }, + { + "not": { "required": ["results"] } + }, + { + "not": { "required": ["iterations"] } + } + ] + }, + "results": { + "allOf": [ + { + "not": { "required": ["steps"] } + }, + { + "not": { "required": ["examples"] } + }, + { + "not": { "required": ["iterations"] } + } + ] + }, + "iterations": { + "allOf": [ + { + "not": { "required": ["steps"] } + }, + { + "not": { "required": ["examples"] } + }, + { + "not": { "required": ["results"] } + } + ] + } + }, + "additionalProperties": false + }, + + "IterationResult": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "parameters": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "log": { + "type": "string" + }, + "duration": { + "type": "string" + }, + "status": { + "type": "string" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/ManualTestStepResult" + } + } + }, + "required": ["status"], + "additionalProperties": false + }, + + "ManualTestStepResult": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "comment": { + "type": "string" + }, + "evidence": { + "type": "array", + "items": { + "$ref": "#/definitions/EvidenceItem" + } + }, + "defects": { + "type": "array", + "items": { + "type": "string" + } + }, + "actualResult": { + "type": "string" + } + }, + "required": ["status"], + "additionalProperties": false + }, + + "TestInfo": { + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "projectKey": { + "type": "string" + }, + "requirementKeys": { + "type": "array", + "items": { + "type": "string" + } + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string" + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "data": { + "type": "string" + }, + "result": { + "type": "string" + } + }, + "customFields": { + ".+": {} + }, + "required": ["action"], + "additionalProperties": false + } + }, + "scenario": { + "type": "string" + }, + "definition": { + "type": "string" + } + }, + "dependencies": { + "steps": { + "allOf": [ + { + "not": { "required": ["scenario"] } + }, + { + "not": { "required": ["definition"] } + } + ] + }, + "scenario": { + "allOf": [ + { + "not": { "required": ["steps"] } + }, + { + "not": { "required": ["definition"] } + } + ] + }, + "definition": { + "allOf": [ + { + "not": { "required": ["steps"] } + }, + { + "not": { "required": ["scenario"] } + } + ] + } + }, + "required": ["summary", "projectKey", "type"], + "additionalProperties": false + }, + + "EvidenceItem": { + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "contentType": { + "type": "string" + } + }, + "required": ["data", "filename"], + "additionalProperties": false + }, + + "CustomField": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": {} + }, + "anyOf": [ + { + "required": ["id", "value"] + }, + { + "required": ["name", "value"] + } + ], + "additionalProperties": false + } + } + } + +} \ No newline at end of file diff --git a/utilities/xray-reporter.ts b/utilities/xray-reporter.ts deleted file mode 100644 index 5038434..0000000 --- a/utilities/xray-reporter.ts +++ /dev/null @@ -1,161 +0,0 @@ -import fs from 'node:fs'; -import { FullConfig, FullResult, Suite, TestCase, TestResult } from '@playwright/test/reporter'; -import env from './env'; - -interface Styles { - success: string; - error: string; - info: string; - warning: string; - upload: string; - test: string; - separator: string; -} - -/** - * Reporter class for uploading test results to Xray - */ -class XRayReporter { - private styles: Styles; - - constructor() { - this.styles = { - success: 'āœ…', - error: 'āŒ', - info: 'ā„¹ļø', - warning: 'ā›”ļø', - upload: 'šŸš€', - test: '🧪', - separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - }; - } - - /** - * Authenticates with Xray API using client credentials - * @returns {Promise} The authentication token - * @throws {Error} If authentication fails - */ - async authenticateWithXray(): Promise { - try { - console.log(`${this.styles.info} Authenticating with Xray...`); - const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - client_id: env.XRAY_CLIENT_ID, - client_secret: env.XRAY_CLIENT_SECRET, - }), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}, ${response.body}`); - } - - const data = await response.json(); - console.log(`${this.styles.success} Successfully authenticated with Xray`); - return data.token; - } catch (error) { - console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); - throw error; - } - } - - /** - * Uploads test results to Xray - * @param {string} token - The authentication token - * @param {string} xmlContent - The JUnit XML content to upload - * @returns {Promise} - * @throws {Error} If upload fails - */ - async uploadTestResults(token: string, xmlContent: string): Promise { - try { - console.log(`${this.styles.info} Uploading test results to Xray...`); - const response = await fetch( - 'https://xray.cloud.getxray.app/api/v2/import/execution/junit?projectKey=XT&testPlanKey=XT-380', - { - method: 'POST', - headers: { - 'Content-Type': 'text/xml', - Authorization: `Bearer ${token}`, - }, - body: xmlContent, - }, - ); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); - } - - console.log(`${this.styles.success} Successfully uploaded test results to Xray`); - } catch (error) { - console.error(`${this.styles.error} Failed to upload test results to Xray:`, error); - throw error; - } - } - - /** - * Called when test run begins - * @param suite - Test suite object containing all tests - */ - onBegin(_config: FullConfig, suite: Suite): void { - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); - console.log(`${this.styles.separator}\n`); - } - - /** - * Called when a test begins - * @param test - Test case object - */ - onTestBegin(test: TestCase, _result: TestResult): void { - console.log(`${this.styles.test} Starting: ${test.title}`); - } - - /** - * Called when a test ends - * @param {Object} test - Test case object - * @param {Object} result - Test result object containing status and other details - */ - onTestEnd(test: TestCase, result: TestResult): void { - const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; - console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); - } - - /** - * Called when all tests have finished - * @param result - Full test run result object containing status and duration - */ - async onEnd(result: FullResult): Promise { - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.info} Test Run Summary:`); - console.log( - `Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`, - ); - console.log(`Duration: ${result.duration}ms`); - console.log(`${this.styles.separator}\n`); - - if (!(env.XRAY_CLIENT_ID || env.XRAY_CLIENT_SECRET)) { - console.log( - `${this.styles.warning} No Xray client ID or secret found, skipping upload to JIRA Xray`, - ); - return; - } - - try { - console.log(`${this.styles.info} Reading test results file...`); - const testResults = fs.readFileSync('./test-results/test-results.xml', 'utf8'); - - const token = await this.authenticateWithXray(); - await this.uploadTestResults(token, testResults); - console.log(`${this.styles.upload} Successfully uploaded test results to Xray`); - } catch (error) { - console.error(`${this.styles.error} Failed to process test results:`, error); - } - console.log(`${this.styles.separator}\n`); - } -} - -export default XRayReporter; diff --git a/utilities/xray-types.ts b/utilities/xray-types.ts new file mode 100644 index 0000000..cf0aea9 --- /dev/null +++ b/utilities/xray-types.ts @@ -0,0 +1,100 @@ +/** + * TypeScript type definitions for Xray Cloud API integration + */ + +/** + * Xray Evidence format (base64 encoded) + */ +export interface XrayEvidence { + data: string; // base64 encoded content + filename: string; + contentType?: string; +} + +/** + * Test step definition (used in testInfo.steps to define the test) + */ +export interface XrayTestStepDefinition { + action: string; + data?: string; + result?: string; +} + +/** + * Test step execution result (used in test.steps to record execution results) + */ +export interface XrayTestStepResult { + status: 'PASSED' | 'FAILED' | 'TODO' | 'EXECUTING'; + comment?: string; + actualResult?: string; + evidence?: XrayEvidence[]; + defects?: string[]; +} + +/** + * Xray Test Information (test definition/specification) + */ +export interface XrayTestInfo { + summary: string; + type: 'Manual' | 'Cucumber' | 'Generic'; + projectKey: string; + requirementKeys?: string[]; + labels?: string[]; + steps?: XrayTestStepDefinition[]; +} + +/** + * Xray Test format (test execution record) + */ +export interface XrayTest { + testKey?: string; + testInfo?: XrayTestInfo; + start?: string; + finish?: string; + status: 'PASSED' | 'FAILED' | 'TODO' | 'EXECUTING'; + comment?: string; + evidence?: XrayEvidence[]; + steps?: XrayTestStepResult[]; + defects?: string[]; +} + +/** + * Xray Test Execution Information (goes in "info" object) + */ +export interface XrayExecutionInfo { + project?: string; + summary: string; + description?: string; + version?: string; + revision?: string; + user?: string; + startDate?: string; + finishDate?: string; + testPlanKey?: string; + testEnvironments?: string[]; +} + +/** + * Xray Execution Result format (for JSON import) + */ +export interface XrayExecutionResult { + testExecutionKey?: string; + info?: XrayExecutionInfo; + tests: XrayTest[]; +} + +/** + * Xray API Import Response + */ +export interface XrayImportResponse { + testExecIssue: { + id: string; + key: string; + self: string; + }; + testIssues?: { + id: string; + key: string; + self: string; + }[]; +} From 4601dd0fba7f391bc20bc01fc8ec3f2e971a3795 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Tue, 10 Feb 2026 19:46:29 -0500 Subject: [PATCH 11/60] linting autofix linting errors --- .../clinician/ClinicianDashboardPage.ts | 16 ++- tests/clinician/add-delete-patient.spec.ts | 12 +- .../edit-personal-profile-API.spec.ts | 107 +++++++++--------- tests/personal/login.spec.ts | 2 +- 4 files changed, 71 insertions(+), 66 deletions(-) diff --git a/page-objects/clinician/ClinicianDashboardPage.ts b/page-objects/clinician/ClinicianDashboardPage.ts index c33eb02..22baba1 100644 --- a/page-objects/clinician/ClinicianDashboardPage.ts +++ b/page-objects/clinician/ClinicianDashboardPage.ts @@ -30,7 +30,7 @@ class ClinicianDashboardPage { readonly bringDataDialog_doneButton: Locator; - //Locators for the Patient Options Dropdown (First find) + // Locators for the Patient Options Dropdown (First find) readonly patientOptionsButton: Locator; readonly removePatientButton: Locator; @@ -47,7 +47,9 @@ class ClinicianDashboardPage { // Add Patient Dialog locators this.addPatientDialog = page.getByRole('dialog'); - this.addPatientDialog_heading = this.addPatientDialog.getByRole('heading', { name: 'Add New Patient Account' }); + this.addPatientDialog_heading = this.addPatientDialog.getByRole('heading', { + name: 'Add New Patient Account', + }); this.addPatientDialog_fullNameInput = this.addPatientDialog.getByRole('textbox', { name: 'Full Name', }); @@ -59,11 +61,15 @@ class ClinicianDashboardPage { }); // Bring Data Dialog locators (robust: find dialog containing heading) - this.bringDataDialog = page.getByRole('dialog').filter({ has: page.getByRole('heading', { name: 'Bring Data into Tidepool' }) }); + this.bringDataDialog = page + .getByRole('dialog') + .filter({ has: page.getByRole('heading', { name: 'Bring Data into Tidepool' }) }); this.bringDataDialog_doneButton = this.bringDataDialog.getByRole('button', { name: 'Done' }); - //Patient Options Dropdown - this.patientOptionsButton = this.patientListTable.getByRole('button', { name: /info|\.\.\./i }).first(); + // Patient Options Dropdown + this.patientOptionsButton = this.patientListTable + .getByRole('button', { name: /info|\.\.\./i }) + .first(); this.removePatientButton = this.page.getByRole('button', { name: /remove patient/i }).first(); this.removePatientConfirm = this.page.getByRole('button', { name: /^Remove$/i }); } diff --git a/tests/clinician/add-delete-patient.spec.ts b/tests/clinician/add-delete-patient.spec.ts index e5d1aa0..4bbb8d7 100644 --- a/tests/clinician/add-delete-patient.spec.ts +++ b/tests/clinician/add-delete-patient.spec.ts @@ -31,10 +31,10 @@ test.describe('Custodial patients are allowed access and modification of profile await test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); }); - //Create pages + // Create pages const clinicianDashboardPage = new ClinicianDashboardPage(page); - //Step 3: Click the New Patient button and fill out the form + // Step 3: Click the New Patient button and fill out the form await test.step('When user clicks the new patient button and fills out the form', async () => { await clinicianDashboardPage.openAndFillAddPatientDialog(patientName, patientBirthdate); }); @@ -62,7 +62,7 @@ test.describe('Custodial patients are allowed access and modification of profile }); // Step 8: Select '...' within the patient row - await test.step("When user opens the options dropdown for the patient", async () => { + await test.step('When user opens the options dropdown for the patient', async () => { await clinicianDashboardPage.openFirstPatientOptionsDropdown(); }); @@ -72,17 +72,17 @@ test.describe('Custodial patients are allowed access and modification of profile }); // Step 10: Click Remove button in confirmation dialog - await test.step("When user confirms patient removal", async () => { + await test.step('When user confirms patient removal', async () => { await clinicianDashboardPage.confirmRemovePatient(); }); // Step 11: Search for the removed patient - await test.step("When user searches for the removed patient", async () => { + await test.step('When user searches for the removed patient', async () => { await clinicianDashboardPage.searchForPatient(patientName); }); // Step 12: Verify the deleted patient does not appear in patient list - await test.step("Then the deleted patient should not appear in the patient list", async () => { + await test.step('Then the deleted patient should not appear in the patient list', async () => { const patientCell = clinicianDashboardPage.getPatientCellByName(patientName); await expect(patientCell).not.toBeVisible(); }); diff --git a/tests/personal/AP-Profile/edit-personal-profile-API.spec.ts b/tests/personal/AP-Profile/edit-personal-profile-API.spec.ts index 9a13e0a..6e74860 100644 --- a/tests/personal/AP-Profile/edit-personal-profile-API.spec.ts +++ b/tests/personal/AP-Profile/edit-personal-profile-API.spec.ts @@ -26,70 +26,69 @@ test.describe('Personal Accounts allow access and modification of profile detail await api.startCapture(); await page.goto('/data'); await test.patient.setup(page); - }); - // Step 2: Navigate to profile - await test.step('When user navigates to Profile page', async () => { - await test.patient.navigateTo('Profile', page); - }); + }); + // Step 2: Navigate to profile + await test.step('When user navigates to Profile page', async () => { + await test.patient.navigateTo('Profile', page); + }); - // Step 3: Check profile GET response - await test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); + // Step 3: Check profile GET response + await test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); - // Step 4: Open Edit Profile - await test.step('When user selects Edit button', async () => { - await test.patient.navigateTo('ProfileEdit', page); - }); + // Step 4: Open Edit Profile + await test.step('When user selects Edit button', async () => { + await test.patient.navigateTo('ProfileEdit', page); + }); - // Initialize ProfilePage for steps 4 and 5 - const profilePage = new ProfilePage(page); + // Initialize ProfilePage for steps 4 and 5 + const profilePage = new ProfilePage(page); - // Step 5: Change profile fields (confirmed user access) - await test.step('When user updates profile fields', async () => { - // Generate completely unique values for this confirmed user test run - const testRunId = Math.floor(Math.random() * 10000); - const updatedName = `Personal Patient Updated ${testRunId}`; - const birthYear = 1985 + (testRunId % 10); - const diagnosisYear = birthYear + 20; - const birthDate = `01/15/${birthYear}`; - const diagnosisDate = `03/10/${diagnosisYear}`; + // Step 5: Change profile fields (confirmed user access) + await test.step('When user updates profile fields', async () => { + // Generate completely unique values for this confirmed user test run + const testRunId = Math.floor(Math.random() * 10000); + const updatedName = `Personal Patient Updated ${testRunId}`; + const birthYear = 1985 + (testRunId % 10); + const diagnosisYear = birthYear + 20; + const birthDate = `01/15/${birthYear}`; + const diagnosisDate = `03/10/${diagnosisYear}`; - // Generate random 15-letter string for clinical notes - const randomString = Array.from({ length: 15 }, () => - String.fromCharCode(65 + Math.floor(Math.random() * 26)), - ).join(''); + // Generate random 15-letter string for clinical notes + const randomString = Array.from({ length: 15 }, () => + String.fromCharCode(65 + Math.floor(Math.random() * 26)), + ).join(''); - // Get current diagnosis index and calculate next one (1-7, wrapping) - const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); - let nextDiagnosisIndex = currentDiagnosisIndex + 1; - if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { - nextDiagnosisIndex = 1; - } + // Get current diagnosis index and calculate next one (1-7, wrapping) + const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); + let nextDiagnosisIndex = currentDiagnosisIndex + 1; + if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { + nextDiagnosisIndex = 1; + } - // Update fields using ProfilePage methods - await profilePage.fillFullName(updatedName); - await profilePage.fillBirthDate(birthDate); - await profilePage.fillDiagnosisDate(diagnosisDate); - await profilePage.selectDiagnosisType(nextDiagnosisIndex); - await profilePage.fillClinicalNotes(randomString); - }); + // Update fields using ProfilePage methods + await profilePage.fillFullName(updatedName); + await profilePage.fillBirthDate(birthDate); + await profilePage.fillDiagnosisDate(diagnosisDate); + await profilePage.selectDiagnosisType(nextDiagnosisIndex); + await profilePage.fillClinicalNotes(randomString); + }); - // Step 6: Save profile edit - await test.step('When user saves profile changes', async () => { - await profilePage.saveProfile(); - }); + // Step 6: Save profile edit + await test.step('When user saves profile changes', async () => { + await profilePage.saveProfile(); + }); - // Step 7: Check profile PUT response - await (test as any).stepNoScreenshot( - 'Then profile endpoint responds with PUT request consistent with schema', - async () => { - await api.validateEndpointResponse('profile-metadata-put'); - }, - ); + // Step 7: Check profile PUT response + await (test as any).stepNoScreenshot( + 'Then profile endpoint responds with PUT request consistent with schema', + async () => { + await api.validateEndpointResponse('profile-metadata-put'); + }, + ); - await api.stopCapture(); - + await api.stopCapture(); }, ); }); diff --git a/tests/personal/login.spec.ts b/tests/personal/login.spec.ts index 534f803..64d4fdc 100644 --- a/tests/personal/login.spec.ts +++ b/tests/personal/login.spec.ts @@ -62,7 +62,7 @@ test.describe('Login into application', () => { // Wait for the error message to appear await expect(page.locator('#input-error-username')).toBeVisible(); await expect(page.locator('#input-error-username')).toContainText( - "This email doesn't belong to an accountz yet.", + "This email doesn't belong to an account yet.", ); }); }, From f4ee15800ab9a72134684135589ab878b3be9243 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Tue, 10 Feb 2026 20:34:45 -0500 Subject: [PATCH 12/60] CircleCI: set env vars conditionally; change default env Change default testEnvironment from 'qa1' to 'qa2'. Remove job-level static environment mappings and add a run step that appends exports to $BASH_ENV so project environment variables are preserved unless pipeline parameters are explicitly provided. Applied the update to both Playwright test jobs to ensure pipeline parameters override project env vars only when set. --- .circleci/config.yml | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 6784f5f..97101e1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,7 +6,7 @@ orbs: parameters: testEnvironment: type: string - default: 'qa1' + default: 'qa2' testExecKey: type: string default: 'none' @@ -41,15 +41,18 @@ jobs: docker: - image: mcr.microsoft.com/playwright:v1.54.1-noble parallelism: 4 - environment: - TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> - TARGET_ENV: << pipeline.parameters.testEnvironment >> - TEST_TAGS: << pipeline.parameters.testTags >> - XRAY_PROJECT_KEY: << pipeline.parameters.xrayProjectKey >> steps: - checkout - node/install - run: node --version + # Pipeline parameters override project env vars only when explicitly provided + - run: + name: Set environment variables + command: | + echo "export TARGET_ENV=\"${TARGET_ENV:-<< pipeline.parameters.testEnvironment >>}\"" >> $BASH_ENV + echo "export TEST_EXECUTION_KEY=\"${TEST_EXECUTION_KEY:-<< pipeline.parameters.testExecKey >>}\"" >> $BASH_ENV + echo "export TEST_TAGS=\"${TEST_TAGS:-<< pipeline.parameters.testTags >>}\"" >> $BASH_ENV + echo "export XRAY_PROJECT_KEY=\"${XRAY_PROJECT_KEY:-<< pipeline.parameters.xrayProjectKey >>}\"" >> $BASH_ENV - restore_cache: keys: - dependency-cache-{{ checksum "package.json" }} @@ -66,7 +69,6 @@ jobs: command: npx playwright install --with-deps # Run tests with parallel execution - # TARGET_ENV and TEST_TAGS are already set as environment variables above - run: name: Run Playwright Tests command: npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL @@ -150,15 +152,18 @@ jobs: docker: - image: mcr.microsoft.com/playwright:v1.54.1-noble parallelism: 4 - environment: - TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> - TARGET_ENV: << pipeline.parameters.testEnvironment >> - TEST_TAGS: << pipeline.parameters.testTags >> - XRAY_PROJECT_KEY: << pipeline.parameters.xrayProjectKey >> steps: - checkout - node/install - run: node --version + # Pipeline parameters override project env vars only when explicitly provided + - run: + name: Set environment variables + command: | + echo "export TARGET_ENV=\"${TARGET_ENV:-<< pipeline.parameters.testEnvironment >>}\"" >> $BASH_ENV + echo "export TEST_EXECUTION_KEY=\"${TEST_EXECUTION_KEY:-<< pipeline.parameters.testExecKey >>}\"" >> $BASH_ENV + echo "export TEST_TAGS=\"${TEST_TAGS:-<< pipeline.parameters.testTags >>}\"" >> $BASH_ENV + echo "export XRAY_PROJECT_KEY=\"${XRAY_PROJECT_KEY:-<< pipeline.parameters.xrayProjectKey >>}\"" >> $BASH_ENV - restore_cache: keys: - dependency-cache-{{ checksum "package.json" }} @@ -175,7 +180,6 @@ jobs: command: npx playwright install --with-deps # Run tests with parallel execution - # TARGET_ENV and TEST_TAGS are already set as environment variables above - run: name: Run Playwright Tests command: npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL From 31f72adc06d2752d7cbf61c803582dc277664469 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Fri, 22 Aug 2025 15:28:07 -0400 Subject: [PATCH 13/60] Add dynamic Playwright test runner and enhance env support Introduces a new utilities/test-runner.js script to dynamically build and execute Playwright test commands based on environment variables and tags. Updates package.json scripts to use the new runner and provide environment/tag-specific test commands. Extends env.ts to support 'prd' and 'int' as TARGET_ENV values. Updates CircleCI config to support test tags and improves Slack notifications for main and develop branches. --- .circleci/config.yml | 65 ++++++++++++++-- package.json | 16 +++- utilities/env.ts | 4 +- utilities/test-runner.js | 156 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 utilities/test-runner.js diff --git a/.circleci/config.yml b/.circleci/config.yml index 77bedc5..b46b340 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,6 +10,9 @@ parameters: testExecKey: type: string default: 'none' + testTags: + type: string + default: '' jobs: code-quality-check: working_directory: ~/tidepool-org/webuitests @@ -38,6 +41,7 @@ jobs: environment: TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> TARGET_ENV: << pipeline.parameters.testEnvironment >> + TEST_TAGS: << pipeline.parameters.testTags >> steps: - checkout - node/install @@ -69,19 +73,69 @@ jobs: path: test-results - store_test_results: path: test-output/test-results.xml - # Commit workflow notifications - basic templates for all branches + # Main and Develop branch notifications - always notify with branch name - slack/notify: event: fail + branch_pattern: main mentions: '<@UG56AQFK2>' - template: basic_fail_1 + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":x: *Tidepool Web UI Tests Failed on Main Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + } + } + ] + } - slack/notify: event: pass branch_pattern: main - template: basic_success_1 + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":white_check_mark: *Tidepool Web UI Tests Passed on Main Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + } + } + ] + } + - slack/notify: + event: fail + branch_pattern: develop + mentions: '<@UG56AQFK2>' + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":x: *Tidepool Web UI Tests Failed on Develop Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + } + } + ] + } - slack/notify: event: pass - branch_pattern: /^(?!main$).*/ - template: basic_success_1 + branch_pattern: develop + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":white_check_mark: *Tidepool Web UI Tests Passed on Develop Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + } + } + ] + } - unless: condition: and: @@ -114,6 +168,7 @@ jobs: environment: TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> TARGET_ENV: << pipeline.parameters.testEnvironment >> + TEST_TAGS: << pipeline.parameters.testTags >> steps: - checkout - node/install diff --git a/package.json b/package.json index 92f020a..683da48 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,20 @@ "lint:fix": "eslint --ext .ts . --fix", "typecheck": "tsc --noEmit", "check": "npm run lint && npm run typecheck", - "debug": "TARGET_ENV=qa1 npx playwright test --debug ", - "test": "TARGET_ENV=qa1 playwright test", + "debug": "node utilities/test-runner.js --debug", + "test": "node utilities/test-runner.js", + "test:qa1": "TARGET_ENV=qa1 node utilities/test-runner.js", + "test:qa2": "TARGET_ENV=qa2 node utilities/test-runner.js", + "test:qa3": "TARGET_ENV=qa3 node utilities/test-runner.js", + "test:qa4": "TARGET_ENV=qa4 node utilities/test-runner.js", + "test:prd": "TARGET_ENV=prd node utilities/test-runner.js", + "test:int": "TARGET_ENV=int node utilities/test-runner.js", + "test:smoke": "TEST_TAGS='@smoke' node utilities/test-runner.js", + "test:critical": "TEST_TAGS='@critical' node utilities/test-runner.js", + "test:api": "TEST_TAGS='@api' node utilities/test-runner.js", + "test:ui": "TEST_TAGS='@ui' node utilities/test-runner.js", + "test:patient": "TEST_TAGS='@patient' node utilities/test-runner.js", + "test:clinician": "TEST_TAGS='@clinician' node utilities/test-runner.js", "format": "prettier --write ." }, "repository": { diff --git a/utilities/env.ts b/utilities/env.ts index 5c11e15..9323afe 100644 --- a/utilities/env.ts +++ b/utilities/env.ts @@ -14,7 +14,7 @@ const envSchema = z.object({ SHARED_PASSWORD: z.string(), CLINICIAN_USERNAME: z.string(), CLINICIAN_PASSWORD: z.string(), - TARGET_ENV: z.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production']), + TARGET_ENV: z.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production', 'prd', 'int']), XRAY_CLIENT_ID: z.string().optional(), XRAY_CLIENT_SECRET: z.string().optional(), }); @@ -32,6 +32,8 @@ const URL_MAP: Record = { qa4: 'https://qa4.development.tidepool.org', qa5: 'https://qa5.development.tidepool.org', production: 'https://app.tidepool.org', + prd: 'https://app.tidepool.org', // Alias for production + int: 'https://int.development.tidepool.org', // Integration environment }; export default { diff --git a/utilities/test-runner.js b/utilities/test-runner.js new file mode 100644 index 0000000..405acbb --- /dev/null +++ b/utilities/test-runner.js @@ -0,0 +1,156 @@ +/** + * Dynamic Test Runner Utility + * + * This utility builds and executes Playwright test commands dynamically based on: + * - TARGET_ENV environment variable (defaults to qa1) + * - TEST_TAGS environment variable (space or comma separated) + * - Command line arguments for additional Playwright flags + * + * Usage: + * node utilities/test-runner.js # Run all tests on qa1 + * TARGET_ENV=qa2 node utilities/test-runner.js # Run all tests on qa2 + * TEST_TAGS="@smoke @critical" node utilities/test-runner.js # Run smoke AND critical tests + * TEST_TAGS="@api,@ui" node utilities/test-runner.js # Run api OR ui tests (comma-separated = OR) + * node utilities/test-runner.js --debug # Pass additional flags to Playwright + */ + +const { spawn } = require('node:child_process'); +const { existsSync } = require('node:fs'); +const path = require('node:path'); + +// Get environment variables with defaults +const targetEnv = process.env.TARGET_ENV || 'qa1'; +const testTags = process.env.TEST_TAGS || ''; +const circleCINodeIndex = process.env.CIRCLE_NODE_INDEX; +const circleCINodeTotal = process.env.CIRCLE_NODE_TOTAL; + +// Get additional command line arguments (everything after the script name) +const additionalArgs = process.argv.slice(2); + +/** + * Parse test tags and build Playwright grep arguments + * @param {string} tags - Space or comma separated tags + * @returns {string[]} Array of grep arguments for Playwright + */ +function buildGrepArgs(tags) { + if (!tags || tags.trim() === '') { + return []; + } + + // Normalize tags: remove @, handle both space and comma separation + const tagList = tags + .split(/[\s,]+/) + .map(tag => tag.trim()) + .filter(tag => tag.length > 0) + .map(tag => (tag.startsWith('@') ? tag.slice(1) : tag)); + + if (tagList.length === 0) { + return []; + } + + if (tagList.length === 1) { + // Single tag: simple grep + return ['--grep', `@${tagList[0]}`]; + } + + // Multiple tags: check if original input used commas (OR logic) or spaces (AND logic) + const hasCommas = tags.includes(','); + + if (hasCommas) { + // Comma-separated = OR logic: @tag1|@tag2|@tag3 + const orPattern = tagList.map(tag => `@${tag}`).join('|'); + return ['--grep', orPattern]; + } + // Space-separated = AND logic: (?=.*@tag1)(?=.*@tag2)(?=.*@tag3) + const andPattern = tagList.map(tag => `(?=.*@${tag})`).join(''); + return ['--grep', andPattern]; +} + +/** + * Build the complete Playwright command + * @returns {object} Command and arguments for spawning + */ +function buildPlaywrightCommand() { + const baseArgs = ['test']; + + // Add sharding for CircleCI if available + if (circleCINodeIndex !== undefined && circleCINodeTotal !== undefined) { + baseArgs.push(`--shard=${circleCINodeIndex}/${circleCINodeTotal}`); + } + + // Add grep arguments for tags + const grepArgs = buildGrepArgs(testTags); + baseArgs.push(...grepArgs); + + // Add any additional command line arguments + baseArgs.push(...additionalArgs); + + return { + command: 'npx', + args: ['playwright', ...baseArgs], + env: { + ...process.env, + TARGET_ENV: targetEnv, + }, + }; +} + +/** + * Main execution function + */ +function main() { + const { command, args, env } = buildPlaywrightCommand(); + + // Log the command being executed for transparency + console.log(`šŸŽ­ Running Playwright tests:`); + console.log(` Environment: ${targetEnv}`); + console.log(` Tags: ${testTags || '(all tests)'}`); + console.log(` Command: ${command} ${args.join(' ')}`); + console.log(''); + + // Validate that we're in the right directory + if (!existsSync('playwright.config.ts')) { + console.error( + 'āŒ Error: playwright.config.ts not found. Please run this script from the project root.', + ); + process.exit(1); + } + + // Spawn the Playwright process + const playwrightProcess = spawn(command, args, { + env, + stdio: 'inherit', // Pass through all stdio streams + shell: process.platform === 'win32', // Use shell on Windows + }); + + // Handle process events + playwrightProcess.on('error', error => { + console.error(`āŒ Failed to start Playwright: ${error.message}`); + process.exit(1); + }); + + playwrightProcess.on('close', code => { + const emoji = code === 0 ? 'āœ…' : 'āŒ'; + console.log(`${emoji} Playwright tests completed with exit code: ${code}`); + process.exit(code); + }); + + // Handle graceful shutdown + process.on('SIGINT', () => { + console.log('\nšŸ›‘ Received SIGINT, terminating Playwright...'); + playwrightProcess.kill('SIGINT'); + }); + + process.on('SIGTERM', () => { + console.log('\nšŸ›‘ Received SIGTERM, terminating Playwright...'); + playwrightProcess.kill('SIGTERM'); + }); +} + +// Export for testing +module.exports = { buildGrepArgs, buildPlaywrightCommand }; + +// Run if called directly +if (require.main === module) { + main(); +} From e1ce98ed7c1f76c2ca3a6fbceed200666f239054 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Fri, 22 Aug 2025 15:31:11 -0400 Subject: [PATCH 14/60] Update CircleCI workflow to remove test job The commit-workflow in .circleci/config.yml now only runs the code-quality-check job, removing the test job and its dependency on eslint-check. --- .circleci/config.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index b46b340..a08587f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -290,7 +290,4 @@ jobs: workflows: commit-workflow: jobs: - - code-quality-check - - test: - requires: - - eslint-check \ No newline at end of file + - code-quality-check \ No newline at end of file From 7dd78e80fac9edeca7f87b8d73c64ebac6e68b66 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 25 Aug 2025 15:13:54 -0400 Subject: [PATCH 15/60] Add Playwright test framework and Xray integration Introduces Playwright-based end-to-end test infrastructure, including page objects for patient, clinician, and account flows, endpoint schema validation utilities, and Xray JSON reporter integration. Updates CircleCI config to build TypeScript utilities and upload test results to Xray in JSON format. Adds supporting utilities, test fixtures, and documentation for Xray integration. --- .circleci/config.yml | 24 +- build/endpoint-schema/auth-endpoints.js | 53 ++ build/endpoint-schema/endpoint-registry.js | 52 ++ .../endpoint-schema/patient-data-endpoints.js | 56 ++ build/endpoint-schema/profile-endpoints.js | 107 ++++ build/page-objects/LoginPage.js | 44 ++ .../page-objects/account/AccountNavigation.js | 62 +++ .../account/AccountSettingsPage.js | 13 + .../clinician/ClinicCreationPage.js | 84 +++ .../clinician/ClinicianDashboardPage.js | 79 +++ .../clinician/ClinicianNavigation.js | 119 +++++ .../clinician/WorkspaceSettingsPage.js | 29 ++ .../page-objects/clinician/WorkspacesPage.js | 36 ++ .../components/navigation-menu.section.js | 27 + .../components/navigation.section.js | 22 + build/page-objects/patient/BasicsPage.js | 143 ++++++ build/page-objects/patient/DailyPage.js | 17 + .../page-objects/patient/PatientNavigation.js | 100 ++++ build/page-objects/patient/ProfilePage.js | 115 +++++ .../patient/components/daily-chart.js | 14 + build/playwright.config.js | 113 ++++ .../claimed-profile-edit-fullname.spec.js | 148 ++++++ .../comprehensive-profile-access-test.spec.js | 159 ++++++ .../API-User/claimed-email-edit.spec.js | 95 ++++ .../edit-custodial-profile-API.spec.js | 91 ++++ build/tests/clinician/add-patient.spec.js | 38 ++ .../clinician/create-clinic-workspace.spec.js | 86 ++++ .../clinician/edit-clinic-address.spec.js | 47 ++ build/tests/clinician/filter-patient.spec.js | 70 +++ build/tests/fixtures/account-helpers.js | 123 +++++ build/tests/fixtures/base.js | 257 ++++++++++ build/tests/fixtures/clinic-helpers.js | 280 ++++++++++ build/tests/fixtures/network-helpers.js | 480 +++++++++++++++++ build/tests/fixtures/patient-helpers.js | 483 ++++++++++++++++++ build/tests/fixtures/test-tags.js | 98 ++++ build/tests/global-setup.js | 47 ++ .../edit-personal-profile-API.spec.js | 75 +++ .../personal/basic-functionality.spec.js | 240 +++++++++ build/tests/personal/login.spec.js | 66 +++ build/utilities/annotations.js | 24 + build/utilities/env.js | 42 ++ build/utilities/xray-json-reporter.js | 268 ++++++++++ build/utilities/xray-reporter.js | 134 +++++ dist/endpoint-schema/auth-endpoints.d.ts | 13 + dist/endpoint-schema/auth-endpoints.js | 50 ++ dist/endpoint-schema/endpoint-registry.d.ts | 34 ++ dist/endpoint-schema/endpoint-registry.js | 48 ++ .../patient-data-endpoints.d.ts | 13 + .../endpoint-schema/patient-data-endpoints.js | 53 ++ dist/endpoint-schema/profile-endpoints.d.ts | 32 ++ dist/endpoint-schema/profile-endpoints.js | 104 ++++ dist/page-objects/LoginPage.d.ts | 32 ++ dist/page-objects/LoginPage.js | 41 ++ .../account/AccountNavigation.d.ts | 18 + .../page-objects/account/AccountNavigation.js | 59 +++ .../account/AccountSettingsPage.d.ts | 9 + .../account/AccountSettingsPage.js | 9 + .../clinician/ClinicCreationPage.d.ts | 55 ++ .../clinician/ClinicCreationPage.js | 81 +++ .../clinician/ClinicianDashboardPage.d.ts | 46 ++ .../clinician/ClinicianDashboardPage.js | 77 +++ .../clinician/ClinicianNavigation.d.ts | 20 + .../clinician/ClinicianNavigation.js | 116 +++++ .../clinician/WorkspaceSettingsPage.d.ts | 18 + .../clinician/WorkspaceSettingsPage.js | 26 + .../clinician/WorkspacesPage.d.ts | 16 + dist/page-objects/clinician/WorkspacesPage.js | 30 ++ .../components/navigation-menu.section.d.ts | 16 + .../components/navigation-menu.section.js | 24 + .../components/navigation.section.d.ts | 14 + .../components/navigation.section.js | 16 + dist/page-objects/patient/BasicsPage.d.ts | 58 +++ dist/page-objects/patient/BasicsPage.js | 138 +++++ dist/page-objects/patient/DailyPage.d.ts | 11 + dist/page-objects/patient/DailyPage.js | 11 + .../patient/PatientNavigation.d.ts | 13 + .../page-objects/patient/PatientNavigation.js | 97 ++++ dist/page-objects/patient/ProfilePage.d.ts | 22 + dist/page-objects/patient/ProfilePage.js | 111 ++++ .../patient/components/daily-chart.d.ts | 11 + .../patient/components/daily-chart.js | 11 + dist/playwright.config.d.ts | 2 + dist/playwright.config.js | 108 ++++ .../claimed-profile-edit-fullname.spec.d.ts | 1 + .../claimed-profile-edit-fullname.spec.js | 146 ++++++ ...omprehensive-profile-access-test.spec.d.ts | 1 + .../comprehensive-profile-access-test.spec.js | 124 +++++ .../API-User/claimed-email-edit.spec.d.ts | 1 + .../API-User/claimed-email-edit.spec.js | 93 ++++ .../edit-custodial-profile-API.spec.d.ts | 1 + .../edit-custodial-profile-API.spec.js | 89 ++++ dist/tests/clinician/add-patient.spec.d.ts | 1 + dist/tests/clinician/add-patient.spec.js | 33 ++ .../create-clinic-workspace.spec.d.ts | 1 + .../clinician/create-clinic-workspace.spec.js | 81 +++ .../clinician/edit-clinic-address.spec.d.ts | 1 + .../clinician/edit-clinic-address.spec.js | 42 ++ dist/tests/clinician/filter-patient.spec.d.ts | 1 + dist/tests/clinician/filter-patient.spec.js | 65 +++ dist/tests/fixtures/account-helpers.d.ts | 20 + dist/tests/fixtures/account-helpers.js | 84 +++ dist/tests/fixtures/base.d.ts | 23 + dist/tests/fixtures/base.js | 219 ++++++++ dist/tests/fixtures/clinic-helpers.d.ts | 61 +++ dist/tests/fixtures/clinic-helpers.js | 274 ++++++++++ dist/tests/fixtures/network-helpers.d.ts | 112 ++++ dist/tests/fixtures/network-helpers.js | 442 ++++++++++++++++ dist/tests/fixtures/patient-helpers.d.ts | 18 + dist/tests/fixtures/patient-helpers.js | 477 +++++++++++++++++ dist/tests/fixtures/test-tags.d.ts | 60 +++ dist/tests/fixtures/test-tags.js | 93 ++++ dist/tests/global-setup.d.ts | 2 + dist/tests/global-setup.js | 41 ++ .../edit-personal-profile-API.spec.d.ts | 1 + .../edit-personal-profile-API.spec.js | 73 +++ .../personal/basic-functionality.spec.d.ts | 1 + .../personal/basic-functionality.spec.js | 235 +++++++++ dist/tests/personal/login.spec.d.ts | 1 + dist/tests/personal/login.spec.js | 61 +++ dist/utilities/annotations.d.ts | 15 + dist/utilities/annotations.js | 21 + dist/utilities/env.d.ts | 17 + dist/utilities/env.js | 37 ++ dist/utilities/xray-json-reporter.d.ts | 93 ++++ dist/utilities/xray-json-reporter.js | 263 ++++++++++ dist/utilities/xray-reporter.d.ts | 44 ++ dist/utilities/xray-reporter.js | 129 +++++ docs/XRAY_INTEGRATION.md | 166 ++++++ package.json | 2 + playwright.config.ts | 3 + .../claimed-profile-edit-fullname.spec.ts | 52 +- tsconfig.json | 4 +- utilities/upload-to-xray.js | 36 ++ utilities/xray-json-reporter.ts | 365 +++++++++++++ 134 files changed, 10535 insertions(+), 14 deletions(-) create mode 100644 build/endpoint-schema/auth-endpoints.js create mode 100644 build/endpoint-schema/endpoint-registry.js create mode 100644 build/endpoint-schema/patient-data-endpoints.js create mode 100644 build/endpoint-schema/profile-endpoints.js create mode 100644 build/page-objects/LoginPage.js create mode 100644 build/page-objects/account/AccountNavigation.js create mode 100644 build/page-objects/account/AccountSettingsPage.js create mode 100644 build/page-objects/clinician/ClinicCreationPage.js create mode 100644 build/page-objects/clinician/ClinicianDashboardPage.js create mode 100644 build/page-objects/clinician/ClinicianNavigation.js create mode 100644 build/page-objects/clinician/WorkspaceSettingsPage.js create mode 100644 build/page-objects/clinician/WorkspacesPage.js create mode 100644 build/page-objects/clinician/components/navigation-menu.section.js create mode 100644 build/page-objects/clinician/components/navigation.section.js create mode 100644 build/page-objects/patient/BasicsPage.js create mode 100644 build/page-objects/patient/DailyPage.js create mode 100644 build/page-objects/patient/PatientNavigation.js create mode 100644 build/page-objects/patient/ProfilePage.js create mode 100644 build/page-objects/patient/components/daily-chart.js create mode 100644 build/playwright.config.js create mode 100644 build/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js create mode 100644 build/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js create mode 100644 build/tests/claimed/API-User/claimed-email-edit.spec.js create mode 100644 build/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js create mode 100644 build/tests/clinician/add-patient.spec.js create mode 100644 build/tests/clinician/create-clinic-workspace.spec.js create mode 100644 build/tests/clinician/edit-clinic-address.spec.js create mode 100644 build/tests/clinician/filter-patient.spec.js create mode 100644 build/tests/fixtures/account-helpers.js create mode 100644 build/tests/fixtures/base.js create mode 100644 build/tests/fixtures/clinic-helpers.js create mode 100644 build/tests/fixtures/network-helpers.js create mode 100644 build/tests/fixtures/patient-helpers.js create mode 100644 build/tests/fixtures/test-tags.js create mode 100644 build/tests/global-setup.js create mode 100644 build/tests/personal/AP-Profile/edit-personal-profile-API.spec.js create mode 100644 build/tests/personal/basic-functionality.spec.js create mode 100644 build/tests/personal/login.spec.js create mode 100644 build/utilities/annotations.js create mode 100644 build/utilities/env.js create mode 100644 build/utilities/xray-json-reporter.js create mode 100644 build/utilities/xray-reporter.js create mode 100644 dist/endpoint-schema/auth-endpoints.d.ts create mode 100644 dist/endpoint-schema/auth-endpoints.js create mode 100644 dist/endpoint-schema/endpoint-registry.d.ts create mode 100644 dist/endpoint-schema/endpoint-registry.js create mode 100644 dist/endpoint-schema/patient-data-endpoints.d.ts create mode 100644 dist/endpoint-schema/patient-data-endpoints.js create mode 100644 dist/endpoint-schema/profile-endpoints.d.ts create mode 100644 dist/endpoint-schema/profile-endpoints.js create mode 100644 dist/page-objects/LoginPage.d.ts create mode 100644 dist/page-objects/LoginPage.js create mode 100644 dist/page-objects/account/AccountNavigation.d.ts create mode 100644 dist/page-objects/account/AccountNavigation.js create mode 100644 dist/page-objects/account/AccountSettingsPage.d.ts create mode 100644 dist/page-objects/account/AccountSettingsPage.js create mode 100644 dist/page-objects/clinician/ClinicCreationPage.d.ts create mode 100644 dist/page-objects/clinician/ClinicCreationPage.js create mode 100644 dist/page-objects/clinician/ClinicianDashboardPage.d.ts create mode 100644 dist/page-objects/clinician/ClinicianDashboardPage.js create mode 100644 dist/page-objects/clinician/ClinicianNavigation.d.ts create mode 100644 dist/page-objects/clinician/ClinicianNavigation.js create mode 100644 dist/page-objects/clinician/WorkspaceSettingsPage.d.ts create mode 100644 dist/page-objects/clinician/WorkspaceSettingsPage.js create mode 100644 dist/page-objects/clinician/WorkspacesPage.d.ts create mode 100644 dist/page-objects/clinician/WorkspacesPage.js create mode 100644 dist/page-objects/clinician/components/navigation-menu.section.d.ts create mode 100644 dist/page-objects/clinician/components/navigation-menu.section.js create mode 100644 dist/page-objects/clinician/components/navigation.section.d.ts create mode 100644 dist/page-objects/clinician/components/navigation.section.js create mode 100644 dist/page-objects/patient/BasicsPage.d.ts create mode 100644 dist/page-objects/patient/BasicsPage.js create mode 100644 dist/page-objects/patient/DailyPage.d.ts create mode 100644 dist/page-objects/patient/DailyPage.js create mode 100644 dist/page-objects/patient/PatientNavigation.d.ts create mode 100644 dist/page-objects/patient/PatientNavigation.js create mode 100644 dist/page-objects/patient/ProfilePage.d.ts create mode 100644 dist/page-objects/patient/ProfilePage.js create mode 100644 dist/page-objects/patient/components/daily-chart.d.ts create mode 100644 dist/page-objects/patient/components/daily-chart.js create mode 100644 dist/playwright.config.d.ts create mode 100644 dist/playwright.config.js create mode 100644 dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.d.ts create mode 100644 dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js create mode 100644 dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.d.ts create mode 100644 dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js create mode 100644 dist/tests/claimed/API-User/claimed-email-edit.spec.d.ts create mode 100644 dist/tests/claimed/API-User/claimed-email-edit.spec.js create mode 100644 dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.d.ts create mode 100644 dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js create mode 100644 dist/tests/clinician/add-patient.spec.d.ts create mode 100644 dist/tests/clinician/add-patient.spec.js create mode 100644 dist/tests/clinician/create-clinic-workspace.spec.d.ts create mode 100644 dist/tests/clinician/create-clinic-workspace.spec.js create mode 100644 dist/tests/clinician/edit-clinic-address.spec.d.ts create mode 100644 dist/tests/clinician/edit-clinic-address.spec.js create mode 100644 dist/tests/clinician/filter-patient.spec.d.ts create mode 100644 dist/tests/clinician/filter-patient.spec.js create mode 100644 dist/tests/fixtures/account-helpers.d.ts create mode 100644 dist/tests/fixtures/account-helpers.js create mode 100644 dist/tests/fixtures/base.d.ts create mode 100644 dist/tests/fixtures/base.js create mode 100644 dist/tests/fixtures/clinic-helpers.d.ts create mode 100644 dist/tests/fixtures/clinic-helpers.js create mode 100644 dist/tests/fixtures/network-helpers.d.ts create mode 100644 dist/tests/fixtures/network-helpers.js create mode 100644 dist/tests/fixtures/patient-helpers.d.ts create mode 100644 dist/tests/fixtures/patient-helpers.js create mode 100644 dist/tests/fixtures/test-tags.d.ts create mode 100644 dist/tests/fixtures/test-tags.js create mode 100644 dist/tests/global-setup.d.ts create mode 100644 dist/tests/global-setup.js create mode 100644 dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.d.ts create mode 100644 dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.js create mode 100644 dist/tests/personal/basic-functionality.spec.d.ts create mode 100644 dist/tests/personal/basic-functionality.spec.js create mode 100644 dist/tests/personal/login.spec.d.ts create mode 100644 dist/tests/personal/login.spec.js create mode 100644 dist/utilities/annotations.d.ts create mode 100644 dist/utilities/annotations.js create mode 100644 dist/utilities/env.d.ts create mode 100644 dist/utilities/env.js create mode 100644 dist/utilities/xray-json-reporter.d.ts create mode 100644 dist/utilities/xray-json-reporter.js create mode 100644 dist/utilities/xray-reporter.d.ts create mode 100644 dist/utilities/xray-reporter.js create mode 100644 docs/XRAY_INTEGRATION.md create mode 100644 utilities/upload-to-xray.js create mode 100644 utilities/xray-json-reporter.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index a08587f..dd9d9a6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -146,15 +146,15 @@ jobs: command: node utilities/browserstackEvidenceDownload.js when: always - run: - name: Get API token - command: | - echo export token=$(curl -H "Content-Type: application/json" -X POST --data "{ \"client_id\": \"$CLIENT_ID\",\"client_secret\": \"$CLIENT_SECRET\" }" https://xray.cloud.getxray.app/api/v1/authenticate| tr -d '"') >> $BASH_ENV - source $BASH_ENV + name: Build TypeScript utilities + command: npm run build when: always - run: - name: Send Results to XRAY - command: 'curl -H "Content-Type: text/xml" -H "Authorization: Bearer $token" --data @test-output/test-results.xml "https://xray.cloud.getxray.app/api/v1/import/execution/junit?testExecKey=<< pipeline.parameters.testExecKey >>"' + name: Upload Results to Xray (JSON) + command: node utilities/upload-to-xray.js test-results/last-run.json when: always + environment: + TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> - run: name: Add Test Evidence to JIRA command: node utilities/sendTestEvidenceToJira.js @@ -273,15 +273,15 @@ jobs: command: node utilities/browserstackEvidenceDownload.js when: always - run: - name: Get API token - command: | - echo export token=$(curl -H "Content-Type: application/json" -X POST --data "{ \"client_id\": \"$CLIENT_ID\",\"client_secret\": \"$CLIENT_SECRET\" }" https://xray.cloud.getxray.app/api/v1/authenticate| tr -d '"') >> $BASH_ENV - source $BASH_ENV + name: Build TypeScript utilities + command: npm run build when: always - run: - name: Send Results to XRAY - command: 'curl -H "Content-Type: text/xml" -H "Authorization: Bearer $token" --data @test-output/test-results.xml "https://xray.cloud.getxray.app/api/v1/import/execution/junit?testExecKey=<< pipeline.parameters.testExecKey >>"' + name: Upload Results to Xray (JSON) + command: node utilities/upload-to-xray.js test-results/last-run.json when: always + environment: + TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> - run: name: Add Test Evidence to JIRA command: node utilities/sendTestEvidenceToJira.js diff --git a/build/endpoint-schema/auth-endpoints.js b/build/endpoint-schema/auth-endpoints.js new file mode 100644 index 0000000..aa3c6ec --- /dev/null +++ b/build/endpoint-schema/auth-endpoints.js @@ -0,0 +1,53 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.refreshTokenSchema = exports.logoutSchema = exports.loginSchema = void 0; +/** + * Schema for user authentication login + */ +exports.loginSchema = { + url: /\/auth\/login$/, + method: 'POST', + expectedStatus: 200, + requestSchema: { + username: 'string', + password: 'string', + }, + responseSchema: { + userid: 'string', + username: 'string', + emails: 'object', + roles: 'object', + }, + validationFields: ['userid', 'username', 'emails', 'roles'], + requiredFields: [ + 'userid', // Auth endpoints require userid instead of fullName + 'username', // Username is also critical for auth + ], +}; +/** + * Schema for user logout + */ +exports.logoutSchema = { + url: /\/auth\/logout$/, + method: 'POST', + expectedStatus: 200, + validationFields: [ + // Logout typically doesn't return data to validate + ], +}; +/** + * Schema for token refresh + */ +exports.refreshTokenSchema = { + url: /\/auth\/token$/, + method: 'POST', + expectedStatus: 200, + responseSchema: { + userid: 'string', + username: 'string', + }, + validationFields: ['userid', 'username'], + requiredFields: [ + 'userid', // Token refresh must return userid + ], +}; diff --git a/build/endpoint-schema/endpoint-registry.js b/build/endpoint-schema/endpoint-registry.js new file mode 100644 index 0000000..d608347 --- /dev/null +++ b/build/endpoint-schema/endpoint-registry.js @@ -0,0 +1,52 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ENDPOINT_REGISTRY = void 0; +exports.getEndpointSchema = getEndpointSchema; +const profile_endpoints_1 = require("./profile-endpoints"); +const patient_data_endpoints_1 = require("./patient-data-endpoints"); +const auth_endpoints_1 = require("./auth-endpoints"); +// Import other endpoint schemas as they're created +/** + * Centralized endpoint registry for all API validation + * This allows network helpers to work with any endpoint by name + * + * ADDING NEW ENDPOINTS: + * 1. Define the endpoint schema in the appropriate *-endpoints.ts file + * 2. Include validationFields array for data consistency checking + * 3. Add the endpoint to this registry + * 4. The validationFields will automatically be used by NetworkHelper methods + * + * VALIDATION FIELDS: + * - Use dot notation for nested fields (e.g., 'patient.fullName') + * - Include all fields that should be validated for data consistency + * - Different endpoints can have different validation requirements + * - Fields are endpoint-specific and stored in the schema definition + */ +exports.ENDPOINT_REGISTRY = { + // Profile endpoints + 'profile-metadata-get': profile_endpoints_1.getProfileMetadataSchema, + 'profile-metadata-put': profile_endpoints_1.putProfileMetadataSchema, + 'profile-patient-data-get': profile_endpoints_1.getPatientDataSchema, + 'profile-metrics-get': profile_endpoints_1.getMetricsSchema, + 'profile-message-notes-get': profile_endpoints_1.getMessageNotesSchema, + // Patient data endpoints + 'patient-data-get': patient_data_endpoints_1.getPatientDataSchema, + 'patient-data-upload': patient_data_endpoints_1.uploadPatientDataSchema, + // Auth endpoints + 'auth-login': auth_endpoints_1.loginSchema, + 'auth-logout': auth_endpoints_1.logoutSchema, + 'auth-refresh-token': auth_endpoints_1.refreshTokenSchema, + // Add more endpoints as needed... + // 'clinic-get': clinicGetSchema, + // 'clinic-update': clinicUpdateSchema, +}; +/** + * Get endpoint schema by name + */ +function getEndpointSchema(endpointName) { + const schema = exports.ENDPOINT_REGISTRY[endpointName]; + if (!schema) { + throw new Error(`Endpoint schema not found: ${endpointName}`); + } + return schema; +} diff --git a/build/endpoint-schema/patient-data-endpoints.js b/build/endpoint-schema/patient-data-endpoints.js new file mode 100644 index 0000000..2443fb0 --- /dev/null +++ b/build/endpoint-schema/patient-data-endpoints.js @@ -0,0 +1,56 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getPatientSettingsSchema = exports.uploadPatientDataSchema = exports.getPatientDataSchema = void 0; +/** + * Schema for patient data GET endpoint + */ +exports.getPatientDataSchema = { + url: /\/v1\/patients\/[^/]+\/data$/, + method: 'GET', + expectedStatus: 200, + responseSchema: { + data: 'object', + meta: { + count: 'number', + size: 'number', + }, + }, + validationFields: ['data', 'meta.count', 'meta.size'], +}; +/** + * Schema for uploading patient data + */ +exports.uploadPatientDataSchema = { + url: /\/v1\/patients\/[^/]+\/data$/, + method: 'POST', + expectedStatus: 201, + requestSchema: { + data: 'object', + deviceId: 'string', + uploadId: 'string', + }, + responseSchema: { + id: 'string', + success: 'boolean', + }, + validationFields: ['id', 'success'], +}; +/** + * Schema for getting patient settings + */ +exports.getPatientSettingsSchema = { + url: /\/v1\/patients\/[^/]+\/settings$/, + method: 'GET', + expectedStatus: 200, + responseSchema: { + bgTarget: { + low: 'number', + high: 'number', + }, + units: { + bg: 'string', + }, + siteChangeSource: 'string', + }, + validationFields: ['bgTarget.low', 'bgTarget.high', 'units.bg', 'siteChangeSource'], +}; diff --git a/build/endpoint-schema/profile-endpoints.js b/build/endpoint-schema/profile-endpoints.js new file mode 100644 index 0000000..0605a5b --- /dev/null +++ b/build/endpoint-schema/profile-endpoints.js @@ -0,0 +1,107 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getMessageNotesSchema = exports.getMetricsSchema = exports.getPatientDataSchema = exports.putProfileMetadataSchema = exports.getProfileMetadataSchema = void 0; +/** + * Schema for profile metadata GET endpoint + */ +exports.getProfileMetadataSchema = { + url: /\/metadata\/.*\/profile$/, + method: 'GET', + expectedStatus: 200, + responseSchema: { + fullName: 'string', + patient: 'object', + }, + validationFields: [ + 'fullName', + 'patient.fullName', + 'patient.birthday', + 'patient.diagnosisDate', + 'patient.diagnosisType', + 'patient.targetDevices', + 'patient.targetTimezone', + 'patient.about', + 'patient.isOtherPerson', + 'patient.mrn', + 'patient.biologicalSex', + 'email', + 'patient.email', + 'patient.emails', + 'emails', + ], + requiredFields: [ + 'fullName', // Profile endpoint must have fullName + ], +}; +/** + * Schema for profile metadata PUT endpoint + */ +exports.putProfileMetadataSchema = { + url: /\/metadata\/.*\/profile$/, + method: 'PUT', + expectedStatus: 200, + requestSchema: { + fullName: 'string', + patient: 'object', + }, + responseSchema: { + fullName: 'string', + patient: 'object', + }, + validationFields: [ + 'fullName', + 'patient.fullName', + 'patient.birthday', + 'patient.diagnosisDate', + 'patient.diagnosisType', + 'patient.targetDevices', + 'patient.targetTimezone', + 'patient.about', + 'patient.isOtherPerson', + 'patient.mrn', + 'patient.biologicalSex', + 'email', + 'patient.email', + 'patient.emails', + 'emails', + ], + requiredFields: [ + 'fullName', // Profile endpoint must have fullName + ], +}; +/** + * Schema for patient data GET endpoint + */ +exports.getPatientDataSchema = { + url: /\/data\/[^/]+\?.*$/, + method: 'GET', + expectedStatus: 200, + responseSchema: { + // Patient data array - structure will vary + }, + validationFields: [ + // Data array validation fields would go here based on specific data types + ], +}; +/** + * Schema for metrics/analytics endpoint + */ +exports.getMetricsSchema = { + url: /\/metrics\/thisuser\/.*$/, + method: 'GET', + expectedStatus: 200, + validationFields: [ + // Metrics-specific validation fields would go here + ], +}; +/** + * Schema for message notes endpoint + */ +exports.getMessageNotesSchema = { + url: /\/message\/notes\/[^/]+\?.*$/, + method: 'GET', + expectedStatus: 200, // We'll handle 404 as acceptable in the validation logic + validationFields: [ + // Message notes validation fields would go here + ], +}; diff --git a/build/page-objects/LoginPage.js b/build/page-objects/LoginPage.js new file mode 100644 index 0000000..bf30499 --- /dev/null +++ b/build/page-objects/LoginPage.js @@ -0,0 +1,44 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +/** + * @class + * @property {Page} page + * @property {Locator} emailInput + * @property {Locator} nextButton + * @property {Locator} passwordInput + * @property {Locator} loginButton + */ +class LoginPage { + /** + * @param {Page} page + */ + constructor(page) { + this.page = page; + this.emailInput = page.getByRole('textbox', { name: 'Email' }); + this.nextButton = page.getByRole('button', { name: 'Next' }); + this.passwordInput = page.getByRole('textbox', { name: 'Password' }); + this.loginButton = page.getByRole('button', { name: 'Log In' }); + } + /** + * Navigate to the login page + * @returns {Promise} + */ + async goto() { + await this.page.goto(`/`); + } + /** + * Login to the application + * @param {string} email - User's email + * @param {string} password - User's password + * @returns {Promise} + */ + // @step("When the user logs in to the application") + async login(email, password) { + await this.emailInput.fill(email); + await this.nextButton.click(); + await this.passwordInput.fill(password); + await this.loginButton.click(); + await this.page.setViewportSize({ width: 1920, height: 1080 }); + } +} +exports.default = LoginPage; diff --git a/build/page-objects/account/AccountNavigation.js b/build/page-objects/account/AccountNavigation.js new file mode 100644 index 0000000..bfc75bc --- /dev/null +++ b/build/page-objects/account/AccountNavigation.js @@ -0,0 +1,62 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class AccountNav { + constructor(page) { + this.page = page; + this.pages = { + AccountNav: { + name: 'AccountNav', + link: page.locator('button#navigation-menu-trigger'), // Use exact ID to identify menu trigger + verifyURL: '', + verifyElement: page + .locator('button.navigation-menu-option') + .filter({ hasText: 'Private Workspace' }), + }, + PrivateWorkspace: { + name: 'PrivateWorkspace', + link: page + .locator('button.navigation-menu-option') + .filter({ hasText: 'Private Workspace' }), + verifyURL: 'workspaces', + verifyElement: page.getByText('View data for:'), + }, + AccountSettings: { + name: 'AccountSettings', + link: page + .locator('#navigationMenu button.navigation-menu-option') + .filter({ hasText: 'Account Settings' }), + verifyURL: 'account', + verifyElement: page.locator('.profile-subnav-title').getByText('Account'), // Target the specific Account title element + }, + ManageWorkspaces: { + name: 'ManageWorkspaces', + link: page + .locator('#navigationMenu button.navigation-menu-option') + .filter({ hasText: 'Manage Workspaces' }), + verifyURL: 'workspaces', + verifyElement: page.getByText('Welcome To Tidepool'), // Should land back on the workspace selection page + }, + Logout: { + name: 'Logout', + link: page + .locator('#navigationMenu button.navigation-menu-option') + .filter({ hasText: 'Logout' }), + verifyURL: 'login', + verifyElement: page.getByRole('heading', { name: 'Log in to Tidepool' }), + }, + }; + } + /** + * Navigate to a page in the account navigation menu by key. + * Example: await accountNav.navigateTo('AccountSettings'); + */ + async navigateTo(pageKey) { + // Always open the navigation menu first + await this.pages.AccountNav.link.click(); + // Then click the desired page + await this.pages[pageKey].link.click(); + // Wait for the verification element to appear + await this.pages[pageKey].verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + } +} +exports.default = AccountNav; diff --git a/build/page-objects/account/AccountSettingsPage.js b/build/page-objects/account/AccountSettingsPage.js new file mode 100644 index 0000000..a3d10e5 --- /dev/null +++ b/build/page-objects/account/AccountSettingsPage.js @@ -0,0 +1,13 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AccountSettingsPage = void 0; +class AccountSettingsPage { + constructor(page) { + this.page = page; + this.emailInput = page.getByRole('textbox', { name: 'Email' }); + this.saveButton = page.getByRole('button', { name: /save/i }); + this.saveConfirm = page.getByText(/All Changes Saved/i); + } +} +exports.AccountSettingsPage = AccountSettingsPage; +exports.default = AccountSettingsPage; diff --git a/build/page-objects/clinician/ClinicCreationPage.js b/build/page-objects/clinician/ClinicCreationPage.js new file mode 100644 index 0000000..e162e1b --- /dev/null +++ b/build/page-objects/clinician/ClinicCreationPage.js @@ -0,0 +1,84 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class ClinicCreationPage { + constructor(page) { + this.url = '/clinic-details/new'; + this.page = page; + // Page header elements + this.pageHeader = page.getByText('Create your Clinic Workspace'); + this.pageDescription = page.getByText('The information below will be displayed along with your name'); + // Form input fields + this.clinicNameInput = page.getByLabel('Clinic Name'); + this.teamTypeDropdown = page.getByRole('combobox', { name: 'What best describes your team?' }); + this.countryDropdown = page.getByRole('combobox', { name: 'Country' }); + this.stateDropdown = page.getByRole('combobox', { name: 'State' }); + this.addressInput = page.getByLabel('Address'); + this.cityInput = page.getByLabel('City'); + this.zipCodeInput = page.getByLabel('Zip code'); + this.websiteInput = page.getByLabel('Website (optional)'); + // Blood glucose units radio buttons + this.mgdlRadio = page.getByLabel('mg/dL'); + this.mmolRadio = page.getByLabel('mmol/L'); + // Acknowledgement checkbox + this.adminAcknowledgeCheckbox = page.getByRole('checkbox', { + name: 'By creating this clinic, your Tidepool account will become the default administrator', + }); + // Action buttons + this.backButton = page.getByRole('button', { name: 'Back' }); + this.createWorkspaceButton = page.getByRole('button', { name: 'Create Workspace' }); + } + /** + * Navigate to the clinic creation page + */ + async goto() { + await this.page.goto(this.url); + } + /** + * Fill the clinic creation form with required information + * @param clinicName - Name of the clinic + * @param teamType - Type of the team + * @param state - State (for US clinics) + * @param address - Street address + * @param city - City name + * @param zipCode - Zip/Postal code + * @param website - Optional website URL + */ + async fillClinicForm({ clinicName, teamType = 'Provider Practice', state = 'California', address = '123 Test Street', city = 'Test City', zipCode = '12345', website = '', }) { + // Fill in clinic name + await this.clinicNameInput.fill(clinicName); + // Select team type + await this.teamTypeDropdown.selectOption(teamType); + // Select state (US is selected by default) + await this.stateDropdown.selectOption(state); + // Fill in address details + await this.addressInput.fill(address); + await this.cityInput.fill(city); + await this.zipCodeInput.fill(zipCode); + // Fill in optional website if provided + if (website) { + await this.websiteInput.fill(website); + } + } + /** + * Select blood glucose units + * @param unit - "mg/dL" or "mmol/L" + */ + async selectBloodGlucoseUnit(unit) { + if (unit === 'mg/dL') { + await this.mgdlRadio.check(); + } + else { + await this.mmolRadio.check(); + } + } + /** + * Create a clinic by filling the form and submitting + * @param clinicName - Name of the clinic to create (required) + * @param formData - Optional form data (uses defaults if not provided) + */ + async createClinic(clinicName, formData) { + await this.fillClinicForm({ clinicName, ...formData }); + await this.createWorkspaceButton.click(); + } +} +exports.default = ClinicCreationPage; diff --git a/build/page-objects/clinician/ClinicianDashboardPage.js b/build/page-objects/clinician/ClinicianDashboardPage.js new file mode 100644 index 0000000..01edc05 --- /dev/null +++ b/build/page-objects/clinician/ClinicianDashboardPage.js @@ -0,0 +1,79 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class ClinicianDashboardPage { + constructor(page) { + this.url = '/clinic-workspace'; + this.name = 'ClinicianDashboardPage'; // Added name for step decorator context + this.page = page; + // Main page locators + this.addNewPatientButton = page.getByRole('button', { name: 'Add New Patient' }); + this.searchInput = page.getByRole('textbox', { name: 'Search' }); + this.patientListTable = page.getByRole('table', { name: 'peopletablelabel' }); + // Add Patient Dialog locators + this.addPatientDialog = page.getByRole('dialog', { name: /Add New Patient Account/i }); + this.addPatientDialog_fullNameInput = this.addPatientDialog.getByRole('textbox', { + name: 'Full Name', + }); + this.addPatientDialog_birthdateInput = this.addPatientDialog.getByRole('textbox', { + name: 'Birthdate', + }); + this.addPatientDialog_addButton = this.addPatientDialog.getByRole('button', { + name: 'Add Patient', + }); + // Bring Data Dialog locators + this.bringDataDialog = page.getByRole('dialog', { name: /Bring Data into Tidepool/i }); + this.bringDataDialog_doneButton = this.bringDataDialog.getByRole('button', { name: 'Done' }); + } + /** + * Opens the Add Patient dialog and fills in the patient details. + * @param name - The full name of the patient. + * @param birthdate - The birthdate of the patient (e.g., MM/DD/YYYY). + */ + async openAndFillAddPatientDialog(name, birthdate) { + await this.addNewPatientButton.click(); + await this.addPatientDialog.waitFor({ state: 'visible' }); + await this.addPatientDialog_fullNameInput.fill(name); + await this.addPatientDialog_birthdateInput.fill(birthdate); + } + /** + * Clicks the Add Patient button in the dialog to submit the new patient. + */ + async submitAddPatientDialog() { + await this.addPatientDialog_addButton.click(); + } + /** + * Closes the Bring Data into Tidepool dialog by clicking Done. + */ + async closeBringDataDialog() { + await this.bringDataDialog.waitFor({ state: 'visible' }); + await this.bringDataDialog_doneButton.click(); + await this.bringDataDialog.waitFor({ state: 'hidden' }); + } + /** + * Searches for a patient in the list. + * @param name - The name of the patient to search for. + */ + async searchForPatient(name) { + await this.searchInput.fill(name); + // Press Enter to trigger search + await this.searchInput.press('Enter'); + // Wait longer for search to process and results to load + await this.page.waitForTimeout(3000); + } + /** + * Gets the locator for a patient cell in the table by name. + * @param name - The name of the patient. + * @returns Locator for the table cell containing the patient's name. + */ + getPatientCellByName(name) { + // Use exact match to avoid multiple matches with similar names + return this.patientListTable.getByRole('cell', { name, exact: true }); + } + /** + * Waits for the main elements of the Clinic Workspace page to be visible. + */ + async waitForLoadState() { + await this.addNewPatientButton.waitFor({ state: 'visible' }); + } +} +exports.default = ClinicianDashboardPage; diff --git a/build/page-objects/clinician/ClinicianNavigation.js b/build/page-objects/clinician/ClinicianNavigation.js new file mode 100644 index 0000000..7cabb9b --- /dev/null +++ b/build/page-objects/clinician/ClinicianNavigation.js @@ -0,0 +1,119 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class ClinicianNav { + constructor(page) { + this.page = page; + // Define hardcoded workspace configurations (matching PatientNavigation approach) + this.workspaces = { + AdminClinicBase: { + name: 'Admin Clinic (Base)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Admin Clinic (Base) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Admin Clinic (Base)' }), + }, + AdminClinicEnterprise: { + name: 'Admin Clinic (Enterprise)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Admin Clinic (Enterprise) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Admin Clinic (Enterprise)' }), + }, + MemberClinicBase: { + name: 'Member Clinic (Base)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Member Clinic (Base) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Member Clinic (Base)' }), + }, + MemberClinicEnterprise: { + name: 'Member Clinic (Enterprise)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Member Clinic (Enterprise) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Member Clinic (Enterprise)' }), + }, + NonMemberClinicBase: { + name: 'Non-Member Clinic (Base)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Non-Member Clinic (Base) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Non-Member Clinic (Base)' }), + }, + NonMemberClinicEnterprise: { + name: 'Non-Member Clinic (Enterprise)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Non-Member Clinic (Enterprise) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Non-Member Clinic (Enterprise)' }), + }, + PartnerClinicBase: { + name: 'Partner Clinic (Base)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Partner Clinic (Base) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Partner Clinic (Base)' }), + }, + PartnerClinicEnterprise: { + name: 'Partner Clinic (Enterprise)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Partner Clinic (Enterprise) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Partner Clinic (Enterprise)' }), + }, + }; + // Define clinician page navigation (matching PatientNavigation format) + this.pages = { + PatientList: { + name: 'PatientList', + link: page.getByRole('link', { name: 'Patients' }), + verifyURL: 'clinic-workspace/patients', + verifyElement: page.getByRole('heading', { name: 'Patients' }), + }, + WorkspaceSettings: { + name: 'WorkspaceSettings', + link: page.getByRole('link', { name: 'Workspace Settings' }), + verifyURL: 'clinic-workspace/workspace/settings', + verifyElement: page.getByRole('heading', { name: 'Workspace Settings' }), + }, + AddPatient: { + name: 'AddPatient', + link: page.getByRole('button', { name: 'Add Patient' }), + verifyURL: 'clinic-workspace/patients/add', + verifyElement: page.getByRole('heading', { name: 'Add Patient' }), + }, + Profile: { + name: 'Profile', + link: page + .getByRole('button', { name: 'Patient Profile Profile' }) + .or(page.getByRole('tab', { name: 'Profile' })) + .or(page.getByRole('link', { name: 'Profile' })) + .or(page.getByRole('button', { name: 'Profile' })), + verifyURL: 'profile', + verifyElement: page + .getByRole('button', { name: 'Edit' }) + .or(page.getByRole('button', { name: 'Edit Profile' })), + }, + ProfileEdit: { + name: 'ProfileEdit', + link: page + .getByRole('button', { name: 'Edit' }) + .or(page.getByRole('button', { name: 'Edit Profile' })), + verifyURL: 'profile', + verifyElement: page + .getByRole('button', { name: 'Save changes' }) + .or(page.getByRole('button', { name: 'Save Profile' })) + .or(page.getByRole('button', { name: 'Save' })), + }, + }; + } +} +exports.default = ClinicianNav; diff --git a/build/page-objects/clinician/WorkspaceSettingsPage.js b/build/page-objects/clinician/WorkspaceSettingsPage.js new file mode 100644 index 0000000..2dffe7a --- /dev/null +++ b/build/page-objects/clinician/WorkspaceSettingsPage.js @@ -0,0 +1,29 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class ClinicAdminPage { + constructor(page) { + this.url = '/clinic-admin'; + this.name = 'ClinicAdminPage'; // Added name for step decorator context + this.page = page; + this.clinicDetailsHeader = page.getByText('Workspace Settings'); + // Assuming the edit button is specifically associated with the details section + this.editDetailsButton = page.getByRole('button', { name: 'Edit' }); + this.editClinicModal = page.getByRole('dialog'); // General dialog selector + this.editClinicModalTitle = this.editClinicModal.getByRole('heading', { + name: 'Edit Workspace Details', + }); + this.addressInput = this.editClinicModal.getByLabel('Address', { exact: true }); // Use exact label match + this.saveChangesButton = this.editClinicModal.getByRole('button', { name: 'Save Changes' }); + // Assuming the details are within a specific container section related to the header + this.clinicDetailsSection = page.locator('div:has(> span:text-is("Workspace Settings")) + div'); + } + /** + * Waits for essential elements of the Clinic Admin page to be loaded. + */ + async waitForLoadState() { + await this.page.waitForLoadState(); // Wait for base elements like header/footer + await this.clinicDetailsHeader.waitFor({ state: 'visible', timeout: 40000 }); + await this.editDetailsButton.waitFor({ state: 'visible', timeout: 10000 }); + } +} +exports.default = ClinicAdminPage; diff --git a/build/page-objects/clinician/WorkspacesPage.js b/build/page-objects/clinician/WorkspacesPage.js new file mode 100644 index 0000000..38f982f --- /dev/null +++ b/build/page-objects/clinician/WorkspacesPage.js @@ -0,0 +1,36 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const env_1 = __importDefault(require("../../utilities/env")); +class WorkspacesPage { + constructor(page) { + this.url = `${env_1.default.BASE_URL}/workspaces`; + this.page = page; + this.header = page.getByRole('heading', { name: 'Clinic Workspace' }); + this.subHeader = page.getByRole('paragraph', { + name: 'View, share and manage patient data', + }); + this.createClinicButton = page.getByRole('button', { + name: 'Create a New Clinic', + }); + } + async goto() { + await this.page.goto(this.url); + } + async visitFirstClinic() { + await this.page.getByRole('button', { name: 'Go To Workspace' }).first().click(); + } + /** + * Visit a clinic by name + * @param clinicName - The name of the clinic to visit + */ + async visitClinic(clinicName) { + // find child element with text and filter by parent element with class + const child = this.page.getByText(clinicName); + const parent = this.page.locator('.workspace-item-clinic').filter({ has: child }); + await parent.getByRole('button', { name: 'Go To Workspace' }).first().click(); + } +} +exports.default = WorkspacesPage; diff --git a/build/page-objects/clinician/components/navigation-menu.section.js b/build/page-objects/clinician/components/navigation-menu.section.js new file mode 100644 index 0000000..7aa1dda --- /dev/null +++ b/build/page-objects/clinician/components/navigation-menu.section.js @@ -0,0 +1,27 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class NavigationMenu { + constructor(page) { + this.page = page; + this.container = page.locator('div#navigation-menu'); + this.buttons = { + trigger: this.container.locator('#navigation-menu-trigger'), + menu: { + privateWorkspace: this.container.getByRole('button', { + name: 'Private Workspace', + }), + accountSettings: this.container.getByRole('button', { + name: 'Account Settings', + }), + logout: this.container.getByRole('button', { name: 'Logout' }), + }, + }; + } + async open() { + await this.buttons.trigger.click(); + } + async close() { + await this.buttons.trigger.click(); + } +} +exports.default = NavigationMenu; diff --git a/build/page-objects/clinician/components/navigation.section.js b/build/page-objects/clinician/components/navigation.section.js new file mode 100644 index 0000000..176d5ff --- /dev/null +++ b/build/page-objects/clinician/components/navigation.section.js @@ -0,0 +1,22 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const navigation_menu_section_1 = __importDefault(require("./navigation-menu.section")); +class NavigationSection { + constructor(page) { + this.page = page; + this.container = page.locator('div#navPatientHeader'); + this.menu = new navigation_menu_section_1.default(page); + this.buttons = { + viewData: this.container.getByRole('button', { name: 'View Data' }), + patientProfile: this.container.getByRole('button', { + name: 'Patient Profile', + }), + share: this.container.getByRole('button', { name: 'Share' }), + uploadData: this.container.getByRole('button', { name: 'Upload Data' }), + }; + } +} +exports.default = NavigationSection; diff --git a/build/page-objects/patient/BasicsPage.js b/build/page-objects/patient/BasicsPage.js new file mode 100644 index 0000000..5977251 --- /dev/null +++ b/build/page-objects/patient/BasicsPage.js @@ -0,0 +1,143 @@ +"use strict"; +var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) { + var useValue = arguments.length > 2; + for (var i = 0; i < initializers.length; i++) { + value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); + } + return useValue ? value : void 0; +}; +var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) { + function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; } + var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; + var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null; + var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); + var _, done = false; + for (var i = decorators.length - 1; i >= 0; i--) { + var context = {}; + for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; + for (var p in contextIn.access) context.access[p] = contextIn.access[p]; + context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); }; + var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context); + if (kind === "accessor") { + if (result === void 0) continue; + if (result === null || typeof result !== "object") throw new TypeError("Object expected"); + if (_ = accept(result.get)) descriptor.get = _; + if (_ = accept(result.set)) descriptor.set = _; + if (_ = accept(result.init)) initializers.unshift(_); + } + else if (_ = accept(result)) { + if (kind === "field") initializers.unshift(_); + else descriptor[key] = _; + } + } + if (target) Object.defineProperty(target, contextIn.name, descriptor); + done = true; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const base_1 = require("@fixtures/base"); +const PatientNavigation_1 = __importDefault(require("@pom/patient/PatientNavigation")); +const navigation_section_1 = __importDefault(require("@components/navigation.section")); +function createSection(page, selector) { + const parsedSelector = selector === 'tubing-primes' ? 'siteChanges' : selector; + const container = page.locator(`.Calendar-container-${parsedSelector}`); + return { + container, + firstDayOfData: container.locator(`.Calendar-day--${parsedSelector}.Calendar-day`).first(), + calendarDayhover: { + el: container.locator('.Calendar-day--HOVER'), + async text() { + return container.locator('.Calendar-day--HOVER').locator('.Calendar-weekday').textContent(); + }, + }, + }; +} +/** + * helper function to create a stat object with locators for the container, header, hoverBar, and hoverBarLabel + */ +function createStat(page, selector) { + const container = page.locator(`#Stat--${selector}`); + return { + container, + header: container.locator('[class^="Stat--chartTitleText"]'), + hoverBar: container.locator('.HoverBar'), + hoverBarLabel: container.locator('.HoverBarLabel'), + }; +} +// list of sections in the stats sidebar +const statsSideBarSection = [ + 'timeInRange', + 'readingsInRange', + 'averageGlucose', + 'totalInsulin', + 'carbs', + 'standardDev', + 'coefficientOfVariation', + 'sensorUsage', + 'glucoseManagementIndicator', + 'totalInsulin', + 'averageDailyDose', +]; +let PatientDataBasicsPage = (() => { + var _a; + let _instanceExtraInitializers = []; + let _goto_decorators; + return _a = class PatientDataBasicsPage { + constructor(page) { + this.page = __runInitializers(this, _instanceExtraInitializers); + this.page = page; + this.url = '/patients/data/basics'; + this.emailInput = page.getByRole('textbox', { name: 'Email' }); + this.navigationBar = new navigation_section_1.default(page); + this.navigationSubMenu = new PatientNavigation_1.default(page); + this.headerBgReading = page.getByRole('heading', { name: 'BG readings' }); + this.headerBolusing = page.getByRole('heading', { name: 'Bolusing' }); + this.statsSidebar = { + toggleContainer: page.locator('.toggle-container'), + async toggleTo(toState) { + const activeToggleState = await page + .locator(".toggle-container span[class*='TwoOptionToggle--active']") + .innerText(); + if (activeToggleState === 'BGM' && toState === 'CGM') { + await this.toggleContainer.click(); + } + else if (activeToggleState === 'CGM' && toState === 'BGM') { + await this.toggleContainer.click(); + } + }, + ...Object.fromEntries(statsSideBarSection.map(stat => [stat, createStat(page, stat)])), + }; + // charts + this.bgReadingsSection = createSection(page, 'fingersticks'); + this.bolusingSection = createSection(page, 'boluses'); + this.tubingPrimeSection = { + ...createSection(page, 'tubing-primes'), + settings: page.locator('.SiteChangeSelector-option').first(), + settingsOption: { + fillTubing: page.getByLabel('Tubing Fill'), + fillCannula: page.getByLabel('Cannula Fill'), + }, + tubingIcons: page.locator('.Change--tubing').first(), + cannulaIcons: page.locator('.Change--cannula').first(), + filledDay: createSection(page, 'tubing-primes') + .container.locator('.Calendar-day') + .filter({ has: page.locator('.Change-daysSince-text') }) + .first(), + }; + this.basalsSection = createSection(page, 'basals'); + } + async goto() { + await this.page.goto(this.url); + } + }, + (() => { + const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0; + _goto_decorators = [(0, base_1.step)('Navigate to the basics page')]; + __esDecorate(_a, null, _goto_decorators, { kind: "method", name: "goto", static: false, private: false, access: { has: obj => "goto" in obj, get: obj => obj.goto }, metadata: _metadata }, null, _instanceExtraInitializers); + if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata }); + })(), + _a; +})(); +exports.default = PatientDataBasicsPage; diff --git a/build/page-objects/patient/DailyPage.js b/build/page-objects/patient/DailyPage.js new file mode 100644 index 0000000..eb0ad4e --- /dev/null +++ b/build/page-objects/patient/DailyPage.js @@ -0,0 +1,17 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const daily_chart_js_1 = __importDefault(require("@components/daily-chart.js")); +const PatientNavigation_js_1 = __importDefault(require("@pom/patient/PatientNavigation.js")); +const navigation_section_js_1 = __importDefault(require("@components/navigation.section.js")); +class PatientDataDailyPage { + constructor(page) { + this.page = page; + this.navigationBar = new navigation_section_js_1.default(page); + this.navigationSubMenu = new PatientNavigation_js_1.default(page); + this.dailyChart = new daily_chart_js_1.default(page); + } +} +exports.default = PatientDataDailyPage; diff --git a/build/page-objects/patient/PatientNavigation.js b/build/page-objects/patient/PatientNavigation.js new file mode 100644 index 0000000..cec9e3c --- /dev/null +++ b/build/page-objects/patient/PatientNavigation.js @@ -0,0 +1,100 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class PatientNav { + // currentDate: Locator; + constructor(page) { + this.page = page; + this.pages = { + ViewData: { + name: 'ViewData', + link: page.getByRole('button', { name: 'View Data View' }), + verifyURL: 'data', + verifyElement: page.locator('div.patient-data-subnav-inner'), + }, + Basics: { + name: 'Basics', + link: page.getByRole('link', { name: 'Basics' }), + verifyURL: 'data/basics', + verifyElement: page.locator('.js-basics.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + ChartDateRange: { + name: 'ChartDateRange', + link: page + .locator('button svg, .css-15vjjnj svg, [aria-label*="calendar"], [title*="calendar"]') + .first(), // Calendar icon in blue navigation bar + verifyURL: '', + verifyElement: page.locator('#printDateRangePickerInner').locator('*').first(), // Any content inside the dialog + closeButton: page.getByRole('button', { name: 'close dialog' }), + }, + Daily: { + name: 'Daily', + link: page.getByRole('link', { name: 'Daily' }), + verifyURL: 'data/daily', + verifyElement: page.locator('.js-daily.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + ChartDate: { + name: 'ChartDate', + link: page.locator('#tidelineLabel .css-15vjjnj svg'), // Using the same calendar icon selector + verifyURL: '', + verifyElement: page.getByRole('heading', { name: 'Chart Date' }), + closeButton: page.getByRole('button', { name: 'close dialog' }), + }, + BGLog: { + name: 'BGLog', + link: page.getByRole('link', { name: 'BG Log' }), + verifyURL: 'data/bglog', + verifyElement: page.locator('.js-bgLog.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + Trends: { + name: 'Trends', + link: page.getByRole('link', { name: 'Trends' }), + verifyURL: 'data/trends', + verifyElement: page.locator('.js-trends.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + Devices: { + name: 'Devices', + link: page.getByRole('link', { name: 'Devices' }), + verifyURL: 'data/devices', + verifyElement: page.locator('.js-settings.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + Print: { + name: 'Print', + link: page.getByRole('link', { name: 'Print PDF report Print' }), // Print link from the snapshot + verifyURL: '', + verifyElement: page.getByRole('heading', { name: 'Print Report' }), // Assuming modal title + closeButton: page.getByRole('button', { name: 'close dialog' }), + }, + Profile: { + name: 'Profile', + link: page.getByRole('button', { name: 'Profile Profile' }), + verifyURL: '', + verifyElement: page.getByRole('button', { name: 'Edit' }), // Edit button is visible on profile page + }, + ProfileEdit: { + name: 'ProfileEdit', + link: page.getByRole('button', { name: 'Edit' }), + verifyURL: 'profile', + verifyElement: page.getByRole('button', { name: 'Save changes' }), // Save changes button appears when in edit mode + }, + Share: { + name: 'Share', + link: page.getByRole('button', { name: 'Share Share' }), + verifyURL: 'share', + verifyElement: page.getByRole('heading', { name: 'Access Management' }), + }, + ShareData: { + name: 'ShareData', + link: page.getByRole('button', { name: 'Share Data' }), + verifyURL: 'share/invite', + verifyElement: page.getByRole('heading', { name: 'Share your data' }), + }, + UploadData: { + name: 'UploadData', + link: page.getByRole('button', { name: 'Upload Data Upload' }), + verifyURL: 'upload', + verifyElement: page.getByRole('heading', { name: 'Upload Data' }), + }, + }; + } +} +exports.default = PatientNav; diff --git a/build/page-objects/patient/ProfilePage.js b/build/page-objects/patient/ProfilePage.js new file mode 100644 index 0000000..ef565d8 --- /dev/null +++ b/build/page-objects/patient/ProfilePage.js @@ -0,0 +1,115 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ProfilePage = void 0; +class ProfilePage { + constructor(page) { + this.page = page; + this.fieldLocators = { + fullName: this.page.getByRole('textbox', { name: 'Full name' }), + birthDate: this.page.getByRole('textbox', { name: 'Date of birth' }), + mrn: this.page.getByRole('textbox', { name: 'MRN' }), + diagnosisDate: this.page.getByRole('textbox', { name: 'Date of diagnosis' }), + clinicalNotes: this.page.getByRole('textbox', { name: 'Anything you would like to share' }), + email: this.page.getByRole('textbox', { name: /email/i }), + }; + } + // Generic fill method for text fields + async fillField(field, value) { + const locator = this.fieldLocators[field]; + if (!locator) + throw new Error(`No locator defined for field: ${field}`); + if (await locator.isVisible({ timeout: 3000 }).catch(() => false)) { + await locator.fill(value); + } + else { + throw new Error(`Field '${field}' not found or not visible`); + } + } + // Select a diagnosis type from the dropdown + async selectDiagnosisType(index) { + const diagnosisCombo = this.page.getByRole('combobox', { name: 'Diagnosed as' }); + if (await diagnosisCombo.isVisible({ timeout: 3000 })) { + await diagnosisCombo.selectOption({ index }); + } + } + // Get the current diagnosis index from the dropdown (needed for setting a new diagnosis) + async getCurrentDiagnosisIndex() { + const diagnosisCombo = this.page.getByRole('combobox', { name: 'Diagnosed as' }); + if (await diagnosisCombo.isVisible({ timeout: 3000 })) { + const currentValue = await diagnosisCombo.inputValue(); + const options = await diagnosisCombo.locator('option').all(); + // Find current index by checking option values + for (let i = 0; i < options.length; i++) { + const optionValue = await options[i].getAttribute('value'); + if (optionValue === currentValue) { + return i; + } + } + } + return 1; // Default to 1 if not found + } + // For backwards compatibility, keep these as wrappers (optional) + async fillFullName(name) { + return this.fillField('fullName', name); + } + async fillBirthDate(date) { + return this.fillField('birthDate', date); + } + async fillMRN(mrn) { + return this.fillField('mrn', mrn); + } + async fillDiagnosisDate(date) { + return this.fillField('diagnosisDate', date); + } + async fillClinicalNotes(notes) { + return this.fillField('clinicalNotes', notes); + } + async fillEmail(email) { + return this.fillField('email', email); + } + async saveProfile() { + // Save button locators + const saveButtons = [ + this.page.getByRole('button', { name: 'Save changes' }), + this.page.getByRole('button', { name: 'Save Profile' }), + this.page.getByRole('button', { name: 'Save' }), + ]; + // Wait for the PUT request to complete after clicking save + const saveProfilePromise = this.page.waitForResponse(response => response.url().includes('/metadata/') && + response.url().includes('/profile') && + response.request().method() === 'PUT'); + let clicked = false; + for (const btn of saveButtons) { + if (await btn.isVisible({ timeout: 5000 }).catch(() => false)) { + await btn.click(); + clicked = true; + break; + } + } + if (!clicked) + throw new Error('No save button found'); + // Wait for the PUT request to complete (with timeout) + try { + await saveProfilePromise; + } + catch (error) { + console.log('āš ļø PUT request timeout - continuing anyway'); + } + } + /** + * Checks if the edit button is displayed and validates against expected state + * @param shouldBeVisible - Boolean indicating whether the edit button should be visible + * @throws Error if the actual visibility doesn't match the expected state + */ + async editButtonDisplays(shouldBeVisible) { + const editButton = this.page.getByRole('button', { name: 'Edit' }); + const isEditButtonVisible = await editButton.isVisible({ timeout: 3000 }).catch(() => false); + if (shouldBeVisible && !isEditButtonVisible) { + throw new Error('Edit button should be visible but was not found'); + } + else if (!shouldBeVisible && isEditButtonVisible) { + throw new Error('Edit button should not be visible for this user - security violation!'); + } + } +} +exports.ProfilePage = ProfilePage; diff --git a/build/page-objects/patient/components/daily-chart.js b/build/page-objects/patient/components/daily-chart.js new file mode 100644 index 0000000..5eee722 --- /dev/null +++ b/build/page-objects/patient/components/daily-chart.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class DailyChartSection { + constructor(page) { + this.page = page; + this.container = page.locator('div.patient-data-content'); + this.dayLabel = this.container.locator('text.d3-day-label').filter({ visible: true }); + this.newNote = this.container.locator('image.newNoteIcon'); + this.buttons = { + refresh: this.container.getByRole('button', { name: 'Refresh' }), + }; + } +} +exports.default = DailyChartSection; diff --git a/build/playwright.config.js b/build/playwright.config.js new file mode 100644 index 0000000..2e08ea5 --- /dev/null +++ b/build/playwright.config.js @@ -0,0 +1,113 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const test_1 = require("@playwright/test"); +const node_path_1 = __importDefault(require("node:path")); +const env_1 = __importDefault(require("./utilities/env")); +const xrayOptions = { + embedAnnotationsAsProperties: true, + textContentAnnotations: ['test_description', 'testrun_comment'], + embedAttachmentsAsProperty: 'testrun_evidence', + outputFile: 'test-output/test-results.xml', +}; +// Helper to detect BrowserStack run +const isBrowserStack = Boolean(process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESS_KEY); +function buildBrowserStackEndpoint(testName) { + const caps = { + browser: 'chrome', + browser_version: 'latest', + os: 'os x', + os_version: 'catalina', + name: testName, + build: process.env.CI_BUILD_NUMBER || 'local-run', + 'browserstack.username': process.env.BROWSERSTACK_USERNAME, + 'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY, + }; + return `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify(caps))}`; +} +exports.default = (0, test_1.defineConfig)({ + testDir: './tests', + outputDir: './test-results', // Custom output directory + globalSetup: require.resolve(node_path_1.default.join(__dirname, 'tests/global-setup')), + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + timeout: 60000, + expect: { + toHaveScreenshot: { maxDiffPixelRatio: 0.2 }, + }, + reporter: [ + ['html', { open: 'never', outputFolder: 'playwright-report' }], + ['json', { outputFile: 'test-results/last-run.json' }], + ['junit', xrayOptions], + ['./utilities/xray-json-reporter.ts'], + ], + use: { + baseURL: env_1.default.BASE_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + // Custom test attachment naming + testIdAttribute: 'data-testid', + }, + projects: [ + { + name: 'chromium-personal', + testMatch: '**/personal/**/*.spec.ts', + use: { + ...test_1.devices['Desktop Chrome'], + storageState: 'tests/.auth/personal.json', + headless: false, + }, + }, + { + name: 'chromium-claimed', + testMatch: '**/claimed/**/*.spec.ts', + use: { + ...test_1.devices['Desktop Chrome'], + storageState: 'tests/.auth/claimed.json', + headless: false, + }, + }, + { + name: 'chromium-clinician', + testMatch: '**/clinician/**/*.spec.ts', + use: { + ...test_1.devices['Desktop Chrome'], + storageState: 'tests/.auth/clinician.json', + headless: false, + }, + }, + ...(isBrowserStack + ? [ + { + name: 'bs-chrome-personal', + testMatch: '**/patient/**/*.spec.ts', + use: { + storageState: 'tests/.auth/personal.json', + connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Personal Patient Tests') }, + }, + }, + { + name: 'bs-chrome-claimed', + testMatch: '**/claimed/**/*.spec.ts', + use: { + storageState: 'tests/.auth/claimed.json', + connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Claimed Patient Tests') }, + }, + }, + { + name: 'bs-chrome-clinician', + testMatch: '**/clinician/**/*.spec.ts', + use: { + storageState: 'tests/.auth/clinician.json', + connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Clinician Tests') }, + }, + }, + ] + : []), + ], +}); diff --git a/build/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js b/build/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js new file mode 100644 index 0000000..ba00295 --- /dev/null +++ b/build/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js @@ -0,0 +1,148 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const base_1 = require("../../fixtures/base"); +const patient_helpers_1 = require("../../fixtures/patient-helpers"); +const account_helpers_1 = require("../../fixtures/account-helpers"); +const clinic_helpers_1 = require("../../fixtures/clinic-helpers"); +const network_helpers_1 = require("../../fixtures/network-helpers"); +const test_tags_1 = require("../../fixtures/test-tags"); +const AccountSettingsPage_1 = require("../../../page-objects/account/AccountSettingsPage"); +const ProfilePage_1 = require("../../../page-objects/patient/ProfilePage"); +const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; +const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; +base_1.test.describe('Claimed Account Settings edit (Full Name only) updates Profile endpoint and visually updates for user, clinic, and shared member', () => { + base_1.test.setTimeout(120000); // 2 minute timeout for multi-phase test + let api; + let putCapture; + let newName; // Declare at test level scope + (0, base_1.test)('should allow navigation to account settings, edit full name, and verify profile update for claimed, shared, and clinician users', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.PATIENT, + test_tags_1.TEST_TAGS.CLINICIAN, // Added clinician tag + test_tags_1.TEST_TAGS.CLAIMED, + test_tags_1.TEST_TAGS.SHARED_MEMBER, // Added shared member tag + test_tags_1.TEST_TAGS.API, + test_tags_1.TEST_TAGS.UI, + test_tags_1.TEST_TAGS.HIGH, + test_tags_1.TEST_TAGS.API_PROFILE, + ]), + }, async ({ page }) => { + // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== + // Step 1: Log in to clinician account and setup network capture + await base_1.test.step('Given claimed account has been logged in', async () => { + api = (0, network_helpers_1.createNetworkHelper)(page); + await api.startCapture(); + await page.goto('/data'); + await patient_helpers_1.test.patient.setup(page); + }); + // Step 2: Navigate to account settings + await base_1.test.step('When user navigates to account settings', async () => { + await account_helpers_1.test.account.navigateTo('AccountSettings', page); + }); + // Step 3: GET response is pulled and validated + await base_1.test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Create new acccount settings page for the following test + const accountSettingsPage = new AccountSettingsPage_1.AccountSettingsPage(page); + // Step 4: Change the Full Name field to a new value + await base_1.test.step('When user updates the Full Name field', async () => { + newName = `Claimed User Updated ${Math.floor(Math.random() * 10000)}`; // Remove let declaration + const nameInput = page.getByRole('textbox', { name: /full name/i }); + await nameInput.fill(newName); + }); + // Step 5: Tap the Save button + await base_1.test.step('When user taps the save button', async () => { + await accountSettingsPage.saveButton.click(); + }); + // Step 6: Confirm save changes message displays + await base_1.test.step('Then the save changes message displays', async () => { + await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); + }); + // Step 7: Validate PUT request and save value + await base_1.test.stepNoScreenshot('Then PUT request is validated and name is set to new value', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + putCapture = api + .getCaptures() + .find((req) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) + throw new Error('No PUT /profile request captured'); + if (!putCapture.requestBody || + !putCapture.requestBody.fullName || + putCapture.requestBody.fullName !== newName) { + throw new Error(`PUT request did not set fullName to ${newName}`); + } + }); + // Step 8: Navigate to Profile page + await base_1.test.step('When user navigates to Profile page', async () => { + await patient_helpers_1.test.patient.navigateTo('Profile', page); + }); + // Step 9: Confirm GET request matches the saved PUT request + await base_1.test.stepNoScreenshot('Then GET request matches the saved PUT request', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + // Get all captures and find the LATEST GET request (after the PUT) + const allCaptures = api.getCaptures(); + const putIndex = allCaptures.findIndex(req => req === putCapture); + // Find GET requests that occurred AFTER the PUT request + const laterGetCaptures = allCaptures + .slice(putIndex + 1) + .filter((req) => req.method === 'GET' && req.url.includes('/profile')); + if (laterGetCaptures.length === 0) { + throw new Error('No GET /profile request captured after the PUT request'); + } + // Use the most recent GET request + const getCapture = laterGetCaptures[laterGetCaptures.length - 1]; + if (!getCapture.responseBody || + getCapture.responseBody.fullName !== putCapture.requestBody.fullName) { + console.log('GET response fullName:', getCapture.responseBody.fullName); + console.log('PUT request fullName:', putCapture.requestBody.fullName); + console.log('Total captures:', allCaptures.length); + console.log('PUT index:', putIndex); + console.log('Later GET captures found:', laterGetCaptures.length); + throw new Error('GET response fullName does not match PUT request fullName'); + } + }); + // ========== PHASE 2: SHARED USER VIEWS PROFILE ========== + // Step 10: Switch to shared user authentication and go directly to Profile + await base_1.test.step('When shared user views claimed user profile', async () => { + await account_helpers_1.test.account.switchUser('shared', page); + await page.goto('/data'); + await patient_helpers_1.test.patient.setup(page); + // Wait a moment for the page to stabilize after user switch + await page.waitForTimeout(500); + // Navigate directly to Profile in the same step to avoid redundancy + await patient_helpers_1.test.patient.navigateTo('Profile', page); + }); + // Step 11: Verify Edit button is not present for shared users + await base_1.test.step('Then Edit button should not be present for shared patients', async () => { + const profilePage = new ProfilePage_1.ProfilePage(page); + await profilePage.editButtonDisplays(false); + }); + // Step 12: Validate shared user sees updated profile data + await base_1.test.stepNoScreenshot('Then shared user sees view-only claimed profile data with matching data', async () => { + await api.compareEndpointResponse('profile-metadata-get', putCapture); + }); + // ========== PHASE 3: CLINICIAN VIEWS PROFILE ========== + // Step 13: Switch to clinician user authentication + await base_1.test.step('When clinician accesses patient workspace', async () => { + await account_helpers_1.test.account.switchUser('clinician', page); + await page.goto('/'); + await clinic_helpers_1.test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); + }); + // Step 14: Access the specific claimed patient that was modified by the producer test + await base_1.test.step('When user accesses the claimed patient modified by producer test', async () => { + await clinic_helpers_1.test.clinician.findAndAccessPatientByPartialName(CLAIMED_PATIENT_SEARCH, page); + // Navigate directly to Profile in the same step to avoid redundancy + await clinic_helpers_1.test.clinician.navigateTo('Profile', page); + }); + // Step 15: Verify Edit button is not present for claimed patients viewed by clinicians + await base_1.test.step('Then Edit button should not be present for claimed patients', async () => { + const profilePage = new ProfilePage_1.ProfilePage(page); + await profilePage.editButtonDisplays(false); + }); + // Step 16: Validate clinician sees updated profile data + await base_1.test.stepNoScreenshot('Then clinician sees claimed profile data with matching data and no save access', async () => { + await api.compareEndpointResponse('profile-metadata-get', putCapture); + }); + }); +}); diff --git a/build/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js b/build/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js new file mode 100644 index 0000000..7847f31 --- /dev/null +++ b/build/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js @@ -0,0 +1,159 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +const base_1 = require("../../fixtures/base"); +const patient_helpers_1 = require("../../fixtures/patient-helpers"); +const clinic_helpers_1 = require("../../fixtures/clinic-helpers"); +const account_helpers_1 = require("../../fixtures/account-helpers"); +const network_helpers_1 = require("../../fixtures/network-helpers"); +const test_tags_1 = require("../../fixtures/test-tags"); +const ProfilePage_1 = require("../../../page-objects/patient/ProfilePage"); +const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; +const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; +base_1.test.describe('Comprehensive Profile Access Test: Edit as Claimed, View as Shared and Clinician', () => { + (0, base_1.test)('should edit claimed profile then verify view-only access for shared and clinician users', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.PATIENT, // User Type (required) + test_tags_1.TEST_TAGS.CLINICIAN, // User Type (required) + test_tags_1.TEST_TAGS.CLAIMED, + test_tags_1.TEST_TAGS.SHARED_MEMBER, + test_tags_1.TEST_TAGS.API, // Test Type (required) + test_tags_1.TEST_TAGS.UI, // Test Type (required) + test_tags_1.TEST_TAGS.HIGH, // Priority (required) + test_tags_1.TEST_TAGS.API_PROFILE, // Feature (optional) + ]), + }, async ({ page }) => { + let api; + let producerPutCapture; + // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== + // Step 1: Claimed account has been logged in + await base_1.test.step('Given claimed account has been logged in', async () => { + api = (0, network_helpers_1.createNetworkHelper)(page); + await api.startCapture(); + await page.goto('/data'); + await patient_helpers_1.test.patient.setup(page); + }); + // Step 2: User navigates to Profile page + await base_1.test.step('When user navigates to Profile page', async () => { + await patient_helpers_1.test.patient.navigateTo('Profile', page); + }); + // Step 3: GET response is pulled and validated + await base_1.test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Step 4: Confirm edit button and click it + await base_1.test.step('When user selects Edit button', async () => { + await patient_helpers_1.test.patient.navigateTo('ProfileEdit', page); + }); + // Initialize ProfilePage for steps 4 and 5 + const profilePage = new ProfilePage_1.ProfilePage(page); + // Step 5: Change profile fields (confirmed user access) + await base_1.test.step('When user updates profile fields', async () => { + const testRunId = Math.floor(Math.random() * 10000); + const updatedName = `Claimed User Updated ${testRunId}`; + const birthYear = 1985 + (testRunId % 10); + const diagnosisYear = birthYear + 20; + const birthDate = `01/15/${birthYear}`; + const diagnosisDate = `03/10/${diagnosisYear}`; + // Generate random 15-letter string for clinical notes + const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); + // Get current diagnosis index and calculate next one (1-7, wrapping) + const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); + let nextDiagnosisIndex = currentDiagnosisIndex + 1; + if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { + nextDiagnosisIndex = 1; + } + // Update fields using ProfilePage methods + await profilePage.fillFullName(updatedName); + await profilePage.fillBirthDate(birthDate); + await profilePage.fillDiagnosisDate(diagnosisDate); + await profilePage.selectDiagnosisType(nextDiagnosisIndex); + await profilePage.fillClinicalNotes(randomString); + }); + // Step 6: Save profile edit + await base_1.test.step('When user saves profile changes', async () => { + await profilePage.saveProfile(); + }); + // Step 7: PUT response is validated and saved for comparison + await base_1.test.stepNoScreenshot('Then profile endpoint responds with PUT request consistent with schema', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putSchema = await Promise.resolve().then(() => __importStar(require('../../../endpoint-schema/profile-endpoints'))); + const schema = putSchema.putProfileMetadataSchema; + producerPutCapture = api.getLatestCaptureMatching(schema.method, schema.url); + }); + //= ========= SHARED MEMEBER VIEWS PROFILE ========== + // Step 8: Switch to shared user authentication + await base_1.test.step('When shared user views claimed user profile', async () => { + await account_helpers_1.test.account.switchUser('shared', page); + await page.goto('/data'); + await patient_helpers_1.test.patient.navigateTo('ViewData', page); + }); + // Step 9: Navigate to profile page + await base_1.test.step('When user navigates to Profile page', async () => { + await patient_helpers_1.test.patient.navigateTo('Profile', page); + }); + // Step 10: Confirm edit button is not present + await base_1.test.step('Then Edit button should not be present for shared patients', async () => { + await profilePage.editButtonDisplays(false); + }); + // Step 11: Validate GET response and compare it against the + await base_1.test.stepNoScreenshot('Then shared user sees view-only claimed profile data with matching data', async () => { + await api.compareEndpointResponse('profile-metadata-get', producerPutCapture); + }); + // ========== CLINICIAN VIEWS PROFILE ========== + // Step 12: Switch to clinician authentication and navigate to patient profile + await base_1.test.step('When clinician accesses patient workspace', async () => { + await account_helpers_1.test.account.switchUser('clinician', page); + await page.goto('/'); + await clinic_helpers_1.test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); + }); + // Step 13: Access the specific claimed patient that was modified by the producer test + await base_1.test.step('When user accesses the claimed patient modified by producer test', async () => { + await clinic_helpers_1.test.clinician.findAndAccessPatientByPartialName(CLAIMED_PATIENT_SEARCH, page); + }); + // Step 14: Navigate to profile + await base_1.test.step('When user navigates to Profile page', async () => { + await clinic_helpers_1.test.clinician.navigateTo('Profile', page); + }); + // Step 15: Confirm edit button is not present + await base_1.test.step('Then Edit button should not be present for claimed patients', async () => { + await profilePage.editButtonDisplays(false); + }); + // Step 16: Validate GET response and confirm appropriate permissions + await base_1.test.stepNoScreenshot('Then clinician sees claimed profile data with matching data and no save access', async () => { + await api.compareEndpointResponse('profile-metadata-get', producerPutCapture); + }); + }); +}); diff --git a/build/tests/claimed/API-User/claimed-email-edit.spec.js b/build/tests/claimed/API-User/claimed-email-edit.spec.js new file mode 100644 index 0000000..4076621 --- /dev/null +++ b/build/tests/claimed/API-User/claimed-email-edit.spec.js @@ -0,0 +1,95 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const base_1 = require("../../fixtures/base"); +const patient_helpers_1 = require("../../fixtures/patient-helpers"); +const account_helpers_1 = require("../../fixtures/account-helpers"); +const network_helpers_1 = require("../../fixtures/network-helpers"); +const test_tags_1 = require("../../fixtures/test-tags"); +const AccountSettingsPage_1 = require("../../../page-objects/account/AccountSettingsPage"); +base_1.test.describe('Clinician Account Settings Access', () => { + // API Test cases require this to capture network activity + let api; + (0, base_1.test)('should allow navigation to account settings and capture GET response', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.PATIENT, + test_tags_1.TEST_TAGS.CLAIMED, + test_tags_1.TEST_TAGS.API, + test_tags_1.TEST_TAGS.UI, + test_tags_1.TEST_TAGS.HIGH, + test_tags_1.TEST_TAGS.API_USER, + ]), + }, async ({ page }) => { + // Step 1: Log in to clinician account and setup network capture + await base_1.test.step('Given clinician has been logged in', async () => { + api = (0, network_helpers_1.createNetworkHelper)(page); + await api.startCapture(); + await page.goto('/data'); + await patient_helpers_1.test.patient.setup(page); + }); + // Step 2: Navigate to account settings + await base_1.test.step('When user navigates to account settings', async () => { + await account_helpers_1.test.account.navigateTo('AccountSettings', page); + }); + // Step 3: Validate profile GET response + await base_1.test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema ', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Setup for Account Settings page and previous email for reset + const accountSettingsPage = new AccountSettingsPage_1.AccountSettingsPage(page); + let originalEmail = ''; + // Step 4: Read and change email field to temporary value + await base_1.test.step('When user updates the email field', async () => { + originalEmail = await accountSettingsPage.emailInput.inputValue(); + await accountSettingsPage.emailInput.fill('qa+TempEdit@tidepool.org'); + }); + // Step 5: Tap the save button + await base_1.test.step('When user taps the save button', async () => { + await accountSettingsPage.saveButton.click(); + }); + // Step 6: Confirm save changes message displays + await base_1.test.step('Then the save changes message displays', async () => { + await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); + }); + // Step 7: Validate PUT request and email value + await base_1.test.stepNoScreenshot('Then PUT request is validated and email is set to new value', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putCapture = api + .getCaptures() + .find((req) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) + throw new Error('No PUT /profile request captured'); + if (!putCapture.requestBody || + !putCapture.requestBody.email || + putCapture.requestBody.email !== 'qa+TempEdit@tidepool.org') { + throw new Error('PUT request did not set email to qa+TempEdit@tidepool.org'); + } + }); + // Step 8: Change email field to temporary value + await base_1.test.step('When user sets the email field to the previous value', async () => { + await accountSettingsPage.emailInput.fill(originalEmail); + }); + // Step 9: Tap the save button + await base_1.test.step('When user taps the save button', async () => { + await accountSettingsPage.saveButton.click(); + }); + // Step 10: Confirm save changes message displays + await base_1.test.step('Then the save changes message displays', async () => { + await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); + }); + // Step 7: Validate PUT request and email value + await base_1.test.stepNoScreenshot('Then PUT request is validated and email is set to new value', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putCapture = api + .getCaptures() + .find((req) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) + throw new Error('No PUT /profile request captured'); + if (!putCapture.requestBody || + !putCapture.requestBody.email || + putCapture.requestBody.email !== originalEmail) { + throw new Error('PUT request did not set email to originalEmail'); + } + }); + await api.stopCapture(); + }); +}); diff --git a/build/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js b/build/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js new file mode 100644 index 0000000..d6f79c7 --- /dev/null +++ b/build/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js @@ -0,0 +1,91 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const clinic_helpers_1 = require("../../fixtures/clinic-helpers"); +const network_helpers_1 = require("../../fixtures/network-helpers"); +const test_tags_1 = require("../../fixtures/test-tags"); +const ProfilePage_1 = require("../../../page-objects/patient/ProfilePage"); +clinic_helpers_1.test.describe('Custodial patients are allowed access and modification of profile details', () => { + // Define the workspace and patient at top level + const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; + const CUSTODIAL_PATIENT_SEARCH = 'Custodial Patient'; + // API Test cases require this to capture network activity + let api; + (0, clinic_helpers_1.test)('should allow navigation to profile details and edit profile fields', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.CLINICIAN, // User Type (required) + test_tags_1.TEST_TAGS.API, // Test Type (required) + test_tags_1.TEST_TAGS.UI, // Test Type (required) + test_tags_1.TEST_TAGS.HIGH, // Priority (required) + test_tags_1.TEST_TAGS.API_PROFILE, // Feature (optional) + ]), + }, async ({ page }, testInfo) => { + // Step 1: Log in to clinician account and setup network capture + await clinic_helpers_1.test.step('Given clinician has been logged in', async () => { + api = (0, network_helpers_1.createNetworkHelper)(page); + await api.startCapture(); + await clinic_helpers_1.test.clinician.setup(page); + }); + // Step 2: Navigate to workspace + await clinic_helpers_1.test.step('When user navigates to desired workspace', async () => { + await clinic_helpers_1.test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); + }); + // Step 3: Access custodial patient + await clinic_helpers_1.test.step('When user accesses a custodial patient summary', async () => { + await clinic_helpers_1.test.clinician.findAndAccessPatientByPartialName(CUSTODIAL_PATIENT_SEARCH, page); + }); + // Step 4: Navigate to profile + await clinic_helpers_1.test.step('When user navigates to Profile page', async () => { + await clinic_helpers_1.test.clinician.navigateTo('Profile', page); + }); + // Step 5: Capture GET response + await clinic_helpers_1.test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Step 6: Open Edit Profile + await clinic_helpers_1.test.step('When user selects Edit button', async () => { + await clinic_helpers_1.test.clinician.navigateTo('ProfileEdit', page); + }); + // Create Profile page for following steps + const profilePage = new ProfilePage_1.ProfilePage(page); + // Step 7: Change profile fields (custodial access) + await clinic_helpers_1.test.step('When user updates profile fields', async () => { + // Generate completely unique values for this custodial test run + const randomSeed = Math.random(); + const randomId = Math.floor(randomSeed * 10000); + const updatedName = `Custodial Patient Updated ${Math.floor(randomId * 10000)}`; + const birthYear = 1980 + (randomId % 15); + const diagnosisYear = birthYear + 25; + const birthDate = `05/20/${birthYear}`; + const diagnosisDate = `08/15/${diagnosisYear}`; + // Generate random 15-digit MRN + const randomMRN = Array.from({ length: 15 }, () => Math.floor(Math.random() * 10).toString()).join(''); + // Generate random 15-letter string for clinical notes + const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); + // Generate unique email + const email = `webuiautomation+custodialEdit${randomId}@tidepool.org`; + // Get current diagnosis index and calculate next one (1-7, wrapping) + const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); + let nextDiagnosisIndex = currentDiagnosisIndex + 1; + if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { + nextDiagnosisIndex = 1; + } + // Update fields using ProfilePage methods + await profilePage.fillFullName(updatedName); + await profilePage.fillBirthDate(birthDate); + await profilePage.fillMRN(randomMRN); + await profilePage.fillDiagnosisDate(diagnosisDate); + await profilePage.selectDiagnosisType(nextDiagnosisIndex); + await profilePage.fillEmail(email); + await profilePage.fillClinicalNotes(randomString); + }); + // Step 8: Save profile edit + await clinic_helpers_1.test.step('When user saves profile changes', async () => { + await profilePage.saveProfile(); + }); + // Step 9: Check profile PUT response + await clinic_helpers_1.test.step('Then profile endpoint responds with PUT request consistent with schema [no-screenshot]', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + }); + await api.stopCapture(); + }); +}); diff --git a/build/tests/clinician/add-patient.spec.js b/build/tests/clinician/add-patient.spec.js new file mode 100644 index 0000000..595caf8 --- /dev/null +++ b/build/tests/clinician/add-patient.spec.js @@ -0,0 +1,38 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const base_1 = require("@fixtures/base"); +const ClinicianDashboardPage_1 = __importDefault(require("@pom/clinician/ClinicianDashboardPage")); +const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); +base_1.test.describe('Add new patient', () => { + // Use a unique patient name for each test run to avoid collisions + const patientName = `Test Patient Playwright ${Date.now()}`; + const patientBirthdate = '01/01/1990'; + base_1.test.beforeEach(async () => { + await base_1.test.step('Given user has been logged in and navigated to base URL', async () => { }); + }); + (0, base_1.test)('should successfully add a new patient', async ({ page }) => { + const workspacesPage = new WorkspacesPage_1.default(page); + const clinicWorkspacePage = new ClinicianDashboardPage_1.default(page); + await base_1.test.step('Given the user is on the workspaces page', async () => { + await workspacesPage.goto(); + await workspacesPage.header.waitFor({ state: 'visible' }); + }); + await base_1.test.step('When user selects the first workspace', async () => { + await workspacesPage.visitFirstClinic(); + await clinicWorkspacePage.waitForLoadState(); // Wait for clinic page elements + }); + await base_1.test.step('When user adds a new patient via dialog', async () => { + await clinicWorkspacePage.openAndFillAddPatientDialog(patientName, patientBirthdate); + await clinicWorkspacePage.submitAddPatientDialog(); + await clinicWorkspacePage.closeBringDataDialog(); + }); + await base_1.test.step('Then the new patient should appear in the patient list', async () => { + await clinicWorkspacePage.searchForPatient(patientName); + const patientCell = clinicWorkspacePage.getPatientCellByName(patientName); + await (0, base_1.expect)(patientCell).toBeVisible(); + }); + }); +}); diff --git a/build/tests/clinician/create-clinic-workspace.spec.js b/build/tests/clinician/create-clinic-workspace.spec.js new file mode 100644 index 0000000..c6fd99f --- /dev/null +++ b/build/tests/clinician/create-clinic-workspace.spec.js @@ -0,0 +1,86 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const base_1 = require("@fixtures/base"); +const ClinicCreationPage_1 = __importDefault(require("@pom/clinician/ClinicCreationPage")); +const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); +const node_crypto_1 = require("node:crypto"); +base_1.test.describe('Create clinic workspace', () => { + const uniqueSuffix = (0, node_crypto_1.randomUUID)().substring(0, 8); + const clinicName = `Test Clinic ${uniqueSuffix}`; + let workspacesPage; + let clinicCreationPage; + base_1.test.beforeEach(async ({ page }) => { + workspacesPage = new WorkspacesPage_1.default(page); + clinicCreationPage = new ClinicCreationPage_1.default(page); + }); + (0, base_1.test)('should successfully create a new clinic workspace', async ({ page }) => { + await base_1.test.step('Given user is on the workspaces page', async () => { + await workspacesPage.goto(); + await (0, base_1.expect)(workspacesPage.header).toBeVisible(); + await (0, base_1.expect)(workspacesPage.createClinicButton).toBeVisible(); + }); + await base_1.test.step("When user clicks on the 'Create a New Clinic' button", async () => { + await workspacesPage.createClinicButton.click(); + // Wait for the clinic details page to load + await (0, base_1.expect)(page).toHaveURL(/clinic-details\/new/); + await (0, base_1.expect)(clinicCreationPage.pageHeader).toBeVisible(); + }); + await base_1.test.step('When user fills in all the required clinic information', async () => { + // Fill the clinic form with test data + await clinicCreationPage.fillClinicForm({ + clinicName, + teamType: 'Provider Practice', + state: 'California', + address: '123 Test Street', + city: 'Test City', + zipCode: '12345', + }); + // Verify blood glucose units (mg/dL is pre-selected) + await (0, base_1.expect)(clinicCreationPage.mgdlRadio).toBeChecked(); + // Verify the admin acknowledgment checkbox is checked + await (0, base_1.expect)(clinicCreationPage.adminAcknowledgeCheckbox).toBeChecked(); + // Verify Create Workspace button is enabled + await (0, base_1.expect)(clinicCreationPage.createWorkspaceButton).toBeEnabled(); + }); + await base_1.test.step("When user clicks on the 'Create Workspace' button", async () => { + await clinicCreationPage.createWorkspaceButton.click(); + // Wait for redirect to workspaces page + await (0, base_1.expect)(page).toHaveURL('/workspaces'); + }); + await base_1.test.step('Then user should see the new clinic in the list and a success message', async () => { + // Verify success message is shown + const successMessage = page.getByText(`"${clinicName}" clinic created`); + await (0, base_1.expect)(successMessage).toBeVisible(); + // Verify the new clinic appears in the list + const clinicHeaderLocator = page.getByRole('heading', { name: clinicName }); + await (0, base_1.expect)(clinicHeaderLocator).toBeVisible(); + // Verify the clinic has the necessary action buttons + const clinicContainer = page + .locator('.workspace-item-clinic') + .filter({ has: clinicHeaderLocator }); + await (0, base_1.expect)(clinicContainer.getByRole('button', { name: 'Leave Clinic' })).toBeVisible(); + await (0, base_1.expect)(clinicContainer.getByRole('button', { name: 'Go To Workspace' })).toBeVisible(); + }); + }); + (0, base_1.test)('should create a new clinic with the simplified createClinic method', async ({ page }) => { + // Navigate to the workspaces page + await page.goto('/workspaces'); + await (0, base_1.expect)(workspacesPage.header).toBeVisible(); + // Click the "Create a New Clinic" button + await workspacesPage.createClinicButton.click(); + await (0, base_1.expect)(page).toHaveURL(/clinic-details\/new/); + // Use the simplified method to create a clinic in one step + await clinicCreationPage.createClinic(clinicName); + // Verify we're back on the workspaces page + await (0, base_1.expect)(page).toHaveURL('/workspaces'); + // Verify the clinic was created + const successMessage = page.getByText(`"${clinicName}" clinic created`); + await (0, base_1.expect)(successMessage).toBeVisible(); + // Verify the clinic appears in the list + const clinicHeaderLocator = page.getByRole('heading', { name: clinicName }); + await (0, base_1.expect)(clinicHeaderLocator).toBeVisible(); + }); +}); diff --git a/build/tests/clinician/edit-clinic-address.spec.js b/build/tests/clinician/edit-clinic-address.spec.js new file mode 100644 index 0000000..0f038c1 --- /dev/null +++ b/build/tests/clinician/edit-clinic-address.spec.js @@ -0,0 +1,47 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const base_1 = require("@fixtures/base"); +const WorkspaceSettingsPage_1 = __importDefault(require("@pom/clinician/WorkspaceSettingsPage")); +const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); +base_1.test.describe('Edit clinic address', () => { + const newAddress = `123 Test Street ${Date.now()}`; // Unique address for test run + let clinicAdminPage; + let workspacesPage; + base_1.test.beforeEach(async ({ page }) => { + clinicAdminPage = new WorkspaceSettingsPage_1.default(page); + workspacesPage = new WorkspacesPage_1.default(page); + await base_1.test.step('Given user has navigated to the Clinic Admin page', async () => { + await workspacesPage.goto(); + await workspacesPage.visitFirstClinic(); + await page.goto('/clinic-admin'); + await clinicAdminPage.waitForLoadState(); // Wait for clinic admin page elements + await clinicAdminPage.clinicDetailsHeader.waitFor({ state: 'visible' }); + }); + }); + (0, base_1.test)('should successfully edit the clinic address', async ({ page }) => { + await base_1.test.step('When user clicks the "Edit" button for workspace details', async () => { + await clinicAdminPage.editDetailsButton.click(); + await clinicAdminPage.editClinicModal.waitFor({ state: 'visible' }); + }); + await base_1.test.step('Then user sees the modal for Editing workspace details', async () => { + await (0, base_1.expect)(clinicAdminPage.editClinicModalTitle).toBeVisible(); + await (0, base_1.expect)(clinicAdminPage.addressInput).toBeVisible(); + }); + await base_1.test.step('When user changes the address', async () => { + await clinicAdminPage.addressInput.fill(newAddress); + }); + await base_1.test.step('When user clicks on "Save changes"', async () => { + await clinicAdminPage.saveChangesButton.click(); + await clinicAdminPage.editClinicModal.waitFor({ state: 'hidden' }); // Wait for modal to close + }); + await base_1.test.step('Then user sees the updated address on the page', async () => { + // Wait for the details section to potentially update + await page.waitForTimeout(1000); // Small wait for potential DOM update + const detailsText = clinicAdminPage.clinicDetailsSection; + await (0, base_1.expect)(detailsText).toContainText(newAddress); + }); + }); +}); diff --git a/build/tests/clinician/filter-patient.spec.js b/build/tests/clinician/filter-patient.spec.js new file mode 100644 index 0000000..5032ef2 --- /dev/null +++ b/build/tests/clinician/filter-patient.spec.js @@ -0,0 +1,70 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const base_1 = require("@fixtures/base"); +const ClinicianDashboardPage_1 = __importDefault(require("@pom/clinician/ClinicianDashboardPage")); +const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); +base_1.test.describe('Filter patients in clinic', () => { + // Use unique patient names for each test run + const timestamp = Date.now(); + const patientName1 = `Filter Patient A ${timestamp}`; + const patientName2 = `Filter Patient B ${timestamp}`; + const patientBirthdate = '01/01/1995'; // Shared birthdate for simplicity + let workspacesPage; + let clinicWorkspacePage; + base_1.test.beforeEach(async ({ page }) => { + workspacesPage = new WorkspacesPage_1.default(page); + clinicWorkspacePage = new ClinicianDashboardPage_1.default(page); + await base_1.test.step('Given user has been logged in and navigated to base URL', async () => { + await workspacesPage.goto(); + await page.waitForURL(workspacesPage.url); + await workspacesPage.header.waitFor({ state: 'visible' }); + }); + await base_1.test.step('Given the user is on the first clinic workspace', async () => { + await workspacesPage.visitFirstClinic(); + await clinicWorkspacePage.waitForLoadState(); // Wait for clinic page elements + }); + await base_1.test.step('Given two patients exist', async () => { + // Add first patient + await clinicWorkspacePage.openAndFillAddPatientDialog(patientName1, patientBirthdate); + await clinicWorkspacePage.submitAddPatientDialog(); + await clinicWorkspacePage.closeBringDataDialog(); + // Ensure the first patient is added before adding the second + await (0, base_1.expect)(clinicWorkspacePage.getPatientCellByName(patientName1)).toBeVisible({ + timeout: 10000, + }); + // Add second patient + await clinicWorkspacePage.openAndFillAddPatientDialog(patientName2, patientBirthdate); + await clinicWorkspacePage.submitAddPatientDialog(); + await clinicWorkspacePage.closeBringDataDialog(); + // Ensure the second patient is also added + await (0, base_1.expect)(clinicWorkspacePage.getPatientCellByName(patientName2)).toBeVisible({ + timeout: 10000, + }); + }); + }); + (0, base_1.test)('should successfully filter patients by name', async () => { + await base_1.test.step("When user filters by the first patient's name", async () => { + await clinicWorkspacePage.searchForPatient(patientName1); + }); + await base_1.test.step('Then only the first patient should be visible', async () => { + const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); + const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); + await (0, base_1.expect)(patientCell1).toBeVisible(); + await (0, base_1.expect)(patientCell2).not.toBeVisible(); + }); + await base_1.test.step('When user clears the filter', async () => { + // Assuming a method like clearPatientSearch exists or searchForPatient('') clears + await clinicWorkspacePage.searchForPatient(''); // Clear search by searching for empty string + // Or potentially: await clinicWorkspacePage.clearPatientSearch(); + }); + await base_1.test.step('Then both patients should be visible again', async () => { + const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); + const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); + await (0, base_1.expect)(patientCell1).toBeVisible(); + await (0, base_1.expect)(patientCell2).toBeVisible(); + }); + }); +}); diff --git a/build/tests/fixtures/account-helpers.js b/build/tests/fixtures/account-helpers.js new file mode 100644 index 0000000..4532eef --- /dev/null +++ b/build/tests/fixtures/account-helpers.js @@ -0,0 +1,123 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.test = void 0; +const base_1 = require("@fixtures/base"); +const AccountNavigation_1 = __importDefault(require("@pom/account/AccountNavigation")); +/** + * Switch user authentication context by loading different storageState + * @param userType - The user type corresponding to the storageState file (e.g., 'shared', 'clinician', 'claimed') + * @param page - The Playwright page instance + */ +async function switchUser(userType, page) { + try { + // Import fs dynamically + const fs = await Promise.resolve().then(() => __importStar(require('node:fs'))); + // Load the specified user's storage state + const storageStatePath = `tests/.auth/${userType}.json`; + const storageState = JSON.parse(fs.readFileSync(storageStatePath, 'utf-8')); + // Clear existing cookies first + await page.context().clearCookies(); + // Set cookies from the new user's storage state + if (storageState.cookies) { + await page.context().addCookies(storageState.cookies); + } + // Set localStorage from the new user's storage state + if (storageState.origins) { + for (const origin of storageState.origins) { + await page.addInitScript(originData => { + if (originData.localStorage) { + for (const item of originData.localStorage) { + localStorage.setItem(item.name, item.value); + } + } + }, origin); + } + } + console.log(`āœ… Successfully switched to ${userType} user authentication`); + } + catch (error) { + throw new Error(`Failed to switch to ${userType} user: ${error}`); + } +} +/** + * Core navigation function that handles account navigation consistently + */ +async function navigateTo(targetPage, page) { + const nav = new AccountNavigation_1.default(page); + const pageConfig = nav.pages[targetPage]; + try { + // Single page check at start + if (page.isClosed()) + return; + // Quick DOM ready check only + await page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => { }); + // Open navigation menu if needed (only for non-AccountNav targets) + if (targetPage !== 'AccountNav') { + const menuVisible = await nav.pages.AccountNav.verifyElement + .isVisible({ timeout: 1000 }) + .catch(() => false); + if (!menuVisible) { + await nav.pages.AccountNav.link.click(); + await nav.pages.AccountNav.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + } + } + // Handle logout specially + if (targetPage === 'Logout') { + await pageConfig.link.click(); + await page + .waitForURL(/.*login.*/, { waitUntil: 'domcontentloaded', timeout: 5000 }) + .catch(() => { }); + } + else { + // Standard navigation - click and verify + await pageConfig.link.click(); + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + } + } + catch (error) { + if (!page.isClosed()) + throw error; + } +} +const test = base_1.test; +exports.test = test; +test.account = { + navigateTo, + switchUser, +}; diff --git a/build/tests/fixtures/base.js b/build/tests/fixtures/base.js new file mode 100644 index 0000000..b21e7bc --- /dev/null +++ b/build/tests/fixtures/base.js @@ -0,0 +1,257 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.expect = exports.test = void 0; +exports.step = step; +const test_1 = require("@playwright/test"); +const fs = __importStar(require("node:fs")); +const path = __importStar(require("node:path")); +// Define the test type with custom fixtures +exports.test = test_1.test.extend({ + page: async ({ page }, use, testInfo) => { + const modifiedTestInfo = testInfo; + modifiedTestInfo.snapshotSuffix = ''; + modifiedTestInfo.snapshotPath = name => `${testInfo.file}-snapshots/${name}`; + // Make testInfo globally available for network helpers + globalThis.testInfo = testInfo; + try { + await use(page); + } + finally { + // Clean up after test + delete globalThis.testInfo; + } + }, + timeLogger: [ + async ({ page }, use, testInfo) => { + testInfo.annotations.push({ + type: 'Start', + description: new Date().toISOString(), + }); + await use(page); + testInfo.annotations.push({ + type: 'End', + description: new Date().toISOString(), + }); + }, + { auto: true }, + ], + timeStepLogger: [ + async ({ page }, use, testInfo) => { + const startTime = Date.now(); + console.time(`[test] ${testInfo.title}`); + await use(page); + console.timeEnd(`[test] ${testInfo.title}`); + const endTime = Date.now(); + const duration = endTime - startTime; + testInfo.annotations.push({ + type: 'Duration', + description: `${duration}ms`, + }); + testInfo.annotations.push({ + type: 'End', + description: new Date().toISOString(), + }); + }, + { auto: true }, + ], + stepTimer: [ + async ({ page }, use, testInfo) => { + const originalStep = exports.test.step; + const stepTimings = new Map(); + // Create a new step function with the same interface as the original + const newStep = function newStepWrapper(name, fn) { + return originalStep.call(this, name, async (stepInfo) => { + const startTime = Date.now(); + console.time(`[step] ${name}`); + const result = await fn(stepInfo); + console.timeEnd(`[step] ${name}`); + const endTime = Date.now(); + const duration = endTime - startTime; + stepTimings.set(name, duration); + testInfo.annotations.push({ + type: `Step Duration: ${name}`, + description: `${duration}ms`, + }); + return result; + }); + }; + // Add the skip method to match the original test.step interface + newStep.skip = function skipStep(name, fn) { + return originalStep.skip.call(this, name, fn); + }; + // Replace the original step with our enhanced version + exports.test.step = newStep; + await use(page); + // Restore original test.step + exports.test.step = originalStep; + }, + { auto: true }, + ], + stepScreenshoter: [ + async ({ page }, use, testInfo) => { + const originalStep = exports.test.step; + let stepCounter = 0; + // Create a safe directory name based on test info + const testDirName = path.basename(testInfo.file, '.spec.ts').replace(/[^a-z0-9]/gi, '-'); + const screenshotDir = path.join('test-results', testDirName); + // Store current step name for network helpers + let currentStepName = ''; + // Make step counter accessible globally for network helper + globalThis.__stepCounter = { + get: () => stepCounter, + increment: () => ++stepCounter, + getDirectory: () => screenshotDir, + getCurrentStepName: () => currentStepName, + setCurrentStepName: (name) => { + currentStepName = name; + }, + }; + // Clean up existing screenshots from previous runs + try { + await fs.promises.access(screenshotDir); + await fs.promises.rm(screenshotDir, { recursive: true, force: true }); + } + catch { + // Directory doesn't exist, no need to clean up + } + // Create a new step function that takes screenshots after completion and attaches them to the report + const newStep = function newStepScreenshot(name, fn) { + return originalStep.call(this, name, async (stepInfo) => { + // Set current step name for network helpers (clean name without [no-screenshot]) + const stepCounterObj = globalThis.__stepCounter; + if (stepCounterObj) { + const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); + stepCounterObj.setCurrentStepName(cleanName); + } + const result = await fn(stepInfo); + // Skip screenshot if step name contains [no-screenshot] + if (name.includes('[no-screenshot]')) { + return result; + } + // Take screenshot after step completion + stepCounter += 1; + try { + if (!page.isClosed()) { + // Use clean name for filename (without [no-screenshot]) + const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); + const screenshotName = `step-${stepCounter.toString().padStart(2, '0')}-${cleanName.toLowerCase().replace(/[^a-z0-9]/g, '-')}.png`; + // Take screenshot directly to buffer (no local file) + const screenshot = await page.screenshot({ + fullPage: true, + }); + // Attach to Playwright report AND force test-results folder creation + if (testInfo && typeof testInfo.attach === 'function') { + await testInfo.attach(screenshotName, { + body: screenshot, + contentType: 'image/png', + }); + // Also save to test-results for organized viewing (single source) + const testResultsDir = path.join(testInfo.outputDir, 'attachments'); + await fs.promises.mkdir(testResultsDir, { recursive: true }); + const screenshotPath = path.join(testResultsDir, screenshotName); + await fs.promises.writeFile(screenshotPath, screenshot); + } + } + } + catch (error) { } + return result; + }); + }; + // Add the skip method to match the original test.step interface + newStep.skip = function skipStepScreenshot(name, fn) { + return originalStep.skip.call(this, name, fn); + }; + // Add a custom stepNoScreenshot function for API validation steps + const stepNoScreenshot = function stepNoScreenshot(name, fn) { + return originalStep.call(this, name, async (stepInfo) => { + // Set current step name for network helpers (clean name) + const stepCounterObj = globalThis.__stepCounter; + if (stepCounterObj) { + stepCounterObj.setCurrentStepName(name); + } + const result = await fn(stepInfo); + // No screenshot taken for this step type + // console.log(`ā­ļø API step completed without screenshot: ${name}`); + return result; + }); + }; + // Replace the original step with our enhanced version + exports.test.step = newStep; + // Add the no-screenshot step function to the test object + exports.test.stepNoScreenshot = stepNoScreenshot; + await use(page); + // Restore original test.step + exports.test.step = originalStep; + }, + { auto: true }, + ], + exceptionLogger: [ + async ({ page }, use, testInfo) => { + const errors = []; + page.on('pageerror', (error) => { + errors.push(error); + }); + await use(page); + if (errors.length > 0) { + await testInfo.attach('frontend-exceptions', { + body: errors.map(error => `${error.message}\n${error.stack}`).join('\n---------\n'), + }); + throw new Error('Some frontend exceptions occurred'); + } + }, + { auto: true }, + ], +}); +var test_2 = require("@playwright/test"); +Object.defineProperty(exports, "expect", { enumerable: true, get: function () { return test_2.expect; } }); +/** + * Decorator function for wrapping POM methods in a test.step. + * + * Use it without a step name `@step()`. + * + * Or with a step name `@step("Search something")`. + * + * @param stepName - The name of the test step. + * @returns A decorator function that can be used to decorate test methods. + */ +function step(stepName) { + return function decorator(target, context) { + return function replacementMethod(...args) { + const name = `${stepName || context.name} (${this.name})`; + return exports.test.step(name, async () => await target.call(this, ...args)); + }; + }; +} diff --git a/build/tests/fixtures/clinic-helpers.js b/build/tests/fixtures/clinic-helpers.js new file mode 100644 index 0000000..17b2e56 --- /dev/null +++ b/build/tests/fixtures/clinic-helpers.js @@ -0,0 +1,280 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.test = void 0; +const base_1 = require("@fixtures/base"); +const ClinicianNavigation_1 = __importDefault(require("../../page-objects/clinician/ClinicianNavigation")); +const ClinicianDashboardPage_1 = __importDefault(require("../../page-objects/clinician/ClinicianDashboardPage")); +const AccountNavigation_1 = __importDefault(require("../../page-objects/account/AccountNavigation")); +/** + * Initialize clinician navigation helpers after login + */ +async function setupClinicianSession(page) { + // Wait for clinician navigation to be available + const nav = new ClinicianNavigation_1.default(page); + // Navigate to login and setup clinic session if needed + if (!page.url().includes('clinic-workspace')) { + await page.goto('/login'); + // Add any necessary login steps here + } + console.log('šŸ„ Clinic session setup complete'); + return nav; +} +/** + * Navigate to workspace selection page + */ +async function navigateToWorkspaceSelection(page) { + const accountNav = new AccountNavigation_1.default(page); + // Open the account navigation menu first + await accountNav.pages.AccountNav.link.click(); + // Then click the ManageWorkspaces option + await accountNav.pages.ManageWorkspaces.link.click(); + // Verify we're on the workspace selection page using the known verification element + await accountNav.pages.ManageWorkspaces.verifyElement.waitFor({ + state: 'visible', + timeout: 5000, + }); + // console.log('āœ… Navigated to workspace selection page'); +} +/** + * Navigate to a specific workspace using hardcoded workspace key + */ +async function navigateToWorkspace(workspaceKey, page) { + const clinicianNav = new ClinicianNavigation_1.default(page); + // First navigate to workspace selection if not already there + if (!page.url().includes('workspaces')) { + await navigateToWorkspaceSelection(page); + } + // Click on the specific workspace using the page object locator + await clinicianNav.workspaces[workspaceKey].link.click(); + // Verify we're in the correct workspace using URL verification + await page.waitForURL(new RegExp(clinicianNav.workspaces[workspaceKey].verifyURL), { + timeout: 5000, + }); + // console.log(`āœ… Successfully navigated to workspace: ${clinicianNav.workspaces[workspaceKey].name}`); +} +/** + * Core navigation function that handles workspace prerequisites and page navigation + */ +async function navigateTo(targetPage, page, workspaceKey) { + const clinicianNav = new ClinicianNavigation_1.default(page); + const pageConfig = clinicianNav.pages[targetPage]; + // Ensure we're in a workspace context (but don't auto-switch if already in one) + const isInWorkspaceContext = page.url().includes('clinic-workspace') || + page.url().includes('/patients/') || + page.url().includes('/profile'); + if (!isInWorkspaceContext) { + const defaultWorkspace = workspaceKey || 'AdminClinicBase'; + await navigateToWorkspace(defaultWorkspace, page); + } + else if (workspaceKey) { + // Only switch if specifically requested and we can verify we're in wrong workspace + const currentUrl = page.url(); + const targetWorkspacePattern = clinicianNav.workspaces[workspaceKey].verifyURL; + if (!currentUrl.includes(targetWorkspacePattern)) { + await navigateToWorkspace(workspaceKey, page); + } + } + // Handle page-specific prerequisites + if (targetPage === 'AddPatient') { + // AddPatient might need to be on PatientList first + if (!page.url().includes('patients')) { + await clinicianNav.pages.PatientList.link.click(); + await clinicianNav.pages.PatientList.verifyElement.waitFor({ + state: 'visible', + timeout: 5000, + }); + } + } + // Perform the actual navigation + try { + await pageConfig.link.click(); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`Failed to click ${targetPage}: ${errorMessage}`); + throw error; + } + // Verify navigation succeeded + try { + if (pageConfig.verifyURL) { + await page.waitForURL(`**/*${pageConfig.verifyURL}*`, { timeout: 5000 }); + } + if (pageConfig.verifyElement) { + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + } + // console.log(`āœ… Navigated to page: ${targetPage}`); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + // console.log(`Navigation verification failed for ${targetPage}: ${errorMessage}`); + } +} +/** + * Execute test logic across multiple workspaces + */ +async function executeAcrossWorkspaces(workspaceConfigs, action, page) { + for (const config of workspaceConfigs) { + console.log(`šŸ”„ Executing across workspace: ${config.workspaceKey}`); + // Navigate to the workspace + await navigateToWorkspace(config.workspaceKey, page); + // Execute the action + await action(config); + // Navigate back to workspace selection for next iteration + if (workspaceConfigs.indexOf(config) < workspaceConfigs.length - 1) { + await navigateToWorkspaceSelection(page); + } + } +} +/** + * Find and access any patient whose name contains the search term (optimized version) + * @param searchTerm - Partial name to search for (e.g., "Custodial") + * @param page - The Playwright page object + * @returns The full name of the patient that was accessed + */ +async function findAndAccessPatientByPartialName(searchTerm, page) { + const dashboard = new ClinicianDashboardPage_1.default(page); + // If empty search term, find any available patient + if (!searchTerm || searchTerm.trim() === '') { + return findAndAccessAnyPatient(page); + } + // Strategy 1: Fill search field THEN click Show All (proven fastest method) + try { + await dashboard.searchInput.fill(searchTerm); + await page.waitForTimeout(500); + const showAllButton = page + .getByRole('button', { name: 'Show All' }) + .or(page.getByRole('button', { name: 'Show all' })) + .or(page.getByText('Show All')) + .or(page.getByText('Show all')); + if (await showAllButton.isVisible({ timeout: 1000 })) { + await showAllButton.click(); + await page.waitForTimeout(1000); + const searchResultCells = await dashboard.patientListTable.getByRole('cell').all(); + if (searchResultCells.length > 0) { + for (const cell of searchResultCells) { + const cellText = await cell.textContent(); + if (cellText && cellText.toLowerCase().includes(searchTerm.toLowerCase())) { + await cell.click(); + await page.waitForTimeout(600); + return cellText.trim(); + } + } + } + } + else { + await dashboard.searchInput.press('Enter'); + await page.waitForTimeout(1000); + const searchResultCells = await dashboard.patientListTable.getByRole('cell').all(); + if (searchResultCells.length > 0) { + for (const cell of searchResultCells) { + const cellText = await cell.textContent(); + if (cellText && cellText.toLowerCase().includes(searchTerm.toLowerCase())) { + await cell.click(); + await page.waitForTimeout(600); + return cellText.trim(); + } + } + } + } + } + catch (error) { + // Silent fallback to any patient + } + // Strategy 2: Fallback to any available patient if specific search fails + try { + return await findAndAccessAnyPatient(page); + } + catch (fallbackError) { + throw new Error(`No patient found containing "${searchTerm}" and no fallback patients available`); + } +} +/** + * Find and access any available patient (fastest option) + * @param page - The Playwright page object + * @returns The full name of the first patient that was accessed + */ +async function findAndAccessAnyPatient(page) { + const dashboard = new ClinicianDashboardPage_1.default(page); + try { + // Clear search to show all patients + await dashboard.searchInput.click(); + await dashboard.searchInput.fill(' '); + await page.waitForTimeout(500); + await dashboard.searchInput.fill(''); + await page.waitForTimeout(1500); + let allCells = await dashboard.patientListTable.getByRole('cell').all(); + // If no cells, try pressing Enter on empty search + if (allCells.length === 0) { + await dashboard.searchInput.press('Enter'); + await page.waitForTimeout(1500); + allCells = await dashboard.patientListTable.getByRole('cell').all(); + } + // Find the first cell that looks like a patient name + for (const cell of allCells) { + const cellText = await cell.textContent(); + if (cellText && cellText.trim().length > 3 && cellText.includes(' ')) { + await cell.click(); + await page.waitForTimeout(800); + return cellText.trim(); + } + } + throw new Error('No patient names found in table'); + } + catch (error) { + throw new Error(`Failed to find any patient: ${error}`); + } +} +/** + * Access a specific patient by name and navigate to their summary page + * @param patientName - The name of the patient to access + * @param page - The Playwright page object + */ +async function accessPatient(patientName, page) { + const dashboard = new ClinicianDashboardPage_1.default(page); + console.log(`šŸ” Searching for patient: ${patientName}`); + // Try optimized search first + await dashboard.searchForPatient(patientName); + await page.waitForTimeout(1000); // Reduced wait time + // Check if search worked + const patientCell = dashboard.getPatientCellByName(patientName); + const isVisible = await patientCell.isVisible({ timeout: 2000 }); + if (isVisible) { + console.log(`šŸ‘¤ Found patient via search: ${patientName}`); + await patientCell.click(); + await page.waitForTimeout(1000); + console.log(`āœ… Successfully accessed patient summary for: ${patientName}`); + return; + } + // If search failed, fall back to show all + find + console.log(`šŸ”„ Search failed, trying show all approach...`); + const showAllButton = page.getByRole('button', { name: 'Show All' }); + if (await showAllButton.isVisible({ timeout: 1000 })) { + await showAllButton.click(); + await page.waitForTimeout(1500); + } + // Try again after showing all + const isVisibleAfterShowAll = await patientCell.isVisible({ timeout: 2000 }); + if (isVisibleAfterShowAll) { + await patientCell.click(); + await page.waitForTimeout(1000); + // console.log(`āœ… Successfully accessed patient summary for: ${patientName}`); + return; + } + // If still not found, throw error + throw new Error(`Patient "${patientName}" not found in current workspace`); +} +const test = base_1.test; +exports.test = test; +test.clinician = { + navigateTo, + navigateToWorkspace, + navigateToWorkspaceSelection, + executeAcrossWorkspaces, + accessPatient, + findAndAccessPatientByPartialName, + findAndAccessAnyPatient, + setup: setupClinicianSession, +}; diff --git a/build/tests/fixtures/network-helpers.js b/build/tests/fixtures/network-helpers.js new file mode 100644 index 0000000..d5a0ebb --- /dev/null +++ b/build/tests/fixtures/network-helpers.js @@ -0,0 +1,480 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NetworkHelper = void 0; +exports.createNetworkHelper = createNetworkHelper; +const fs = __importStar(require("node:fs")); +const path = __importStar(require("node:path")); +const endpoint_registry_1 = require("../../endpoint-schema/endpoint-registry"); +const ENDPOINTS = { + profile: /\/data\/[^\/]+$/, // GET requests for patient data + profileUpdate: /\/data\/[^\/]+$/, // PUT requests for patient data updates + profileMetrics: /\/metrics\/thisuser\//, + profileMessage: /\/message\/notes\//, +}; +/** + * Simple network helper for API validation + */ +class NetworkHelper { + constructor(page) { + this.captures = []; + this.isCapturing = false; + this.page = page; + } + async startCapture() { + if (this.isCapturing) + return; + // Only intercept API requests we care about to avoid interfering with other requests + const apiPatterns = [ + '**/data/**', + '**/metrics/**', + '**/message/**', + '**/auth/**', + '**/v1/**', + '**/metadata/**', + '**/user/**', + '**/users/**', + '**/profile/**', + ]; + for (const pattern of apiPatterns) { + await this.page.route(pattern, async (route) => { + const request = route.request(); + try { + const response = await route.fetch(); + let requestBody; + let responseBody; + try { + requestBody = request.postDataJSON(); + } + catch { + requestBody = request.postData(); + } + try { + responseBody = await response.json(); + } + catch { + responseBody = await response.text(); + } + this.captures.push({ + url: request.url(), + method: request.method(), + requestBody, + responseBody, + statusCode: response.status(), + timestamp: Date.now(), + }); + await route.fulfill({ response }); + } + catch (error) { + // If there's an error, continue the request without handling + try { + await route.continue(); + } + catch { + // Route might already be handled, ignore + } + } + }); + } + this.isCapturing = true; + } + async stopCapture() { + if (!this.isCapturing) + return; + // Remove all API route handlers + const apiPatterns = ['**/data/**', '**/metrics/**', '**/message/**', '**/auth/**', '**/v1/**']; + for (const pattern of apiPatterns) { + await this.page.unroute(pattern); + } + this.isCapturing = false; + } + async waitForEndpoint(endpointName, method, timeout = 30000) { + const pattern = ENDPOINTS[endpointName]; + if (!pattern) { + throw new Error(`Unknown endpoint: ${endpointName}`); + } + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + const matches = this.captures.filter(capture => pattern.test(capture.url) && capture.method.toLowerCase() === method.toLowerCase()); + if (matches.length > 0) { + return matches[matches.length - 1]; // Return latest match + } + await this.page.waitForTimeout(100); + } + throw new Error(`${method} request to ${endpointName} not found within ${timeout}ms`); + } + getCaptures() { + return [...this.captures]; + } + /** + * Simple helper to validate endpoint requests by URL pattern and method + */ + validateEndpointRequests(urlPattern, method) { + return this.captures.filter(c => c.url.includes(urlPattern) && c.method === method); + } + /** + * Save all captures to a JSON file + */ + async saveCapturesTo(filename, testInfo) { + const logDir = path.join(process.cwd(), 'log'); + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + // Create capture data + const captureData = { + timestamp: new Date().toISOString(), + totalCaptures: this.captures.length, + captures: this.captures, + }; + // Use Playwright's automatic attachment instead of manual file writing + if (testInfo && typeof testInfo.attach === 'function') { + await testInfo.attach(filename, { + body: JSON.stringify(captureData, null, 2), + contentType: 'application/json', + }); + console.log(`šŸ“„ Network captures attached to Playwright report: ${filename}`); + } + else { + console.log(`šŸ“„ Network captures ready (${this.captures.length} captures)`); + } + } + /** + * Print a summary of all captures to console + */ + printCaptureSummary() { + console.log(`\nšŸ“Š Network Capture Summary (${this.captures.length} total requests):`); + console.log('='.repeat(60)); + this.captures.forEach((capture, index) => { + const timestamp = new Date(capture.timestamp).toLocaleTimeString(); + console.log(`${index + 1}. ${capture.method} ${capture.statusCode} - ${capture.url}`); + console.log(` Time: ${timestamp}`); + if (capture.requestBody) { + console.log(` Request: ${JSON.stringify(capture.requestBody).substring(0, 100)}...`); + } + console.log(''); + }); + } + /** + * Get captures filtered by status code + */ + getCapturesByStatus(statusCode) { + return this.captures.filter(c => c.statusCode === statusCode); + } + /** + * Get the most recent capture matching method and URL pattern + */ + getLatestCaptureMatching(method, urlPattern) { + const matches = this.captures + .filter(c => c.method === method && urlPattern.test(c.url)) + .sort((a, b) => b.timestamp - a.timestamp); + return matches.length > 0 ? matches[0] : null; + } + /** + * Get all captures for a specific endpoint + */ + getCapturesForEndpoint(endpointName) { + const pattern = ENDPOINTS[endpointName]; + if (!pattern) { + throw new Error(`Unknown endpoint: ${endpointName}`); + } + return this.captures.filter(c => pattern.test(c.url)); + } + /** + * Get all captures + */ + getAllCaptures() { + return [...this.captures]; + } + /** + * Save API response as JSON attachment and to organized test-results folder + */ + async saveApiResponse(response, endpoint, method, fileName, testInfo) { + const responseData = { + _request: { + method, + endpoint, + }, + ...response, + }; + const jsonContent = JSON.stringify(responseData, null, 2); + // Attach to Playwright report AND save to organized test-results folder + if (testInfo && typeof testInfo.attach === 'function') { + await testInfo.attach(fileName, { + body: jsonContent, + contentType: 'application/json', + }); + // Also save to test-results for organized viewing (like screenshots) + const testResultsDir = path.join(testInfo.outputDir, 'attachments'); + await fs.promises.mkdir(testResultsDir, { recursive: true }); + const jsonPath = path.join(testResultsDir, fileName); + await fs.promises.writeFile(jsonPath, jsonContent, 'utf8'); + } + } + /** + * Validate and save API response for any endpoint defined in the endpoint registry + * @param endpointName - The endpoint name from the registry (e.g., 'profile-metadata-get') + * @returns The captured network request or null if not found + */ + async validateEndpointResponse(endpointName) { + const schema = (0, endpoint_registry_1.getEndpointSchema)(endpointName); + const request = this.getLatestCaptureMatching(schema.method, schema.url); + if (request?.responseBody) { + // Access the shared step counter from the stepScreenshoter fixture + const stepCounterObj = globalThis.__stepCounter; + if (stepCounterObj) { + const stepNumber = stepCounterObj.increment(); + const currentStepName = stepCounterObj.getCurrentStepName(); + // Create consistent filename with step number and step name (like screenshots) + const stepNameForFile = currentStepName + ? currentStepName.toLowerCase().replace(/[^a-z0-9]/g, '-') + : endpointName.replace(/[^a-z0-9]/gi, '-'); + const fileName = `step-${stepNumber.toString().padStart(2, '0')}-${stepNameForFile}-response.json`; + await this.saveApiResponse(request.responseBody, request.url, schema.method, fileName, globalThis.testInfo); + } + } + return request; + } + /** + * Save network capture for producer/consumer test patterns + * @param endpointName - The endpoint to save + * @param testName - Name of the test (used for file naming) + * @returns The saved network capture or null + */ + async saveForDependentTests(endpointName, testName) { + const schema = (0, endpoint_registry_1.getEndpointSchema)(endpointName); + const capture = this.getLatestCaptureMatching(schema.method, schema.url); + if (capture) { + // Create step-based filename for better organization + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const stepName = testName.toLowerCase().replace(/[^a-z0-9]/g, '-'); + const fileName = `step-api-${stepName}-${endpointName.replace(/[^a-z0-9]/gi, '-')}-${timestamp}.json`; + console.log(`āœ… Saved ${endpointName} response for dependent tests`); + // Use Playwright's automatic attachment instead of file system + const { testInfo } = globalThis; + if (testInfo && typeof testInfo.attach === 'function') { + await testInfo.attach(fileName, { + body: JSON.stringify(capture, null, 2), + contentType: 'application/json', + }); + } + return capture; + } + return null; + } + /** + * Load producer test data for consumer tests + * @param testName - Name of the producer test (used for file naming) + * @returns The loaded network capture or null + */ + loadFromProducerTest(testName) { + const filePath = path.join(process.cwd(), 'log', 'test-data-pipeline', `${testName}-response.json`); + if (fs.existsSync(filePath)) { + const fileContent = fs.readFileSync(filePath, 'utf-8'); + const capture = JSON.parse(fileContent); + console.log(`āœ… Loaded ${testName} response from producer test`); + return capture; + } + throw new Error(`Producer test data not found at: ${filePath}. Please run ${testName} test first.`); + } + /** + * Validate data consistency between producer and consumer responses + * @param producerCapture - The producer test network capture + * @param consumerCapture - The consumer test network capture + * @param fieldsToValidate - Array of field paths to validate (e.g., ['fullName', 'patient.birthday']) + * @param requiredFields - Array of fields that must exist and match (defaults to common required fields) + */ + validateDataConsistency(producerCapture, consumerCapture, fieldsToValidate, requiredFields = ['fullName']) { + // Use provided fields or fall back to a basic set for backward compatibility + const defaultFields = ['fullName', 'patient.fullName', 'patient.birthday', 'email']; + const fieldsToCheck = fieldsToValidate || defaultFields; + const producerData = producerCapture.responseBody; + const consumerData = consumerCapture.responseBody; + if (!producerData || !consumerData) { + throw new Error('Missing response data for consistency validation'); + } + console.log('šŸ” Validating data consistency:'); + // Only log full data in development mode + if (process.env.VERBOSE_VALIDATION) { + console.log('Producer:', JSON.stringify(producerData, null, 2)); + console.log('Consumer:', JSON.stringify(consumerData, null, 2)); + } + else { + console.log('Producer fullName:', producerData.fullName); + console.log('Consumer fullName:', consumerData.fullName); + } + // Validate each specified field + for (const fieldPath of fieldsToCheck) { + const producerValue = this.getNestedValue(producerData, fieldPath); + const consumerValue = this.getNestedValue(consumerData, fieldPath); + // Check if this field is marked as required + const isRequired = requiredFields.includes(fieldPath); + if (isRequired) { + if (producerValue === undefined || producerValue === null) { + throw new Error(`Required field ${fieldPath} is missing in producer data`); + } + if (consumerValue === undefined || consumerValue === null) { + throw new Error(`Required field ${fieldPath} is missing in consumer data`); + } + } + // For optional fields: only validate if the field exists in producer data + // If it exists in producer, it must also exist in consumer with same value + if (producerValue !== undefined && producerValue !== null) { + // Handle array comparison + if (Array.isArray(producerValue) && Array.isArray(consumerValue)) { + if (JSON.stringify(producerValue) !== JSON.stringify(consumerValue)) { + throw new Error(`${fieldPath} mismatch - Expected: ${JSON.stringify(producerValue)}, Got: ${JSON.stringify(consumerValue)}`); + } + } + else if (producerValue !== consumerValue) { + throw new Error(`${fieldPath} mismatch - Expected: ${producerValue}, Got: ${consumerValue}`); + } + } + // If producer value doesn't exist, consumer doesn't need to have it either (optional field) + } + console.log('āœ… Data consistency validated: consumer data reflects producer changes'); + } + /** + * Helper method to get nested object values using dot notation + * @param obj - The object to search + * @param path - The dot-notation path (e.g., 'patient.birthday') + * @returns The value at the path or undefined + */ + getNestedValue(obj, path) { + return path.split('.').reduce((current, key) => current?.[key], obj); + } + /** + * Validate producer-consumer data consistency for profile endpoints + * @param producerEndpointName - The PUT endpoint name (e.g., 'profile-metadata-put') + * @param consumerEndpointName - The GET endpoint name (e.g., 'profile-metadata-get') + * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) + * @throws Error if validation fails + */ + async validateProducerConsumerData(producerEndpointName, consumerEndpointName, fieldsToValidate) { + const producerSchema = (0, endpoint_registry_1.getEndpointSchema)(producerEndpointName); + const consumerSchema = (0, endpoint_registry_1.getEndpointSchema)(consumerEndpointName); + // Use provided fields, or consumer endpoint validation fields, or producer endpoint validation fields + const validationFields = fieldsToValidate || + consumerSchema.validationFields || + producerSchema.validationFields || ['fullName', 'email']; + // Use consumer endpoint required fields, or producer endpoint required fields, or default + const requiredFields = consumerSchema.requiredFields || + producerSchema.requiredFields || ['fullName']; + const producerCapture = this.getLatestCaptureMatching(producerSchema.method, producerSchema.url); + const consumerCapture = this.getLatestCaptureMatching(consumerSchema.method, consumerSchema.url); + if (!producerCapture) { + throw new Error(`No ${producerEndpointName} capture found for producer validation`); + } + if (!consumerCapture) { + throw new Error(`No ${consumerEndpointName} capture found for consumer validation`); + } + this.validateDataConsistency(producerCapture, consumerCapture, validationFields, requiredFields); + } + /** + * Private method to validate endpoint response without generating JSON file + * @param endpointName - The endpoint name from the registry + * @returns The captured network request or null if not found + */ + validateEndpointResponseSilent(endpointName) { + const schema = (0, endpoint_registry_1.getEndpointSchema)(endpointName); + const request = this.getLatestCaptureMatching(schema.method, schema.url); + return request; + } + /** + * Complete validation workflow for a user viewing profile data + * Validates both API schema and data consistency in one call + * @param consumerEndpointName - The GET endpoint name + * @param producerCapture - The stored PUT capture from the producer + * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) + */ + async compareEndpointResponse(consumerEndpointName, producerCapture, fieldsToValidate) { + // Get the endpoint schema to determine validation fields + const consumerSchema = (0, endpoint_registry_1.getEndpointSchema)(consumerEndpointName); + // Use provided fields, or endpoint-specific fields, or fall back to basic fields + const validationFields = fieldsToValidate || + consumerSchema.validationFields || ['fullName', 'patient.fullName', 'email']; + // Use endpoint-specific required fields, or default to fullName for backward compatibility + const requiredFields = consumerSchema.requiredFields || ['fullName']; + // Validate GET response schema without generating JSON file + const consumerCapture = this.validateEndpointResponseSilent(consumerEndpointName); + if (!consumerCapture) { + throw new Error(`No compare endpoint found`); + } + if (!producerCapture) { + throw new Error('No base endpoint found'); + } + // Generate comparison JSON file similar to validateEndpointResponse + const stepCounterObj = globalThis.__stepCounter; + if (stepCounterObj) { + // Increment for JSON file naming (this is correct behavior) + const stepNumber = stepCounterObj.increment(); + const currentStepName = stepCounterObj.getCurrentStepName(); + // Create comparison data object + const comparisonData = { + _comparison: { + description: `Data consistency comparison for ${consumerEndpointName}`, + timestamp: new Date().toISOString(), + fieldsValidated: validationFields, + requiredFields, + }, + original: { + url: producerCapture.url, + method: producerCapture.method, + timestamp: producerCapture.timestamp, + responseBody: producerCapture.responseBody, + }, + new: { + url: consumerCapture.url, + method: consumerCapture.method, + timestamp: consumerCapture.timestamp, + responseBody: consumerCapture.responseBody, + }, + }; + // Create consistent filename with step number and step name (like screenshots) + const stepNameForFile = currentStepName + ? currentStepName.toLowerCase().replace(/[^a-z0-9]/g, '-') + : consumerEndpointName.replace(/[^a-z0-9]/gi, '-'); + const fileName = `step-${stepNumber.toString().padStart(2, '0')}-${stepNameForFile}-comparison.json`; + // Save the comparison data using the unified approach + const { testInfo } = globalThis; + await this.saveApiResponse(comparisonData, consumerCapture.url, consumerCapture.method, fileName, testInfo); + } + // Validate data consistency using the determined validation fields and required fields + this.validateDataConsistency(producerCapture, consumerCapture, validationFields, requiredFields); + } +} +exports.NetworkHelper = NetworkHelper; +function createNetworkHelper(page) { + return new NetworkHelper(page); +} diff --git a/build/tests/fixtures/patient-helpers.js b/build/tests/fixtures/patient-helpers.js new file mode 100644 index 0000000..0b68151 --- /dev/null +++ b/build/tests/fixtures/patient-helpers.js @@ -0,0 +1,483 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.test = void 0; +const base_1 = require("@fixtures/base"); +const PatientNavigation_1 = __importDefault(require("@pom/patient/PatientNavigation")); +const env_1 = __importDefault(require("../../utilities/env")); +/** + * Initialize patient navigation helpers after login + */ +async function setupPatientSession(page) { + // Wait for patient navigation to be available + const nav = new PatientNavigation_1.default(page); + await Promise.all([ + nav.pages.ViewData.link.waitFor({ state: 'visible' }), + nav.pages.Profile.link.waitFor({ state: 'visible' }), + ]); + return nav; +} +/** + * Close any open modal dialogs that might block navigation + */ +async function closeOpenDialogs(page) { + try { + if (page.isClosed()) + return; + // Simple and fast: just press Escape twice to close any modals + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); + } + catch (error) { + // Ignore errors in dialog closing - they're not critical + } +} +/** + * Check if we're in a context where patient navigation is supported + */ +async function isInPatientContext(nav, page) { + try { + // Check if any patient navigation elements are visible + const patientElements = [nav.pages.ViewData.link, nav.pages.Profile.link, nav.pages.Share.link]; + for (const element of patientElements) { + if (await element.isVisible({ timeout: 1000 })) { + return true; + } + } + return false; + } + catch { + return false; + } +} +/** + * Get current page state by checking URL and visible elements + */ +async function getCurrentPageState(nav, page) { + const url = page.url(); + // Check each page in order of specificity + for (const [pageName, pageConfig] of Object.entries(nav.pages)) { + try { + if (pageConfig.verifyURL && url.includes(pageConfig.verifyURL)) { + if (pageConfig.verifyElement && + (await pageConfig.verifyElement.isVisible({ timeout: 1000 }))) { + return pageName; + } + } + } + catch { + // Continue checking other pages + } + } + return 'unknown'; +} +/** + * Navigation strategies for different page types + */ +const navigationStrategies = { + // Basic page navigation + default: [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'check-patient-context', + condition: async (state) => !(await isInPatientContext(state.nav, state.page)), + action: async (state) => { + console.log('Not in patient context, navigating to /data URL to reset'); + // Navigate to /data endpoint specifically, not just base URL + await state.page.goto(`${env_1.default.BASE_URL}/data`); + await state.page.waitForLoadState('domcontentloaded'); + // Wait for patient navigation to be available + await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); + console.log('Successfully reset to patient context via /data URL'); + }, + }, + { + name: 'wait-for-loading', + action: async (state) => { + const loading = state.page.getByText('Loading...', { exact: true }); + try { + await loading.waitFor({ state: 'hidden', timeout: 3000 }); + } + catch { + // Loading might not be visible + } + }, + }, + { + name: 'navigate-click', + action: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + await pageConfig.link.click({ timeout: 5000 }); + }, + }, + { + name: 'verify-navigation', + verify: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + if (pageConfig.verifyElement) { + try { + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + return true; + } + catch { + return false; + } + } + return true; + }, + }, + ], + // Profile page - handle account settings conflict + Profile: [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'check-patient-context', + condition: async (state) => !(await isInPatientContext(state.nav, state.page)), + action: async (state) => { + console.log('Not in patient context, navigating to /data URL to reset'); + // Navigate to /data endpoint specifically, not just base URL + await state.page.goto(`${env_1.default.BASE_URL}/data`); + await state.page.waitForLoadState('domcontentloaded'); + // Wait for patient navigation to be available + await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); + console.log('Successfully reset to patient context via /data URL'); + }, + }, + { + name: 'handle-account-settings-conflict', + condition: async (state) => state.page.url().includes('/profile') && + (await state.page + .getByRole('heading', { name: /account/i }) + .or(state.page.getByText('Account Settings')) + .or(state.page.getByText('Account')) + .or(state.page.locator('.profile-subnav-title').getByText('Account')) + .isVisible() + .catch(() => false)), + action: async (state) => { + console.log('On account settings page, redirecting to base URL first'); + await state.page.goto(env_1.default.BASE_URL); + await state.page.waitForTimeout(500); + }, + }, + { + name: 'navigate-click', + action: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + await pageConfig.link.click({ timeout: 5000 }); + }, + }, + { + name: 'verify-navigation', + verify: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + if (pageConfig.verifyElement) { + try { + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + return true; + } + catch { + return false; + } + } + return true; + }, + }, + ], + // Modal dialogs + modal: [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'navigate-click', + action: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + await pageConfig.link.click({ timeout: 5000 }); + }, + }, + { + name: 'wait-for-modal', + action: async (state) => { + await state.page.waitForTimeout(500); + }, + }, + ], + // Data pages that need ViewData prerequisite + 'data-page': [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'ensure-data-view', + condition: async (state) => !state.page.url().includes('/data/'), + action: async (state) => { + await state.nav.pages.ViewData.link.click(); + await state.nav.pages.ViewData.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + }, + }, + { + name: 'navigate-click', + action: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + await pageConfig.link.click({ timeout: 5000 }); + }, + }, + { + name: 'verify-navigation', + verify: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + if (pageConfig.verifyElement) { + try { + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + return true; + } + catch { + return false; + } + } + return true; + }, + }, + ], + // ShareData requires Share main page to be accessible first + ShareData: [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'check-patient-context', + condition: async (state) => !(await isInPatientContext(state.nav, state.page)), + action: async (state) => { + console.log('Not in patient context, navigating to /data URL to reset'); + await state.page.goto(`${env_1.default.BASE_URL}/data`); + await state.page.waitForLoadState('domcontentloaded'); + await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); + console.log('Successfully reset to patient context via /data URL'); + }, + }, + { + name: 'ensure-share-dependency', + action: async (state) => { + // First ensure Share main page is accessible + try { + await state.nav.pages.Share.link.waitFor({ state: 'visible', timeout: 3000 }); + console.log('Share dependency met - Share button is accessible'); + } + catch { + console.log('Share dependency not met - performing URL reset to /data'); + await state.page.goto(`${env_1.default.BASE_URL}/data`); + await state.page.waitForLoadState('domcontentloaded'); + await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); + console.log('URL reset completed, Share dependency should now be available'); + } + }, + }, + { + name: 'navigate-to-share-first', + action: async (state) => { + // Navigate to Share main page first to establish context + try { + await state.nav.pages.Share.link.click({ timeout: 3000 }); + await state.nav.pages.Share.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + console.log('Successfully navigated to Share main page'); + } + catch { + console.log('Could not reach Share main page, staying in current state'); + } + }, + }, + { + name: 'navigate-to-sharedata', + action: async (state) => { + // Now try to navigate to ShareData sub-page + try { + await state.nav.pages.ShareData.link.click({ timeout: 5000 }); + console.log('Successfully clicked ShareData button'); + } + catch { + console.log('ShareData button not available - this is expected and OK'); + } + }, + }, + { + name: 'verify-navigation', + verify: async (state) => { + // Try to verify ShareData first, fall back to Share if not available + try { + await state.nav.pages.ShareData.verifyElement.waitFor({ + state: 'visible', + timeout: 3000, + }); + console.log('āœ… ShareData page verified'); + return true; + } + catch { + try { + await state.nav.pages.Share.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + console.log('āœ… Share main page verified (ShareData not available - this is OK)'); + return true; + } + catch { + console.log('Neither ShareData nor Share page could be verified'); + return false; + } + } + }, + }, + ], +}; +/** + * Page type mappings to determine which strategy to use + */ +const pageStrategies = { + ViewData: 'default', + Basics: 'data-page', + Daily: 'data-page', + BGLog: 'data-page', + Trends: 'data-page', + Devices: 'data-page', + Profile: 'Profile', + ProfileEdit: 'default', // TODO: Add prerequisite logic + Share: 'default', + ShareData: 'ShareData', // Uses dependency-aware strategy + UploadData: 'default', + ChartDateRange: 'modal', + ChartDate: 'modal', + Print: 'modal', +}; +/** + * Execute navigation strategy + */ +async function executeNavigationStrategy(state) { + const strategyName = pageStrategies[state.targetPage] || 'default'; + const strategy = navigationStrategies[strategyName]; + console.log(`Executing ${strategyName} strategy for ${state.targetPage}`); + for (const step of strategy) { + try { + // Check condition if present + if (step.condition && !(await step.condition(state))) { + console.log(`Skipping step ${step.name} - condition not met`); + continue; + } + console.log(`Executing step: ${step.name}`); + // Execute action if present + if (step.action) { + await step.action(state); + } + // Verify if present + if (step.verify && !(await step.verify(state))) { + console.log(`Step ${step.name} verification failed`); + return false; + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`Step ${step.name} failed:`, errorMessage); + return false; + } + } + return true; +} +/** + * New scalable navigation function using state machine approach + */ +async function navigateTo(targetPage, page) { + if (page.isClosed()) { + console.log(`Page is closed, cannot navigate to ${targetPage}`); + return; + } + const nav = new PatientNavigation_1.default(page); + const currentPage = await getCurrentPageState(nav, page); + const state = { + currentPage, + targetPage, + nav, + page, + }; + console.log(`Navigating from ${currentPage} to ${targetPage}`); + // Execute primary navigation strategy + const success = await executeNavigationStrategy(state); + if (!success) { + console.log(`Primary navigation failed, trying fallback strategies`); + // Fallback strategy - go to base URL and try again + if (targetPage === 'Profile') { + try { + console.log('Profile fallback: going to base URL and trying again'); + await page.goto(env_1.default.BASE_URL); + await page.waitForTimeout(500); + await nav.pages[targetPage].link.click({ timeout: 3000 }); + console.log(`Successfully navigated to ${targetPage} via fallback`); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`Profile fallback failed: ${errorMessage}`); + throw error; + } + } + else if (nav.pages[targetPage].verifyURL) { + // Generic URL fallback for pages with backup URLs + try { + let fallbackURL = env_1.default.BASE_URL; + // For sub-pages that might not be available, fall back to the main page + if (targetPage === 'ShareData') { + fallbackURL = `${env_1.default.BASE_URL}/share`; // Fall back to main Share page + } + else if (targetPage === 'ProfileEdit') { + fallbackURL = `${env_1.default.BASE_URL}/profile`; // Fall back to main Profile page + } + else if (['Basics', 'Daily', 'BGLog', 'Trends', 'Devices'].includes(targetPage)) { + fallbackURL = `${env_1.default.BASE_URL}/data`; // Fall back to main ViewData page + } + else if (nav.pages[targetPage].verifyURL) { + fallbackURL = `${env_1.default.BASE_URL}/${nav.pages[targetPage].verifyURL}`; + } + await page.goto(fallbackURL); + console.log(`Used backup URL for ${targetPage}: ${fallbackURL}`); + // For sub-pages that fall back to main pages, verify the main page elements + let { verifyElement } = nav.pages[targetPage]; + if (targetPage === 'ShareData') { + verifyElement = nav.pages.Share.verifyElement; // Verify main Share page instead + } + else if (targetPage === 'ProfileEdit') { + verifyElement = nav.pages.Profile.verifyElement; // Verify main Profile page instead + } + else if (['Basics', 'Daily', 'BGLog', 'Trends', 'Devices'].includes(targetPage)) { + verifyElement = nav.pages.ViewData.verifyElement; // Verify main ViewData page instead + } + // Wait for the fallback page to actually load and verify we're there + if (verifyElement) { + await verifyElement.waitFor({ + state: 'visible', + timeout: 10000, + }); + console.log(`āœ… Backup URL navigation to ${targetPage} verified successfully (using fallback verification)`); + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`Backup URL failed: ${errorMessage}`); + throw error; + } + } + else { + throw new Error(`Navigation to ${targetPage} failed and no fallback available`); + } + } +} +const test = base_1.test; +exports.test = test; +test.patient = { + navigateTo, + setup: setupPatientSession, +}; diff --git a/build/tests/fixtures/test-tags.js b/build/tests/fixtures/test-tags.js new file mode 100644 index 0000000..a2f7ec6 --- /dev/null +++ b/build/tests/fixtures/test-tags.js @@ -0,0 +1,98 @@ +"use strict"; +/** + * Test Tags Fixture + * + * Simple tag definitions for test organization and Xray integration. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.TAG_CATEGORIES = exports.TEST_TAGS = void 0; +exports.validateRequiredTags = validateRequiredTags; +exports.createValidatedTags = createValidatedTags; +exports.TEST_TAGS = { + /** + * Generate a Jira-related tag for linking tests to Jira tickets. + * Usage: TEST_TAGS.RELATED('JIRA-1234') => '@jira(JIRA-1234)' + */ + RELATED: (jiraId) => { + // Accepts formats like ABC-1234 or JIRA-1234 + const jiraPattern = /^[A-Z][A-Z0-9]+-\d+$/; + if (!jiraPattern.test(jiraId)) { + throw new Error(`Invalid Jira ID: ${jiraId}. Must match pattern ABC-1234.`); + } + return `@jira(${jiraId})`; + }, + // Backend Services + BACK_SHORELINE: '@back-shoreline', + BACK_CLINIC: '@back-clinic', + BACK_HIGHWATER: '@back-highwater', + BACK_HYDROPHONE: '@back-hydrophone', + BACK_PLATFORM: '@back-platform', + BACK_SEAGULL: '@back-seagull', + BACK_TIDEWHISPERER: '@back-tidewhisperer', + BACK_MESSAGEAPI: '@back-messageapi', + BACK_JELLYFISH: '@back-jellyfish', + BACK_GATEKEEPER: '@back-gatekeeper', + BACK_EXPORT: '@back-export', + BACK_KEYCLOAK: '@back-keycloak', + // User Types + PATIENT: '@patient', + CLINICIAN: '@clinician', + // User-Subtypes + CUSTODIAL: '@custodial', + SHARED_MEMBER: '@shared_member', + PERSONAL: '@personal', + CLAIMED: '@claimed', + // Test Types + API: '@api', + UI: '@ui', + SMOKE: '@smoke', + REGRESSION: '@regression', + // Priority + CRITICAL: '@critical', + HIGH: '@high', + MEDIUM: '@medium', + LOW: '@low', + // Endpoint API Testing + API_PROFILE: '@api_profile', + API_USER: '@api_user', +}; +// Tag Categories for Validation +exports.TAG_CATEGORIES = { + USER_TYPES: [exports.TEST_TAGS.PATIENT, exports.TEST_TAGS.CLINICIAN], + TEST_TYPES: [exports.TEST_TAGS.API, exports.TEST_TAGS.UI, exports.TEST_TAGS.SMOKE, exports.TEST_TAGS.REGRESSION], + PRIORITIES: [exports.TEST_TAGS.CRITICAL, exports.TEST_TAGS.HIGH, exports.TEST_TAGS.MEDIUM, exports.TEST_TAGS.LOW], +}; +/** + * Validates that tags include at least one from each required category + * @param tags Array of tags to validate + * @returns Object with validation results + */ +function validateRequiredTags(tags) { + const hasUserType = tags.some(tag => exports.TAG_CATEGORIES.USER_TYPES.includes(tag)); + const hasTestType = tags.some(tag => exports.TAG_CATEGORIES.TEST_TYPES.includes(tag)); + const hasPriority = tags.some(tag => exports.TAG_CATEGORIES.PRIORITIES.includes(tag)); + const isValid = hasUserType && hasTestType && hasPriority; + const missing = []; + if (!hasUserType) + missing.push('User Type'); + if (!hasTestType) + missing.push('Test Type'); + if (!hasPriority) + missing.push('Priority'); + return { + isValid, + missing, + message: isValid ? 'All required tags present' : `Missing required tags: ${missing.join(', ')}`, + }; +} +/** + * Helper function to create tags with validation + * Throws error if required tags are missing + */ +function createValidatedTags(tags) { + const validation = validateRequiredTags(tags); + if (!validation.isValid) { + throw new Error(`Test tags validation failed: ${validation.message}`); + } + return tags; +} diff --git a/build/tests/global-setup.js b/build/tests/global-setup.js new file mode 100644 index 0000000..2550db3 --- /dev/null +++ b/build/tests/global-setup.js @@ -0,0 +1,47 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = globalSetup; +const test_1 = require("@playwright/test"); +const LoginPage_1 = __importDefault(require("@pom/LoginPage")); +const node_fs_1 = __importDefault(require("node:fs")); +const node_path_1 = __importDefault(require("node:path")); +const env_1 = __importDefault(require("../utilities/env")); +async function loginUserType(role) { + const browser = await test_1.chromium.launch(); + const context = await browser.newContext({ + baseURL: process.env.BASE_URL, + }); + const page = await context.newPage(); + await page.goto(env_1.default.BASE_URL); + const loginPage = new LoginPage_1.default(page); + if (role === 'personal') { + await loginPage.login(env_1.default.PERSONAL_USERNAME, env_1.default.PERSONAL_PASSWORD); + await page.waitForURL('**/data'); + } + else if (role === 'claimed') { + await loginPage.login(env_1.default.CLAIMED_USERNAME, env_1.default.CLAIMED_PASSWORD); + await page.waitForURL('**/data'); + } + else if (role === 'shared') { + await loginPage.login(env_1.default.SHARED_USERNAME, env_1.default.SHARED_PASSWORD); + await page.waitForURL('**/data'); + } + else { + await loginPage.login(env_1.default.CLINICIAN_USERNAME, env_1.default.CLINICIAN_PASSWORD); + await page.waitForURL('**/workspaces'); + } + const authDir = node_path_1.default.resolve(process.cwd(), 'tests', '.auth'); + await node_fs_1.default.promises.mkdir(authDir, { recursive: true }); + const filePath = node_path_1.default.join(authDir, `${role}.json`); + await context.storageState({ path: filePath }); + await browser.close(); +} +async function globalSetup(_config) { + await loginUserType('personal'); + await loginUserType('claimed'); + await loginUserType('shared'); + await loginUserType('clinician'); +} diff --git a/build/tests/personal/AP-Profile/edit-personal-profile-API.spec.js b/build/tests/personal/AP-Profile/edit-personal-profile-API.spec.js new file mode 100644 index 0000000..45bc9b2 --- /dev/null +++ b/build/tests/personal/AP-Profile/edit-personal-profile-API.spec.js @@ -0,0 +1,75 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const patient_helpers_1 = require("../../fixtures/patient-helpers"); +const network_helpers_1 = require("../../fixtures/network-helpers"); +const test_tags_1 = require("../../fixtures/test-tags"); +const ProfilePage_1 = require("../../../page-objects/patient/ProfilePage"); +patient_helpers_1.test.describe('Personal Accounts allow access and modification of profile details', () => { + // API Test cases require this to capture network activity + let api; + (0, patient_helpers_1.test)('should allow navigation to profile details and edit profile fields', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.PATIENT, // User Type (required) + test_tags_1.TEST_TAGS.PERSONAL, // User Subtype (required) + test_tags_1.TEST_TAGS.API, // Test Type (required) + test_tags_1.TEST_TAGS.UI, // Test Type (required) + test_tags_1.TEST_TAGS.HIGH, // Priority (required) + test_tags_1.TEST_TAGS.API_PROFILE, // Feature (optional) + ]), + }, async ({ page }) => { + // Step 1: Log in to personal account and setup network capture + await patient_helpers_1.test.step('Given personal account has been logged in', async () => { + api = (0, network_helpers_1.createNetworkHelper)(page); + await api.startCapture(); + await page.goto('/data'); + await patient_helpers_1.test.patient.setup(page); + // Step 2: Navigate to profile + await patient_helpers_1.test.step('When user navigates to Profile page', async () => { + await patient_helpers_1.test.patient.navigateTo('Profile', page); + }); + // Step 3: Check profile GET response + await patient_helpers_1.test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Step 4: Open Edit Profile + await patient_helpers_1.test.step('When user selects Edit button', async () => { + await patient_helpers_1.test.patient.navigateTo('ProfileEdit', page); + }); + // Initialize ProfilePage for steps 4 and 5 + const profilePage = new ProfilePage_1.ProfilePage(page); + // Step 5: Change profile fields (confirmed user access) + await patient_helpers_1.test.step('When user updates profile fields', async () => { + // Generate completely unique values for this confirmed user test run + const testRunId = Math.floor(Math.random() * 10000); + const updatedName = `Personal Patient Updated ${testRunId}`; + const birthYear = 1985 + (testRunId % 10); + const diagnosisYear = birthYear + 20; + const birthDate = `01/15/${birthYear}`; + const diagnosisDate = `03/10/${diagnosisYear}`; + // Generate random 15-letter string for clinical notes + const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); + // Get current diagnosis index and calculate next one (1-7, wrapping) + const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); + let nextDiagnosisIndex = currentDiagnosisIndex + 1; + if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { + nextDiagnosisIndex = 1; + } + // Update fields using ProfilePage methods + await profilePage.fillFullName(updatedName); + await profilePage.fillBirthDate(birthDate); + await profilePage.fillDiagnosisDate(diagnosisDate); + await profilePage.selectDiagnosisType(nextDiagnosisIndex); + await profilePage.fillClinicalNotes(randomString); + }); + // Step 6: Save profile edit + await patient_helpers_1.test.step('When user saves profile changes', async () => { + await profilePage.saveProfile(); + }); + // Step 7: Check profile PUT response + await patient_helpers_1.test.stepNoScreenshot('Then profile endpoint responds with PUT request consistent with schema', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + }); + await api.stopCapture(); + }); + }); +}); diff --git a/build/tests/personal/basic-functionality.spec.js b/build/tests/personal/basic-functionality.spec.js new file mode 100644 index 0000000..48e40fa --- /dev/null +++ b/build/tests/personal/basic-functionality.spec.js @@ -0,0 +1,240 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +// @ts-check +const base_1 = require("@fixtures/base"); +const BasicsPage_1 = __importDefault(require("@pom/patient/BasicsPage")); +const DailyPage_1 = __importDefault(require("@pom/patient/DailyPage")); +base_1.test.describe('Patient Data Navigation and Visualization', () => { + base_1.test.beforeEach(async ({ page }) => { + await base_1.test.step('Given user has been logged in', async () => { + const basicsPage = new BasicsPage_1.default(page); + await basicsPage.goto(); + // await page.getByText("Loading").waitFor({ state: "detached", timeout: 10000 }); + }); + }); + // BG readings dashboard functionality + (0, base_1.test)('should display daily chart when selecting a date from basics page', async ({ page }) => { + const basicsPage = new BasicsPage_1.default(page); + const dailyPage = new DailyPage_1.default(page); + let selectedDateText; + await base_1.test.step('When the navigation bar is visible', async () => { + await basicsPage.navigationBar.buttons.viewData.waitFor({ + state: 'visible', + }); + }); + await base_1.test.step('When the user clicks on the most recent day', async () => { + const recentDayElement = basicsPage.bgReadingsSection.firstDayOfData; + await recentDayElement.waitFor({ state: 'visible' }); + await recentDayElement.hover(); + selectedDateText = await basicsPage.bgReadingsSection.calendarDayhover.text(); + await basicsPage.bgReadingsSection.calendarDayhover.el.click(); + }); + await base_1.test.step('Then the daily chart is visible and correctly rendered', async () => { + const chartContainer = dailyPage.dailyChart.container; + await chartContainer.waitFor({ state: 'visible' }); + if (!selectedDateText) { + throw new Error('Selected date text is null'); + } + // Verify the selected date matches the displayed date + // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); + // Capture chart screenshot for visual regression + await (0, base_1.expect)(chartContainer).toHaveScreenshot('daily-chart-1.png'); + }); + }); + // Bolus dashboard functionality + (0, base_1.test)('should display bolus dashboard when selecting a date from basics page', async ({ page, }) => { + const basicsPage = new BasicsPage_1.default(page); + const dailyPage = new DailyPage_1.default(page); + let selectedDateText; + await base_1.test.step('When the navigation bar is visible', async () => { + await basicsPage.navigationBar.buttons.viewData.waitFor({ + state: 'visible', + }); + }); + await base_1.test.step('When the user clicks on the most recent day', async () => { + const recentDayElement = basicsPage.bolusingSection.firstDayOfData; + await recentDayElement.waitFor({ state: 'visible' }); + await recentDayElement.hover(); + selectedDateText = await basicsPage.bolusingSection.calendarDayhover.text(); + await basicsPage.bolusingSection.calendarDayhover.el.click(); + }); + await base_1.test.step('Then the daily chart is visible and correctly rendered', async () => { + const chartContainer = dailyPage.dailyChart.container; + await chartContainer.waitFor({ state: 'visible' }); + if (!selectedDateText) { + throw new Error('Selected date text is null'); + } + // Verify the selected date matches the displayed date + // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); + // Capture chart screenshot for visual regression + await (0, base_1.expect)(chartContainer).toHaveScreenshot('daily-chart-2.png'); + }); + }); + // Infusion Site Changes dashboard functionality + (0, base_1.test)('should display Infusion site changes dashboard when selecting a date from basics page', async ({ page, }) => { + const basicsPage = new BasicsPage_1.default(page); + const dailyPage = new DailyPage_1.default(page); + let selectedDateText; + await base_1.test.step('When the infusion site changes dashboard is visible', async () => { + // Verify dashboard title and initial state + // await expect(basicsPage.tubingPrimeSection.title).toBeVisible(); + // await expect(basicsPage.tubingPrimeSection.description).toHaveText( + // "We are using Fill Cannula to visualize your infusion site changes." + // ); + }); + await base_1.test.step('When testing Fill Cannula functionality', async () => { + // Verify radio button options + await basicsPage.tubingPrimeSection.settingsOption.fillCannula.waitFor({ + state: 'visible', + timeout: 60000, + }); + await (0, base_1.expect)(basicsPage.tubingPrimeSection.settingsOption.fillCannula).toBeVisible(); + await (0, base_1.expect)(basicsPage.tubingPrimeSection.settingsOption.fillTubing).toBeVisible(); + // Select Fill Cannula and verify highlighted days + await basicsPage.tubingPrimeSection.settingsOption.fillCannula.click(); + // // Verify duration indicator is visible + // await expect( + // basicsPage.tubingPrimeSection.durationIndicator + // ).toContainText("4 days"); + // Verify cannula icons are visible and tubing icons are not + await (0, base_1.expect)(basicsPage.tubingPrimeSection.cannulaIcons).toBeAttached(); + await (0, base_1.expect)(basicsPage.tubingPrimeSection.tubingIcons).not.toBeAttached(); + // Select a highlighted day + const highlightedDay = basicsPage.tubingPrimeSection.filledDay; + await highlightedDay.hover(); + selectedDateText = await basicsPage.tubingPrimeSection.calendarDayhover.text(); + await basicsPage.tubingPrimeSection.calendarDayhover.el.click(); + }); + await base_1.test.step('Then the daily chart shows correct cannula fill date', async () => { + const chartContainer = dailyPage.dailyChart.container; + await chartContainer.waitFor({ state: 'visible' }); + if (!selectedDateText) { + throw new Error('Selected date text is null'); + } + // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); + await (0, base_1.expect)(chartContainer).toHaveScreenshot('daily-chart-cannula.png'); + }); + // Return to basics page and test Fill Tubing Option + await base_1.test.step('When testing Fill Tubing functionality', async () => { + // Navigate back to basics + await base_1.test.step('When the navigation bar is visible', async () => { + await basicsPage.navigationBar.buttons.viewData.waitFor({ + state: 'visible', + }); + }); + // await basicsPage.navigationSubMenu.links.basics.click(); + await basicsPage.tubingPrimeSection.settings.waitFor({ + state: 'visible', + }); + // Click settings and select Fill Tubing + await basicsPage.tubingPrimeSection.settings.click(); + await basicsPage.tubingPrimeSection.settingsOption.fillTubing.click(); + // Verify filled tubing day is visible and cannula day is not + await (0, base_1.expect)(basicsPage.tubingPrimeSection.tubingIcons).toBeAttached(); + await (0, base_1.expect)(basicsPage.tubingPrimeSection.cannulaIcons).not.toBeAttached(); + // Click on the most recent day with tubing fill + const tubingDay = basicsPage.tubingPrimeSection.filledDay; + await tubingDay.hover(); + selectedDateText = await basicsPage.tubingPrimeSection.calendarDayhover.text(); + await basicsPage.tubingPrimeSection.calendarDayhover.el.click(); + }); + await base_1.test.step('Then the daily chart shows correct tubing fill date', async () => { + const chartContainer = dailyPage.dailyChart.container; + await chartContainer.waitFor({ state: 'visible' }); + if (!selectedDateText) { + throw new Error('Selected date text is null'); + } + // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); + await (0, base_1.expect)(chartContainer).toHaveScreenshot('daily-chart-tubing.png'); + }); + }); + // TODO: Previous test doesn't test values. Should we? :) + // Readings in range functionality + (0, base_1.test)('The hover over elements in sidebar shows correct values', async ({ page }) => { + // Stats for BGM + const expectedHeadersReadingInRange = [ + { header: 'Readings Below Range', value: 3 }, + { header: 'Readings Below Range', value: 0 }, + { header: 'Readings In Range', value: 71 }, + { header: 'Readings Above Range', value: 24 }, + { header: 'Readings Above Range', value: 2 }, + ]; + const basicsPage = new BasicsPage_1.default(page); + await base_1.test.step('When the navigation bar is visible', async () => { + await basicsPage.navigationBar.buttons.viewData.waitFor({ + state: 'visible', + }); + }); + // Other BGM tooltip functionality + await basicsPage.statsSidebar.toggleTo('BGM'); + for (let i = 0; i < 5; i += 1) { + const bar = basicsPage.statsSidebar.readingsInRange.hoverBar.nth(i); + const barLabel = basicsPage.statsSidebar.readingsInRange.hoverBarLabel.nth(i); + await base_1.test.step('When the user hovers over the Avg. Daily Readings In Range chart', async () => { + await bar.hover(); + }); + await base_1.test.step('Then the correct header is visible', async () => { + await base_1.expect + .soft(basicsPage.statsSidebar.readingsInRange.header) + .toContainText(expectedHeadersReadingInRange[i].header); + }); + await base_1.test.step('Then the correct value is visible', async () => { + await base_1.expect + .soft(barLabel) + .toContainText(expectedHeadersReadingInRange[i].value.toString()); + }); + } + // Stats for CGM + // Time in range functionality + const expectedHeadersTimeInRange = [ + { header: 'Time Below Range', value: 0.1 }, + { header: 'Time Below Range', value: 1 }, + { header: 'Time In Range', value: 90 }, + { header: 'Time Above Range', value: 9 }, + { header: 'Time Above Range', value: 0.3 }, + ]; + await basicsPage.statsSidebar.toggleTo('CGM'); + for (let i = 0; i < expectedHeadersTimeInRange.length; i += 1) { + const bar = basicsPage.statsSidebar.timeInRange.hoverBar.nth(i); + const barLabel = basicsPage.statsSidebar.timeInRange.hoverBarLabel.nth(i); + await base_1.test.step('When the user hovers over the Avg. Daily Time In Range chart', async () => { + await bar.hover(); + }); + await base_1.test.step('Then the correct header is visible', async () => { + await base_1.expect + .soft(basicsPage.statsSidebar.timeInRange.header) + .toContainText(expectedHeadersTimeInRange[i].header); + }); + await base_1.test.step('Then the correct value is visible', async () => { + await base_1.expect.soft(barLabel).toContainText(expectedHeadersTimeInRange[i].value.toString()); + }); + } + }); + // Other CGM tooltip functionality + (0, base_1.test)('other CGM tooltip functionality', async ({ page }) => { + const basicsPage = new BasicsPage_1.default(page); + await basicsPage.statsSidebar.toggleTo('CGM'); + const expectedHeadersTimeInRange = [ + { header: 'Basal Insulin', value: 14.7, percentage: 44 }, + { header: 'Bolus Insulin', value: 18.8, percentage: 56 }, + ]; + for (let i = 0; i < expectedHeadersTimeInRange.length; i += 1) { + const bar = basicsPage.statsSidebar.totalInsulin.hoverBar.nth(i); + const barLabel = basicsPage.statsSidebar.totalInsulin.hoverBarLabel.nth(i); + await base_1.test.step('When the user hovers over the Avg. Daily Total Insulin chart', async () => { + await bar.hover(); + }); + await base_1.test.step('Then the correct header is visible', async () => { + await base_1.expect + .soft(basicsPage.statsSidebar.timeInRange.header) + .toContainText(expectedHeadersTimeInRange[i].header); + }); + await base_1.test.step('Then the correct value is visible', async () => { + await base_1.expect.soft(barLabel).toContainText(expectedHeadersTimeInRange[i].value.toString()); + }); + } + }); +}); diff --git a/build/tests/personal/login.spec.js b/build/tests/personal/login.spec.js new file mode 100644 index 0000000..9855597 --- /dev/null +++ b/build/tests/personal/login.spec.js @@ -0,0 +1,66 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +// @ts-check +const base_1 = require("@fixtures/base"); +const LoginPage_1 = __importDefault(require("page-objects/LoginPage")); +const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); +const env_1 = __importDefault(require("../../utilities/env")); +// make sure we don't have any cookies or origins +base_1.test.use({ storageState: { cookies: [], origins: [] } }); +// Possible testcases: https://tidepool.atlassian.net/jira/software/c/projects/WEB/issues/?jql=project%20%3D%20%22WEB%22%20AND%20type%20%3D%20Test%20AND%20textfields%20~%20%22login%22%20ORDER%20BY%20created%20DESC +base_1.test.describe('Login into application', () => { + (0, base_1.test)('should work with valid credentials for clinician with multiple clinics', async ({ page, }) => { + const loginPage = new LoginPage_1.default(page); + await base_1.test.step('When user is logged into application', async () => { + await loginPage.goto(); + await loginPage.login(env_1.default.CLINICIAN_USERNAME, env_1.default.CLINICIAN_PASSWORD); + }); + await base_1.test.step('Then the user is redirected to workspaces page', async () => { + const workspacesPage = new WorkspacesPage_1.default(page); + await page.waitForURL(workspacesPage.url); + await (0, base_1.expect)(workspacesPage.header).toBeVisible(); + }); + }); + (0, base_1.test)('should show error message with invalid credentials', async ({ page }) => { + const loginPage = new LoginPage_1.default(page); + await base_1.test.step('When user attempts to login with invalid credentials', async () => { + await loginPage.goto(); + // Enter email + await page.fill('#username', 'invalid@email.com'); + await page.click('#kc-login'); + }); + await base_1.test.step('Then error message should be displayed', async () => { + // Wait for the error message to appear + await (0, base_1.expect)(page.locator('#input-error-username')).toBeVisible(); + await (0, base_1.expect)(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); + }); + }); + (0, base_1.test)('should validate email format', async ({ page }) => { + const loginPage = new LoginPage_1.default(page); + await base_1.test.step('When user attempts to login with invalid email format', async () => { + await loginPage.goto(); + // Enter invalid email format + await page.fill('#username', 'invalidemail'); + await page.click('#kc-login'); + }); + await base_1.test.step('Then email validation error should be displayed', async () => { + // Check for email validation error message + await (0, base_1.expect)(page.locator('#input-error-username')).toBeVisible(); + await (0, base_1.expect)(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); + }); + }); + (0, base_1.test)('should show error message with invalid credentials 1', async ({ page }) => { + const loginPage = new LoginPage_1.default(page); + await base_1.test.step('When user is logged into application', async () => { + await loginPage.goto(); + await loginPage.login(env_1.default.CLINICIAN_USERNAME, `${env_1.default.CLINICIAN_PASSWORD}1`); + }); + await base_1.test.step('Then error message should be displayed', async () => { + await (0, base_1.expect)(page.locator('#input-error')).toBeVisible(); + await (0, base_1.expect)(page.locator('#input-error')).toContainText('Invalid password.'); + }); + }); +}); diff --git a/build/utilities/annotations.js b/build/utilities/annotations.js new file mode 100644 index 0000000..528cbcc --- /dev/null +++ b/build/utilities/annotations.js @@ -0,0 +1,24 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = addTestAnnotations; +/** + * Add test annotations to the test info for JIRA integration + */ +function addTestAnnotations(testInfo, annotations) { + testInfo.annotations.push({ + type: 'test_key', + description: annotations.testKey, + }); + testInfo.annotations.push({ + type: 'test_summary', + description: annotations.testSummary, + }); + testInfo.annotations.push({ + type: 'requirements', + description: annotations.requirements, + }); + testInfo.annotations.push({ + type: 'test_description', + description: annotations.testDescription, + }); +} diff --git a/build/utilities/env.js b/build/utilities/env.js new file mode 100644 index 0000000..1c8b960 --- /dev/null +++ b/build/utilities/env.js @@ -0,0 +1,42 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const dotenv_1 = __importDefault(require("dotenv")); +const zod_1 = __importDefault(require("zod")); +dotenv_1.default.config(); +const envSchema = zod_1.default.object({ + BROWSERSTACK_USERNAME: zod_1.default.string().optional(), + BROWSERSTACK_ACCESS_KEY: zod_1.default.string().optional(), + PERSONAL_USERNAME: zod_1.default.string(), + PERSONAL_PASSWORD: zod_1.default.string(), + CLAIMED_USERNAME: zod_1.default.string(), + CLAIMED_PASSWORD: zod_1.default.string(), + SHARED_USERNAME: zod_1.default.string(), + SHARED_PASSWORD: zod_1.default.string(), + CLINICIAN_USERNAME: zod_1.default.string(), + CLINICIAN_PASSWORD: zod_1.default.string(), + TARGET_ENV: zod_1.default.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production', 'prd', 'int']), + XRAY_CLIENT_ID: zod_1.default.string().optional(), + XRAY_CLIENT_SECRET: zod_1.default.string().optional(), +}); +const env = envSchema.safeParse(process.env); +if (!env.success) { + console.error('āŒ Invalid environment variables:\n', env.error.format()); + throw new Error('Invalid environment variables. Check your .env file.'); +} +const URL_MAP = { + qa1: 'https://qa1.development.tidepool.org', + qa2: 'https://qa2.development.tidepool.org', + qa3: 'https://qa3.development.tidepool.org', + qa4: 'https://qa4.development.tidepool.org', + qa5: 'https://qa5.development.tidepool.org', + production: 'https://app.tidepool.org', + prd: 'https://app.tidepool.org', // Alias for production + int: 'https://int.development.tidepool.org', // Integration environment +}; +exports.default = { + ...env.data, + BASE_URL: URL_MAP[env.data.TARGET_ENV], +}; diff --git a/build/utilities/xray-json-reporter.js b/build/utilities/xray-json-reporter.js new file mode 100644 index 0000000..c6d7c4a --- /dev/null +++ b/build/utilities/xray-json-reporter.js @@ -0,0 +1,268 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const node_fs_1 = __importDefault(require("node:fs")); +const node_path_1 = __importDefault(require("node:path")); +const env_1 = __importDefault(require("./env")); +/** + * Unified Xray JSON Reporter for Playwright + * Maps rich Playwright test data to Xray's JSON format with step-by-step evidence + */ +class XrayJsonReporter { + constructor() { + this.styles = { + success: 'āœ…', + error: 'āŒ', + info: 'ā„¹ļø', + warning: 'ā›”ļø', + upload: 'šŸš€', + test: '🧪', + separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + }; + this.startTime = ''; + this.endTime = ''; + } + /** + * Authenticates with Xray API using client credentials + */ + async authenticateWithXray() { + try { + console.log(`${this.styles.info} Authenticating with Xray...`); + const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: env_1.default.XRAY_CLIENT_ID, + client_secret: env_1.default.XRAY_CLIENT_SECRET, + }), + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + } + const token = await response.text(); + console.log(`${this.styles.success} Successfully authenticated with Xray`); + return token.replace(/"/g, ''); // Remove quotes from token + } + catch (error) { + console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); + throw error; + } + } + /** + * Converts file to base64 string for Xray evidence + */ + async fileToBase64(filePath) { + try { + const fileBuffer = node_fs_1.default.readFileSync(filePath); + return fileBuffer.toString('base64'); + } + catch (error) { + console.warn(`${this.styles.warning} Could not read file ${filePath}:`, error); + return ''; + } + } + /** + * Extracts step information from test annotations + */ + async extractSteps(annotations, attachments) { + const steps = []; + const stepAnnotations = annotations.filter(ann => ann.type.startsWith('Step Duration:')); + for (const stepAnn of stepAnnotations) { + const stepName = stepAnn.type.replace('Step Duration: ', ''); + const duration = stepAnn.description; + // Find associated step attachments + const stepAttachments = attachments.filter(att => att.name.toLowerCase().includes(stepName.toLowerCase().substring(0, 20))); + const step = { + action: stepName, + data: `Duration: ${duration}`, + result: stepName.includes('Then') ? stepName : undefined, + status: 'PASS', // Will be updated based on test result + evidences: [] + }; + // Add evidence for this step + for (const attachment of stepAttachments) { + if (attachment.path && node_fs_1.default.existsSync(attachment.path)) { + step.evidences?.push({ + data: await this.fileToBase64(attachment.path), + filename: node_path_1.default.basename(attachment.path), + contentType: attachment.contentType || 'application/octet-stream' + }); + } + } + steps.push(step); + } + return steps; + } + /** + * Maps Playwright test result to Xray test format + */ + async mapPlaywrightTestToXray(testCase, testResult) { + const tags = testCase.tags || []; + const annotations = testResult.annotations || []; + const attachments = testResult.attachments || []; + // Extract steps from annotations + const steps = await this.extractSteps(annotations, attachments); + // Mark failed steps if test failed + if (testResult.status !== 'passed' && steps.length > 0) { + steps[steps.length - 1].status = 'FAIL'; + steps[steps.length - 1].actualResult = testResult.error?.message || 'Test failed'; + } + // Collect test-level evidence (screenshots, videos) + const testEvidences = []; + for (const attachment of attachments) { + if (attachment.path && node_fs_1.default.existsSync(attachment.path)) { + // Add main test evidence (final screenshots, videos, etc.) + if (attachment.name.includes('screenshot') || attachment.name.includes('video')) { + testEvidences.push({ + data: await this.fileToBase64(attachment.path), + filename: attachment.name, + contentType: attachment.contentType || 'application/octet-stream' + }); + } + } + } + const xrayTest = { + testInfo: { + summary: testCase.title, + type: 'Generic', + projectKey: 'XT', // Could be made configurable + labels: tags + }, + status: testResult.status === 'passed' ? 'PASS' : + testResult.status === 'skipped' ? 'PENDING' : 'FAIL', + comment: testResult.error?.message, + evidences: testEvidences, + steps: steps.length > 0 ? steps : undefined + }; + return xrayTest; + } + /** + * Converts Playwright JSON results to Xray format + */ + async convertPlaywrightJsonToXray(playwrightJsonPath) { + const jsonContent = node_fs_1.default.readFileSync(playwrightJsonPath, 'utf8'); + const playwrightResult = JSON.parse(jsonContent); + const tests = []; + // Process all test suites + for (const suite of playwrightResult.suites || []) { + await this.processSuite(suite, tests); + } + const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; + const targetEnv = process.env.TARGET_ENV || 'qa1'; + const xrayResult = { + info: { + summary: `Playwright Test Execution - ${new Date().toISOString()}`, + description: `Automated test execution for ${targetEnv} environment`, + version: '1.0', + testExecutionKey: testExecKey !== 'none' ? testExecKey : undefined, + startDate: playwrightResult.stats?.startTime || new Date().toISOString(), + finishDate: new Date(new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + + (playwrightResult.stats?.duration || 0)).toISOString(), + testEnvironments: [targetEnv] + }, + tests + }; + return xrayResult; + } + /** + * Recursively processes test suites + */ + async processSuite(suite, tests) { + // Process specs in this suite + for (const spec of suite.specs || []) { + for (const test of spec.tests || []) { + for (const result of test.results || []) { + const xrayTest = await this.mapPlaywrightTestToXray(spec, result); + tests.push(xrayTest); + } + } + } + // Process nested suites + for (const nestedSuite of suite.suites || []) { + await this.processSuite(nestedSuite, tests); + } + } + /** + * Uploads Xray execution result to Xray + */ + async uploadToXray(xrayResult) { + try { + console.log(`${this.styles.info} Uploading test execution to Xray...`); + const token = await this.authenticateWithXray(); + const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(xrayResult), + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + } + const result = await response.json(); + console.log(`${this.styles.success} Successfully uploaded to Xray. Execution Key: ${result.key}`); + } + catch (error) { + console.error(`${this.styles.error} Failed to upload to Xray:`, error); + throw error; + } + } + /** + * Main method to process and upload results + */ + async processAndUpload(playwrightJsonPath) { + if (!(env_1.default.XRAY_CLIENT_ID && env_1.default.XRAY_CLIENT_SECRET)) { + console.log(`${this.styles.warning} No Xray credentials found, skipping upload to JIRA Xray`); + return; + } + try { + console.log(`${this.styles.info} Processing Playwright results...`); + const xrayResult = await this.convertPlaywrightJsonToXray(playwrightJsonPath); + // Save converted result for debugging + node_fs_1.default.writeFileSync('test-results/xray-execution.json', JSON.stringify(xrayResult, null, 2)); + await this.uploadToXray(xrayResult); + console.log(`${this.styles.upload} Xray upload completed successfully`); + } + catch (error) { + console.error(`${this.styles.error} Failed to process and upload:`, error); + throw error; + } + } + /** + * Reporter lifecycle methods for direct Playwright integration + */ + onBegin(_config, suite) { + this.startTime = new Date().toISOString(); + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); + console.log(`${this.styles.separator}\n`); + } + onTestBegin(test, _result) { + console.log(`${this.styles.test} Starting: ${test.title}`); + } + onTestEnd(test, result) { + const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; + console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); + } + async onEnd(result) { + this.endTime = new Date().toISOString(); + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.info} Test Run Summary:`); + console.log(`Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`); + console.log(`Duration: ${result.duration}ms`); + console.log(`${this.styles.separator}\n`); + // Auto-upload if JSON results are available + const jsonPath = 'test-results/last-run.json'; + if (node_fs_1.default.existsSync(jsonPath)) { + await this.processAndUpload(jsonPath); + } + } +} +exports.default = XrayJsonReporter; diff --git a/build/utilities/xray-reporter.js b/build/utilities/xray-reporter.js new file mode 100644 index 0000000..0532c49 --- /dev/null +++ b/build/utilities/xray-reporter.js @@ -0,0 +1,134 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const node_fs_1 = __importDefault(require("node:fs")); +const env_1 = __importDefault(require("./env")); +/** + * Reporter class for uploading test results to Xray + */ +class XRayReporter { + constructor() { + this.styles = { + success: 'āœ…', + error: 'āŒ', + info: 'ā„¹ļø', + warning: 'ā›”ļø', + upload: 'šŸš€', + test: '🧪', + separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + }; + } + /** + * Authenticates with Xray API using client credentials + * @returns {Promise} The authentication token + * @throws {Error} If authentication fails + */ + async authenticateWithXray() { + try { + console.log(`${this.styles.info} Authenticating with Xray...`); + const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: env_1.default.XRAY_CLIENT_ID, + client_secret: env_1.default.XRAY_CLIENT_SECRET, + }), + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}, ${response.body}`); + } + const data = await response.json(); + console.log(`${this.styles.success} Successfully authenticated with Xray`); + return data.token; + } + catch (error) { + console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); + throw error; + } + } + /** + * Uploads test results to Xray + * @param {string} token - The authentication token + * @param {string} xmlContent - The JUnit XML content to upload + * @returns {Promise} + * @throws {Error} If upload fails + */ + async uploadTestResults(token, xmlContent) { + try { + console.log(`${this.styles.info} Uploading test results to Xray...`); + const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution/junit?projectKey=XT&testPlanKey=XT-380', { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + Authorization: `Bearer ${token}`, + }, + body: xmlContent, + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + } + console.log(`${this.styles.success} Successfully uploaded test results to Xray`); + } + catch (error) { + console.error(`${this.styles.error} Failed to upload test results to Xray:`, error); + throw error; + } + } + /** + * Called when test run begins + * @param suite - Test suite object containing all tests + */ + onBegin(_config, suite) { + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); + console.log(`${this.styles.separator}\n`); + } + /** + * Called when a test begins + * @param test - Test case object + */ + onTestBegin(test, _result) { + console.log(`${this.styles.test} Starting: ${test.title}`); + } + /** + * Called when a test ends + * @param {Object} test - Test case object + * @param {Object} result - Test result object containing status and other details + */ + onTestEnd(test, result) { + const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; + console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); + } + /** + * Called when all tests have finished + * @param result - Full test run result object containing status and duration + */ + async onEnd(result) { + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.info} Test Run Summary:`); + console.log(`Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`); + console.log(`Duration: ${result.duration}ms`); + console.log(`${this.styles.separator}\n`); + if (!(env_1.default.XRAY_CLIENT_ID || env_1.default.XRAY_CLIENT_SECRET)) { + console.log(`${this.styles.warning} No Xray client ID or secret found, skipping upload to JIRA Xray`); + return; + } + try { + console.log(`${this.styles.info} Reading test results file...`); + const testResults = node_fs_1.default.readFileSync('./test-results/test-results.xml', 'utf8'); + const token = await this.authenticateWithXray(); + await this.uploadTestResults(token, testResults); + console.log(`${this.styles.upload} Successfully uploaded test results to Xray`); + } + catch (error) { + console.error(`${this.styles.error} Failed to process test results:`, error); + } + console.log(`${this.styles.separator}\n`); + } +} +exports.default = XRayReporter; diff --git a/dist/endpoint-schema/auth-endpoints.d.ts b/dist/endpoint-schema/auth-endpoints.d.ts new file mode 100644 index 0000000..a130449 --- /dev/null +++ b/dist/endpoint-schema/auth-endpoints.d.ts @@ -0,0 +1,13 @@ +import { EndpointSchema } from './profile-endpoints'; +/** + * Schema for user authentication login + */ +export declare const loginSchema: EndpointSchema; +/** + * Schema for user logout + */ +export declare const logoutSchema: EndpointSchema; +/** + * Schema for token refresh + */ +export declare const refreshTokenSchema: EndpointSchema; diff --git a/dist/endpoint-schema/auth-endpoints.js b/dist/endpoint-schema/auth-endpoints.js new file mode 100644 index 0000000..8eff4cc --- /dev/null +++ b/dist/endpoint-schema/auth-endpoints.js @@ -0,0 +1,50 @@ +/** + * Schema for user authentication login + */ +export const loginSchema = { + url: /\/auth\/login$/, + method: 'POST', + expectedStatus: 200, + requestSchema: { + username: 'string', + password: 'string', + }, + responseSchema: { + userid: 'string', + username: 'string', + emails: 'object', + roles: 'object', + }, + validationFields: ['userid', 'username', 'emails', 'roles'], + requiredFields: [ + 'userid', // Auth endpoints require userid instead of fullName + 'username', // Username is also critical for auth + ], +}; +/** + * Schema for user logout + */ +export const logoutSchema = { + url: /\/auth\/logout$/, + method: 'POST', + expectedStatus: 200, + validationFields: [ + // Logout typically doesn't return data to validate + ], +}; +/** + * Schema for token refresh + */ +export const refreshTokenSchema = { + url: /\/auth\/token$/, + method: 'POST', + expectedStatus: 200, + responseSchema: { + userid: 'string', + username: 'string', + }, + validationFields: ['userid', 'username'], + requiredFields: [ + 'userid', // Token refresh must return userid + ], +}; diff --git a/dist/endpoint-schema/endpoint-registry.d.ts b/dist/endpoint-schema/endpoint-registry.d.ts new file mode 100644 index 0000000..f29522f --- /dev/null +++ b/dist/endpoint-schema/endpoint-registry.d.ts @@ -0,0 +1,34 @@ +import { EndpointSchema } from './profile-endpoints'; +/** + * Centralized endpoint registry for all API validation + * This allows network helpers to work with any endpoint by name + * + * ADDING NEW ENDPOINTS: + * 1. Define the endpoint schema in the appropriate *-endpoints.ts file + * 2. Include validationFields array for data consistency checking + * 3. Add the endpoint to this registry + * 4. The validationFields will automatically be used by NetworkHelper methods + * + * VALIDATION FIELDS: + * - Use dot notation for nested fields (e.g., 'patient.fullName') + * - Include all fields that should be validated for data consistency + * - Different endpoints can have different validation requirements + * - Fields are endpoint-specific and stored in the schema definition + */ +export declare const ENDPOINT_REGISTRY: { + readonly 'profile-metadata-get': EndpointSchema; + readonly 'profile-metadata-put': EndpointSchema; + readonly 'profile-patient-data-get': EndpointSchema; + readonly 'profile-metrics-get': EndpointSchema; + readonly 'profile-message-notes-get': EndpointSchema; + readonly 'patient-data-get': EndpointSchema; + readonly 'patient-data-upload': EndpointSchema; + readonly 'auth-login': EndpointSchema; + readonly 'auth-logout': EndpointSchema; + readonly 'auth-refresh-token': EndpointSchema; +}; +export type EndpointName = keyof typeof ENDPOINT_REGISTRY; +/** + * Get endpoint schema by name + */ +export declare function getEndpointSchema(endpointName: EndpointName): EndpointSchema; diff --git a/dist/endpoint-schema/endpoint-registry.js b/dist/endpoint-schema/endpoint-registry.js new file mode 100644 index 0000000..6e64934 --- /dev/null +++ b/dist/endpoint-schema/endpoint-registry.js @@ -0,0 +1,48 @@ +import { getProfileMetadataSchema, putProfileMetadataSchema, getPatientDataSchema as profileGetPatientDataSchema, getMetricsSchema as profileGetMetricsSchema, getMessageNotesSchema as profileGetMessageNotesSchema, } from './profile-endpoints'; +import { getPatientDataSchema, uploadPatientDataSchema } from './patient-data-endpoints'; +import { loginSchema, logoutSchema, refreshTokenSchema } from './auth-endpoints'; +// Import other endpoint schemas as they're created +/** + * Centralized endpoint registry for all API validation + * This allows network helpers to work with any endpoint by name + * + * ADDING NEW ENDPOINTS: + * 1. Define the endpoint schema in the appropriate *-endpoints.ts file + * 2. Include validationFields array for data consistency checking + * 3. Add the endpoint to this registry + * 4. The validationFields will automatically be used by NetworkHelper methods + * + * VALIDATION FIELDS: + * - Use dot notation for nested fields (e.g., 'patient.fullName') + * - Include all fields that should be validated for data consistency + * - Different endpoints can have different validation requirements + * - Fields are endpoint-specific and stored in the schema definition + */ +export const ENDPOINT_REGISTRY = { + // Profile endpoints + 'profile-metadata-get': getProfileMetadataSchema, + 'profile-metadata-put': putProfileMetadataSchema, + 'profile-patient-data-get': profileGetPatientDataSchema, + 'profile-metrics-get': profileGetMetricsSchema, + 'profile-message-notes-get': profileGetMessageNotesSchema, + // Patient data endpoints + 'patient-data-get': getPatientDataSchema, + 'patient-data-upload': uploadPatientDataSchema, + // Auth endpoints + 'auth-login': loginSchema, + 'auth-logout': logoutSchema, + 'auth-refresh-token': refreshTokenSchema, + // Add more endpoints as needed... + // 'clinic-get': clinicGetSchema, + // 'clinic-update': clinicUpdateSchema, +}; +/** + * Get endpoint schema by name + */ +export function getEndpointSchema(endpointName) { + const schema = ENDPOINT_REGISTRY[endpointName]; + if (!schema) { + throw new Error(`Endpoint schema not found: ${endpointName}`); + } + return schema; +} diff --git a/dist/endpoint-schema/patient-data-endpoints.d.ts b/dist/endpoint-schema/patient-data-endpoints.d.ts new file mode 100644 index 0000000..5562b5b --- /dev/null +++ b/dist/endpoint-schema/patient-data-endpoints.d.ts @@ -0,0 +1,13 @@ +import { EndpointSchema } from './profile-endpoints'; +/** + * Schema for patient data GET endpoint + */ +export declare const getPatientDataSchema: EndpointSchema; +/** + * Schema for uploading patient data + */ +export declare const uploadPatientDataSchema: EndpointSchema; +/** + * Schema for getting patient settings + */ +export declare const getPatientSettingsSchema: EndpointSchema; diff --git a/dist/endpoint-schema/patient-data-endpoints.js b/dist/endpoint-schema/patient-data-endpoints.js new file mode 100644 index 0000000..fa48d94 --- /dev/null +++ b/dist/endpoint-schema/patient-data-endpoints.js @@ -0,0 +1,53 @@ +/** + * Schema for patient data GET endpoint + */ +export const getPatientDataSchema = { + url: /\/v1\/patients\/[^/]+\/data$/, + method: 'GET', + expectedStatus: 200, + responseSchema: { + data: 'object', + meta: { + count: 'number', + size: 'number', + }, + }, + validationFields: ['data', 'meta.count', 'meta.size'], +}; +/** + * Schema for uploading patient data + */ +export const uploadPatientDataSchema = { + url: /\/v1\/patients\/[^/]+\/data$/, + method: 'POST', + expectedStatus: 201, + requestSchema: { + data: 'object', + deviceId: 'string', + uploadId: 'string', + }, + responseSchema: { + id: 'string', + success: 'boolean', + }, + validationFields: ['id', 'success'], +}; +/** + * Schema for getting patient settings + */ +export const getPatientSettingsSchema = { + url: /\/v1\/patients\/[^/]+\/settings$/, + method: 'GET', + expectedStatus: 200, + responseSchema: { + bgTarget: { + low: 'number', + high: 'number', + }, + units: { + bg: 'string', + }, + siteChangeSource: 'string', + }, + validationFields: ['bgTarget.low', 'bgTarget.high', 'units.bg', 'siteChangeSource'], +}; diff --git a/dist/endpoint-schema/profile-endpoints.d.ts b/dist/endpoint-schema/profile-endpoints.d.ts new file mode 100644 index 0000000..d1d3739 --- /dev/null +++ b/dist/endpoint-schema/profile-endpoints.d.ts @@ -0,0 +1,32 @@ +/** + * Schema definition for API endpoints + */ +export interface EndpointSchema { + url: string | RegExp; + method: string; + expectedStatus?: number; + responseSchema?: any; + requestSchema?: any; + validationFields?: string[]; + requiredFields?: string[]; +} +/** + * Schema for profile metadata GET endpoint + */ +export declare const getProfileMetadataSchema: EndpointSchema; +/** + * Schema for profile metadata PUT endpoint + */ +export declare const putProfileMetadataSchema: EndpointSchema; +/** + * Schema for patient data GET endpoint + */ +export declare const getPatientDataSchema: EndpointSchema; +/** + * Schema for metrics/analytics endpoint + */ +export declare const getMetricsSchema: EndpointSchema; +/** + * Schema for message notes endpoint + */ +export declare const getMessageNotesSchema: EndpointSchema; diff --git a/dist/endpoint-schema/profile-endpoints.js b/dist/endpoint-schema/profile-endpoints.js new file mode 100644 index 0000000..3e2101c --- /dev/null +++ b/dist/endpoint-schema/profile-endpoints.js @@ -0,0 +1,104 @@ +/** + * Schema for profile metadata GET endpoint + */ +export const getProfileMetadataSchema = { + url: /\/metadata\/.*\/profile$/, + method: 'GET', + expectedStatus: 200, + responseSchema: { + fullName: 'string', + patient: 'object', + }, + validationFields: [ + 'fullName', + 'patient.fullName', + 'patient.birthday', + 'patient.diagnosisDate', + 'patient.diagnosisType', + 'patient.targetDevices', + 'patient.targetTimezone', + 'patient.about', + 'patient.isOtherPerson', + 'patient.mrn', + 'patient.biologicalSex', + 'email', + 'patient.email', + 'patient.emails', + 'emails', + ], + requiredFields: [ + 'fullName', // Profile endpoint must have fullName + ], +}; +/** + * Schema for profile metadata PUT endpoint + */ +export const putProfileMetadataSchema = { + url: /\/metadata\/.*\/profile$/, + method: 'PUT', + expectedStatus: 200, + requestSchema: { + fullName: 'string', + patient: 'object', + }, + responseSchema: { + fullName: 'string', + patient: 'object', + }, + validationFields: [ + 'fullName', + 'patient.fullName', + 'patient.birthday', + 'patient.diagnosisDate', + 'patient.diagnosisType', + 'patient.targetDevices', + 'patient.targetTimezone', + 'patient.about', + 'patient.isOtherPerson', + 'patient.mrn', + 'patient.biologicalSex', + 'email', + 'patient.email', + 'patient.emails', + 'emails', + ], + requiredFields: [ + 'fullName', // Profile endpoint must have fullName + ], +}; +/** + * Schema for patient data GET endpoint + */ +export const getPatientDataSchema = { + url: /\/data\/[^/]+\?.*$/, + method: 'GET', + expectedStatus: 200, + responseSchema: { + // Patient data array - structure will vary + }, + validationFields: [ + // Data array validation fields would go here based on specific data types + ], +}; +/** + * Schema for metrics/analytics endpoint + */ +export const getMetricsSchema = { + url: /\/metrics\/thisuser\/.*$/, + method: 'GET', + expectedStatus: 200, + validationFields: [ + // Metrics-specific validation fields would go here + ], +}; +/** + * Schema for message notes endpoint + */ +export const getMessageNotesSchema = { + url: /\/message\/notes\/[^/]+\?.*$/, + method: 'GET', + expectedStatus: 200, // We'll handle 404 as acceptable in the validation logic + validationFields: [ + // Message notes validation fields would go here + ], +}; diff --git a/dist/page-objects/LoginPage.d.ts b/dist/page-objects/LoginPage.d.ts new file mode 100644 index 0000000..8a0e079 --- /dev/null +++ b/dist/page-objects/LoginPage.d.ts @@ -0,0 +1,32 @@ +import { Locator, Page } from '@playwright/test'; +/** + * @class + * @property {Page} page + * @property {Locator} emailInput + * @property {Locator} nextButton + * @property {Locator} passwordInput + * @property {Locator} loginButton + */ +export default class LoginPage { + page: Page; + emailInput: Locator; + nextButton: Locator; + passwordInput: Locator; + loginButton: Locator; + /** + * @param {Page} page + */ + constructor(page: Page); + /** + * Navigate to the login page + * @returns {Promise} + */ + goto(): Promise; + /** + * Login to the application + * @param {string} email - User's email + * @param {string} password - User's password + * @returns {Promise} + */ + login(email: string, password: string): Promise; +} diff --git a/dist/page-objects/LoginPage.js b/dist/page-objects/LoginPage.js new file mode 100644 index 0000000..0d3b7c3 --- /dev/null +++ b/dist/page-objects/LoginPage.js @@ -0,0 +1,41 @@ +/** + * @class + * @property {Page} page + * @property {Locator} emailInput + * @property {Locator} nextButton + * @property {Locator} passwordInput + * @property {Locator} loginButton + */ +export default class LoginPage { + /** + * @param {Page} page + */ + constructor(page) { + this.page = page; + this.emailInput = page.getByRole('textbox', { name: 'Email' }); + this.nextButton = page.getByRole('button', { name: 'Next' }); + this.passwordInput = page.getByRole('textbox', { name: 'Password' }); + this.loginButton = page.getByRole('button', { name: 'Log In' }); + } + /** + * Navigate to the login page + * @returns {Promise} + */ + async goto() { + await this.page.goto(`/`); + } + /** + * Login to the application + * @param {string} email - User's email + * @param {string} password - User's password + * @returns {Promise} + */ + // @step("When the user logs in to the application") + async login(email, password) { + await this.emailInput.fill(email); + await this.nextButton.click(); + await this.passwordInput.fill(password); + await this.loginButton.click(); + await this.page.setViewportSize({ width: 1920, height: 1080 }); + } +} diff --git a/dist/page-objects/account/AccountNavigation.d.ts b/dist/page-objects/account/AccountNavigation.d.ts new file mode 100644 index 0000000..eabf680 --- /dev/null +++ b/dist/page-objects/account/AccountNavigation.d.ts @@ -0,0 +1,18 @@ +import { Locator, Page } from '@playwright/test'; +export interface AccountNavVerify { + name: string; + link: Locator; + verifyURL: string; + verifyElement: Locator; + closeButton?: Locator; +} +export default class AccountNav { + readonly page: Page; + readonly pages: Record<'AccountNav' | 'PrivateWorkspace' | 'AccountSettings' | 'ManageWorkspaces' | 'Logout', AccountNavVerify>; + constructor(page: Page); + /** + * Navigate to a page in the account navigation menu by key. + * Example: await accountNav.navigateTo('AccountSettings'); + */ + navigateTo(pageKey: keyof AccountNav['pages']): Promise; +} diff --git a/dist/page-objects/account/AccountNavigation.js b/dist/page-objects/account/AccountNavigation.js new file mode 100644 index 0000000..ef4dfe6 --- /dev/null +++ b/dist/page-objects/account/AccountNavigation.js @@ -0,0 +1,59 @@ +export default class AccountNav { + constructor(page) { + this.page = page; + this.pages = { + AccountNav: { + name: 'AccountNav', + link: page.locator('button#navigation-menu-trigger'), // Use exact ID to identify menu trigger + verifyURL: '', + verifyElement: page + .locator('button.navigation-menu-option') + .filter({ hasText: 'Private Workspace' }), + }, + PrivateWorkspace: { + name: 'PrivateWorkspace', + link: page + .locator('button.navigation-menu-option') + .filter({ hasText: 'Private Workspace' }), + verifyURL: 'workspaces', + verifyElement: page.getByText('View data for:'), + }, + AccountSettings: { + name: 'AccountSettings', + link: page + .locator('#navigationMenu button.navigation-menu-option') + .filter({ hasText: 'Account Settings' }), + verifyURL: 'account', + verifyElement: page.locator('.profile-subnav-title').getByText('Account'), // Target the specific Account title element + }, + ManageWorkspaces: { + name: 'ManageWorkspaces', + link: page + .locator('#navigationMenu button.navigation-menu-option') + .filter({ hasText: 'Manage Workspaces' }), + verifyURL: 'workspaces', + verifyElement: page.getByText('Welcome To Tidepool'), // Should land back on the workspace selection page + }, + Logout: { + name: 'Logout', + link: page + .locator('#navigationMenu button.navigation-menu-option') + .filter({ hasText: 'Logout' }), + verifyURL: 'login', + verifyElement: page.getByRole('heading', { name: 'Log in to Tidepool' }), + }, + }; + } + /** + * Navigate to a page in the account navigation menu by key. + * Example: await accountNav.navigateTo('AccountSettings'); + */ + async navigateTo(pageKey) { + // Always open the navigation menu first + await this.pages.AccountNav.link.click(); + // Then click the desired page + await this.pages[pageKey].link.click(); + // Wait for the verification element to appear + await this.pages[pageKey].verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + } +} diff --git a/dist/page-objects/account/AccountSettingsPage.d.ts b/dist/page-objects/account/AccountSettingsPage.d.ts new file mode 100644 index 0000000..6250bf8 --- /dev/null +++ b/dist/page-objects/account/AccountSettingsPage.d.ts @@ -0,0 +1,9 @@ +import { Page, Locator } from '@playwright/test'; +export declare class AccountSettingsPage { + readonly page: Page; + readonly emailInput: Locator; + readonly saveButton: Locator; + readonly saveConfirm: Locator; + constructor(page: Page); +} +export default AccountSettingsPage; diff --git a/dist/page-objects/account/AccountSettingsPage.js b/dist/page-objects/account/AccountSettingsPage.js new file mode 100644 index 0000000..2247c70 --- /dev/null +++ b/dist/page-objects/account/AccountSettingsPage.js @@ -0,0 +1,9 @@ +export class AccountSettingsPage { + constructor(page) { + this.page = page; + this.emailInput = page.getByRole('textbox', { name: 'Email' }); + this.saveButton = page.getByRole('button', { name: /save/i }); + this.saveConfirm = page.getByText(/All Changes Saved/i); + } +} +export default AccountSettingsPage; diff --git a/dist/page-objects/clinician/ClinicCreationPage.d.ts b/dist/page-objects/clinician/ClinicCreationPage.d.ts new file mode 100644 index 0000000..b21595a --- /dev/null +++ b/dist/page-objects/clinician/ClinicCreationPage.d.ts @@ -0,0 +1,55 @@ +import { Locator, Page } from '@playwright/test'; +export default class ClinicCreationPage { + page: Page; + url: string; + pageHeader: Locator; + pageDescription: Locator; + clinicNameInput: Locator; + teamTypeDropdown: Locator; + countryDropdown: Locator; + stateDropdown: Locator; + addressInput: Locator; + cityInput: Locator; + zipCodeInput: Locator; + websiteInput: Locator; + mgdlRadio: Locator; + mmolRadio: Locator; + adminAcknowledgeCheckbox: Locator; + backButton: Locator; + createWorkspaceButton: Locator; + constructor(page: Page); + /** + * Navigate to the clinic creation page + */ + goto(): Promise; + /** + * Fill the clinic creation form with required information + * @param clinicName - Name of the clinic + * @param teamType - Type of the team + * @param state - State (for US clinics) + * @param address - Street address + * @param city - City name + * @param zipCode - Zip/Postal code + * @param website - Optional website URL + */ + fillClinicForm({ clinicName, teamType, state, address, city, zipCode, website, }: { + clinicName: string; + teamType?: string; + state?: string; + address?: string; + city?: string; + zipCode?: string; + website?: string; + }): Promise; + /** + * Select blood glucose units + * @param unit - "mg/dL" or "mmol/L" + */ + selectBloodGlucoseUnit(unit: 'mg/dL' | 'mmol/L'): Promise; + /** + * Create a clinic by filling the form and submitting + * @param clinicName - Name of the clinic to create (required) + * @param formData - Optional form data (uses defaults if not provided) + */ + createClinic(clinicName: string, formData?: Omit[0], 'clinicName'>): Promise; +} diff --git a/dist/page-objects/clinician/ClinicCreationPage.js b/dist/page-objects/clinician/ClinicCreationPage.js new file mode 100644 index 0000000..4a0a94f --- /dev/null +++ b/dist/page-objects/clinician/ClinicCreationPage.js @@ -0,0 +1,81 @@ +export default class ClinicCreationPage { + constructor(page) { + this.url = '/clinic-details/new'; + this.page = page; + // Page header elements + this.pageHeader = page.getByText('Create your Clinic Workspace'); + this.pageDescription = page.getByText('The information below will be displayed along with your name'); + // Form input fields + this.clinicNameInput = page.getByLabel('Clinic Name'); + this.teamTypeDropdown = page.getByRole('combobox', { name: 'What best describes your team?' }); + this.countryDropdown = page.getByRole('combobox', { name: 'Country' }); + this.stateDropdown = page.getByRole('combobox', { name: 'State' }); + this.addressInput = page.getByLabel('Address'); + this.cityInput = page.getByLabel('City'); + this.zipCodeInput = page.getByLabel('Zip code'); + this.websiteInput = page.getByLabel('Website (optional)'); + // Blood glucose units radio buttons + this.mgdlRadio = page.getByLabel('mg/dL'); + this.mmolRadio = page.getByLabel('mmol/L'); + // Acknowledgement checkbox + this.adminAcknowledgeCheckbox = page.getByRole('checkbox', { + name: 'By creating this clinic, your Tidepool account will become the default administrator', + }); + // Action buttons + this.backButton = page.getByRole('button', { name: 'Back' }); + this.createWorkspaceButton = page.getByRole('button', { name: 'Create Workspace' }); + } + /** + * Navigate to the clinic creation page + */ + async goto() { + await this.page.goto(this.url); + } + /** + * Fill the clinic creation form with required information + * @param clinicName - Name of the clinic + * @param teamType - Type of the team + * @param state - State (for US clinics) + * @param address - Street address + * @param city - City name + * @param zipCode - Zip/Postal code + * @param website - Optional website URL + */ + async fillClinicForm({ clinicName, teamType = 'Provider Practice', state = 'California', address = '123 Test Street', city = 'Test City', zipCode = '12345', website = '', }) { + // Fill in clinic name + await this.clinicNameInput.fill(clinicName); + // Select team type + await this.teamTypeDropdown.selectOption(teamType); + // Select state (US is selected by default) + await this.stateDropdown.selectOption(state); + // Fill in address details + await this.addressInput.fill(address); + await this.cityInput.fill(city); + await this.zipCodeInput.fill(zipCode); + // Fill in optional website if provided + if (website) { + await this.websiteInput.fill(website); + } + } + /** + * Select blood glucose units + * @param unit - "mg/dL" or "mmol/L" + */ + async selectBloodGlucoseUnit(unit) { + if (unit === 'mg/dL') { + await this.mgdlRadio.check(); + } + else { + await this.mmolRadio.check(); + } + } + /** + * Create a clinic by filling the form and submitting + * @param clinicName - Name of the clinic to create (required) + * @param formData - Optional form data (uses defaults if not provided) + */ + async createClinic(clinicName, formData) { + await this.fillClinicForm({ clinicName, ...formData }); + await this.createWorkspaceButton.click(); + } +} diff --git a/dist/page-objects/clinician/ClinicianDashboardPage.d.ts b/dist/page-objects/clinician/ClinicianDashboardPage.d.ts new file mode 100644 index 0000000..5f1113a --- /dev/null +++ b/dist/page-objects/clinician/ClinicianDashboardPage.d.ts @@ -0,0 +1,46 @@ +import { Locator, Page } from '@playwright/test'; +declare class ClinicianDashboardPage { + page: Page; + url: string; + name: string; + readonly addNewPatientButton: Locator; + readonly searchInput: Locator; + readonly patientListTable: Locator; + readonly addPatientDialog: Locator; + readonly addPatientDialog_fullNameInput: Locator; + readonly addPatientDialog_birthdateInput: Locator; + readonly addPatientDialog_addButton: Locator; + readonly bringDataDialog: Locator; + readonly bringDataDialog_doneButton: Locator; + constructor(page: Page); + /** + * Opens the Add Patient dialog and fills in the patient details. + * @param name - The full name of the patient. + * @param birthdate - The birthdate of the patient (e.g., MM/DD/YYYY). + */ + openAndFillAddPatientDialog(name: string, birthdate: string): Promise; + /** + * Clicks the Add Patient button in the dialog to submit the new patient. + */ + submitAddPatientDialog(): Promise; + /** + * Closes the Bring Data into Tidepool dialog by clicking Done. + */ + closeBringDataDialog(): Promise; + /** + * Searches for a patient in the list. + * @param name - The name of the patient to search for. + */ + searchForPatient(name: string): Promise; + /** + * Gets the locator for a patient cell in the table by name. + * @param name - The name of the patient. + * @returns Locator for the table cell containing the patient's name. + */ + getPatientCellByName(name: string): Locator; + /** + * Waits for the main elements of the Clinic Workspace page to be visible. + */ + waitForLoadState(): Promise; +} +export default ClinicianDashboardPage; diff --git a/dist/page-objects/clinician/ClinicianDashboardPage.js b/dist/page-objects/clinician/ClinicianDashboardPage.js new file mode 100644 index 0000000..558fd9b --- /dev/null +++ b/dist/page-objects/clinician/ClinicianDashboardPage.js @@ -0,0 +1,77 @@ +class ClinicianDashboardPage { + constructor(page) { + this.url = '/clinic-workspace'; + this.name = 'ClinicianDashboardPage'; // Added name for step decorator context + this.page = page; + // Main page locators + this.addNewPatientButton = page.getByRole('button', { name: 'Add New Patient' }); + this.searchInput = page.getByRole('textbox', { name: 'Search' }); + this.patientListTable = page.getByRole('table', { name: 'peopletablelabel' }); + // Add Patient Dialog locators + this.addPatientDialog = page.getByRole('dialog', { name: /Add New Patient Account/i }); + this.addPatientDialog_fullNameInput = this.addPatientDialog.getByRole('textbox', { + name: 'Full Name', + }); + this.addPatientDialog_birthdateInput = this.addPatientDialog.getByRole('textbox', { + name: 'Birthdate', + }); + this.addPatientDialog_addButton = this.addPatientDialog.getByRole('button', { + name: 'Add Patient', + }); + // Bring Data Dialog locators + this.bringDataDialog = page.getByRole('dialog', { name: /Bring Data into Tidepool/i }); + this.bringDataDialog_doneButton = this.bringDataDialog.getByRole('button', { name: 'Done' }); + } + /** + * Opens the Add Patient dialog and fills in the patient details. + * @param name - The full name of the patient. + * @param birthdate - The birthdate of the patient (e.g., MM/DD/YYYY). + */ + async openAndFillAddPatientDialog(name, birthdate) { + await this.addNewPatientButton.click(); + await this.addPatientDialog.waitFor({ state: 'visible' }); + await this.addPatientDialog_fullNameInput.fill(name); + await this.addPatientDialog_birthdateInput.fill(birthdate); + } + /** + * Clicks the Add Patient button in the dialog to submit the new patient. + */ + async submitAddPatientDialog() { + await this.addPatientDialog_addButton.click(); + } + /** + * Closes the Bring Data into Tidepool dialog by clicking Done. + */ + async closeBringDataDialog() { + await this.bringDataDialog.waitFor({ state: 'visible' }); + await this.bringDataDialog_doneButton.click(); + await this.bringDataDialog.waitFor({ state: 'hidden' }); + } + /** + * Searches for a patient in the list. + * @param name - The name of the patient to search for. + */ + async searchForPatient(name) { + await this.searchInput.fill(name); + // Press Enter to trigger search + await this.searchInput.press('Enter'); + // Wait longer for search to process and results to load + await this.page.waitForTimeout(3000); + } + /** + * Gets the locator for a patient cell in the table by name. + * @param name - The name of the patient. + * @returns Locator for the table cell containing the patient's name. + */ + getPatientCellByName(name) { + // Use exact match to avoid multiple matches with similar names + return this.patientListTable.getByRole('cell', { name, exact: true }); + } + /** + * Waits for the main elements of the Clinic Workspace page to be visible. + */ + async waitForLoadState() { + await this.addNewPatientButton.waitFor({ state: 'visible' }); + } +} +export default ClinicianDashboardPage; diff --git a/dist/page-objects/clinician/ClinicianNavigation.d.ts b/dist/page-objects/clinician/ClinicianNavigation.d.ts new file mode 100644 index 0000000..d3996f9 --- /dev/null +++ b/dist/page-objects/clinician/ClinicianNavigation.d.ts @@ -0,0 +1,20 @@ +import { Locator, Page } from '@playwright/test'; +export interface WorkspaceNavVerify { + name: string; + link: Locator; + verifyURL: string; + verifyElement: Locator; +} +export interface PageNavVerify { + name: string; + link: Locator; + verifyURL: string; + verifyElement: Locator; + closeButton?: Locator; +} +export default class ClinicianNav { + readonly page: Page; + readonly workspaces: Record<'AdminClinicBase' | 'AdminClinicEnterprise' | 'MemberClinicBase' | 'MemberClinicEnterprise' | 'NonMemberClinicBase' | 'NonMemberClinicEnterprise' | 'PartnerClinicBase' | 'PartnerClinicEnterprise', WorkspaceNavVerify>; + readonly pages: Record<'PatientList' | 'WorkspaceSettings' | 'AddPatient' | 'Profile' | 'ProfileEdit', PageNavVerify>; + constructor(page: Page); +} diff --git a/dist/page-objects/clinician/ClinicianNavigation.js b/dist/page-objects/clinician/ClinicianNavigation.js new file mode 100644 index 0000000..5a7502e --- /dev/null +++ b/dist/page-objects/clinician/ClinicianNavigation.js @@ -0,0 +1,116 @@ +export default class ClinicianNav { + constructor(page) { + this.page = page; + // Define hardcoded workspace configurations (matching PatientNavigation approach) + this.workspaces = { + AdminClinicBase: { + name: 'Admin Clinic (Base)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Admin Clinic (Base) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Admin Clinic (Base)' }), + }, + AdminClinicEnterprise: { + name: 'Admin Clinic (Enterprise)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Admin Clinic (Enterprise) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Admin Clinic (Enterprise)' }), + }, + MemberClinicBase: { + name: 'Member Clinic (Base)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Member Clinic (Base) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Member Clinic (Base)' }), + }, + MemberClinicEnterprise: { + name: 'Member Clinic (Enterprise)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Member Clinic (Enterprise) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Member Clinic (Enterprise)' }), + }, + NonMemberClinicBase: { + name: 'Non-Member Clinic (Base)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Non-Member Clinic (Base) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Non-Member Clinic (Base)' }), + }, + NonMemberClinicEnterprise: { + name: 'Non-Member Clinic (Enterprise)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Non-Member Clinic (Enterprise) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Non-Member Clinic (Enterprise)' }), + }, + PartnerClinicBase: { + name: 'Partner Clinic (Base)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Partner Clinic (Base) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Partner Clinic (Base)' }), + }, + PartnerClinicEnterprise: { + name: 'Partner Clinic (Enterprise)', + link: page + .locator('#navigationMenu button') + .filter({ hasText: 'Partner Clinic (Enterprise) Workspace' }), + verifyURL: 'clinic-workspace', + verifyElement: page.locator('h4').filter({ hasText: 'Partner Clinic (Enterprise)' }), + }, + }; + // Define clinician page navigation (matching PatientNavigation format) + this.pages = { + PatientList: { + name: 'PatientList', + link: page.getByRole('link', { name: 'Patients' }), + verifyURL: 'clinic-workspace/patients', + verifyElement: page.getByRole('heading', { name: 'Patients' }), + }, + WorkspaceSettings: { + name: 'WorkspaceSettings', + link: page.getByRole('link', { name: 'Workspace Settings' }), + verifyURL: 'clinic-workspace/workspace/settings', + verifyElement: page.getByRole('heading', { name: 'Workspace Settings' }), + }, + AddPatient: { + name: 'AddPatient', + link: page.getByRole('button', { name: 'Add Patient' }), + verifyURL: 'clinic-workspace/patients/add', + verifyElement: page.getByRole('heading', { name: 'Add Patient' }), + }, + Profile: { + name: 'Profile', + link: page + .getByRole('button', { name: 'Patient Profile Profile' }) + .or(page.getByRole('tab', { name: 'Profile' })) + .or(page.getByRole('link', { name: 'Profile' })) + .or(page.getByRole('button', { name: 'Profile' })), + verifyURL: 'profile', + verifyElement: page + .getByRole('button', { name: 'Edit' }) + .or(page.getByRole('button', { name: 'Edit Profile' })), + }, + ProfileEdit: { + name: 'ProfileEdit', + link: page + .getByRole('button', { name: 'Edit' }) + .or(page.getByRole('button', { name: 'Edit Profile' })), + verifyURL: 'profile', + verifyElement: page + .getByRole('button', { name: 'Save changes' }) + .or(page.getByRole('button', { name: 'Save Profile' })) + .or(page.getByRole('button', { name: 'Save' })), + }, + }; + } +} diff --git a/dist/page-objects/clinician/WorkspaceSettingsPage.d.ts b/dist/page-objects/clinician/WorkspaceSettingsPage.d.ts new file mode 100644 index 0000000..666bb9a --- /dev/null +++ b/dist/page-objects/clinician/WorkspaceSettingsPage.d.ts @@ -0,0 +1,18 @@ +import { Locator, Page } from '@playwright/test'; +export default class ClinicAdminPage { + readonly clinicDetailsHeader: Locator; + readonly editDetailsButton: Locator; + readonly editClinicModal: Locator; + readonly editClinicModalTitle: Locator; + readonly addressInput: Locator; + readonly saveChangesButton: Locator; + readonly clinicDetailsSection: Locator; + url: string; + name: string; + page: Page; + constructor(page: Page); + /** + * Waits for essential elements of the Clinic Admin page to be loaded. + */ + waitForLoadState(): Promise; +} diff --git a/dist/page-objects/clinician/WorkspaceSettingsPage.js b/dist/page-objects/clinician/WorkspaceSettingsPage.js new file mode 100644 index 0000000..aec2426 --- /dev/null +++ b/dist/page-objects/clinician/WorkspaceSettingsPage.js @@ -0,0 +1,26 @@ +export default class ClinicAdminPage { + constructor(page) { + this.url = '/clinic-admin'; + this.name = 'ClinicAdminPage'; // Added name for step decorator context + this.page = page; + this.clinicDetailsHeader = page.getByText('Workspace Settings'); + // Assuming the edit button is specifically associated with the details section + this.editDetailsButton = page.getByRole('button', { name: 'Edit' }); + this.editClinicModal = page.getByRole('dialog'); // General dialog selector + this.editClinicModalTitle = this.editClinicModal.getByRole('heading', { + name: 'Edit Workspace Details', + }); + this.addressInput = this.editClinicModal.getByLabel('Address', { exact: true }); // Use exact label match + this.saveChangesButton = this.editClinicModal.getByRole('button', { name: 'Save Changes' }); + // Assuming the details are within a specific container section related to the header + this.clinicDetailsSection = page.locator('div:has(> span:text-is("Workspace Settings")) + div'); + } + /** + * Waits for essential elements of the Clinic Admin page to be loaded. + */ + async waitForLoadState() { + await this.page.waitForLoadState(); // Wait for base elements like header/footer + await this.clinicDetailsHeader.waitFor({ state: 'visible', timeout: 40000 }); + await this.editDetailsButton.waitFor({ state: 'visible', timeout: 10000 }); + } +} diff --git a/dist/page-objects/clinician/WorkspacesPage.d.ts b/dist/page-objects/clinician/WorkspacesPage.d.ts new file mode 100644 index 0000000..44f2a64 --- /dev/null +++ b/dist/page-objects/clinician/WorkspacesPage.d.ts @@ -0,0 +1,16 @@ +import { Locator, Page } from '@playwright/test'; +export default class WorkspacesPage { + readonly page: Page; + readonly url: string; + readonly header: Locator; + readonly subHeader: Locator; + readonly createClinicButton: Locator; + constructor(page: Page); + goto(): Promise; + visitFirstClinic(): Promise; + /** + * Visit a clinic by name + * @param clinicName - The name of the clinic to visit + */ + visitClinic(clinicName: string): Promise; +} diff --git a/dist/page-objects/clinician/WorkspacesPage.js b/dist/page-objects/clinician/WorkspacesPage.js new file mode 100644 index 0000000..1c9cc60 --- /dev/null +++ b/dist/page-objects/clinician/WorkspacesPage.js @@ -0,0 +1,30 @@ +import env from '../../utilities/env'; +export default class WorkspacesPage { + constructor(page) { + this.url = `${env.BASE_URL}/workspaces`; + this.page = page; + this.header = page.getByRole('heading', { name: 'Clinic Workspace' }); + this.subHeader = page.getByRole('paragraph', { + name: 'View, share and manage patient data', + }); + this.createClinicButton = page.getByRole('button', { + name: 'Create a New Clinic', + }); + } + async goto() { + await this.page.goto(this.url); + } + async visitFirstClinic() { + await this.page.getByRole('button', { name: 'Go To Workspace' }).first().click(); + } + /** + * Visit a clinic by name + * @param clinicName - The name of the clinic to visit + */ + async visitClinic(clinicName) { + // find child element with text and filter by parent element with class + const child = this.page.getByText(clinicName); + const parent = this.page.locator('.workspace-item-clinic').filter({ has: child }); + await parent.getByRole('button', { name: 'Go To Workspace' }).first().click(); + } +} diff --git a/dist/page-objects/clinician/components/navigation-menu.section.d.ts b/dist/page-objects/clinician/components/navigation-menu.section.d.ts new file mode 100644 index 0000000..203acf6 --- /dev/null +++ b/dist/page-objects/clinician/components/navigation-menu.section.d.ts @@ -0,0 +1,16 @@ +import { Locator, Page } from '@playwright/test'; +export default class NavigationMenu { + page: Page; + container: Locator; + buttons: { + trigger: Locator; + menu: { + privateWorkspace: Locator; + accountSettings: Locator; + logout: Locator; + }; + }; + constructor(page: Page); + open(): Promise; + close(): Promise; +} diff --git a/dist/page-objects/clinician/components/navigation-menu.section.js b/dist/page-objects/clinician/components/navigation-menu.section.js new file mode 100644 index 0000000..c999acd --- /dev/null +++ b/dist/page-objects/clinician/components/navigation-menu.section.js @@ -0,0 +1,24 @@ +export default class NavigationMenu { + constructor(page) { + this.page = page; + this.container = page.locator('div#navigation-menu'); + this.buttons = { + trigger: this.container.locator('#navigation-menu-trigger'), + menu: { + privateWorkspace: this.container.getByRole('button', { + name: 'Private Workspace', + }), + accountSettings: this.container.getByRole('button', { + name: 'Account Settings', + }), + logout: this.container.getByRole('button', { name: 'Logout' }), + }, + }; + } + async open() { + await this.buttons.trigger.click(); + } + async close() { + await this.buttons.trigger.click(); + } +} diff --git a/dist/page-objects/clinician/components/navigation.section.d.ts b/dist/page-objects/clinician/components/navigation.section.d.ts new file mode 100644 index 0000000..eea6afb --- /dev/null +++ b/dist/page-objects/clinician/components/navigation.section.d.ts @@ -0,0 +1,14 @@ +import { Locator, Page } from '@playwright/test'; +import NavigationMenu from './navigation-menu.section'; +export default class NavigationSection { + page: Page; + container: Locator; + menu: NavigationMenu; + buttons: { + viewData: Locator; + patientProfile: Locator; + share: Locator; + uploadData: Locator; + }; + constructor(page: Page); +} diff --git a/dist/page-objects/clinician/components/navigation.section.js b/dist/page-objects/clinician/components/navigation.section.js new file mode 100644 index 0000000..e75d2a6 --- /dev/null +++ b/dist/page-objects/clinician/components/navigation.section.js @@ -0,0 +1,16 @@ +import NavigationMenu from './navigation-menu.section'; +export default class NavigationSection { + constructor(page) { + this.page = page; + this.container = page.locator('div#navPatientHeader'); + this.menu = new NavigationMenu(page); + this.buttons = { + viewData: this.container.getByRole('button', { name: 'View Data' }), + patientProfile: this.container.getByRole('button', { + name: 'Patient Profile', + }), + share: this.container.getByRole('button', { name: 'Share' }), + uploadData: this.container.getByRole('button', { name: 'Upload Data' }), + }; + } +} diff --git a/dist/page-objects/patient/BasicsPage.d.ts b/dist/page-objects/patient/BasicsPage.d.ts new file mode 100644 index 0000000..009dbe7 --- /dev/null +++ b/dist/page-objects/patient/BasicsPage.d.ts @@ -0,0 +1,58 @@ +import { Locator, Page } from '@playwright/test'; +import PatientNav from '@pom/patient/PatientNavigation'; +import NavigationSection from '@components/navigation.section'; +interface CalendarSection { + container: Locator; + firstDayOfData: Locator; + calendarDayhover: { + el: Locator; + text(): Promise; + }; +} +interface Stat { + container: Locator; + header: Locator; + hoverBar: Locator; + hoverBarLabel: Locator; +} +interface StatsSidebar { + toggleContainer: Locator; + toggleTo(toState: 'BGM' | 'CGM'): Promise; + timeInRange: Stat; + readingsInRange: Stat; + averageGlucose: Stat; + totalInsulin: Stat; + carbs: Stat; + standardDev: Stat; + coefficientOfVariation: Stat; + sensorUsage: Stat; + glucoseManagementIndicator: Stat; + averageDailyDose: Stat; +} +interface TubingPrimeSection extends CalendarSection { + settings: Locator; + settingsOption: { + fillTubing: Locator; + fillCannula: Locator; + }; + tubingIcons: Locator; + cannulaIcons: Locator; + filledDay: Locator; +} +export default class PatientDataBasicsPage { + page: Page; + url: string; + emailInput: Locator; + navigationBar: NavigationSection; + navigationSubMenu: PatientNav; + headerBgReading: Locator; + headerBolusing: Locator; + statsSidebar: StatsSidebar; + bgReadingsSection: CalendarSection; + bolusingSection: CalendarSection; + tubingPrimeSection: TubingPrimeSection; + basalsSection: CalendarSection; + constructor(page: Page); + goto(): Promise; +} +export {}; diff --git a/dist/page-objects/patient/BasicsPage.js b/dist/page-objects/patient/BasicsPage.js new file mode 100644 index 0000000..067a865 --- /dev/null +++ b/dist/page-objects/patient/BasicsPage.js @@ -0,0 +1,138 @@ +var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) { + var useValue = arguments.length > 2; + for (var i = 0; i < initializers.length; i++) { + value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); + } + return useValue ? value : void 0; +}; +var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) { + function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; } + var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; + var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null; + var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); + var _, done = false; + for (var i = decorators.length - 1; i >= 0; i--) { + var context = {}; + for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; + for (var p in contextIn.access) context.access[p] = contextIn.access[p]; + context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); }; + var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context); + if (kind === "accessor") { + if (result === void 0) continue; + if (result === null || typeof result !== "object") throw new TypeError("Object expected"); + if (_ = accept(result.get)) descriptor.get = _; + if (_ = accept(result.set)) descriptor.set = _; + if (_ = accept(result.init)) initializers.unshift(_); + } + else if (_ = accept(result)) { + if (kind === "field") initializers.unshift(_); + else descriptor[key] = _; + } + } + if (target) Object.defineProperty(target, contextIn.name, descriptor); + done = true; +}; +import { step } from '@fixtures/base'; +import PatientNav from '@pom/patient/PatientNavigation'; +import NavigationSection from '@components/navigation.section'; +function createSection(page, selector) { + const parsedSelector = selector === 'tubing-primes' ? 'siteChanges' : selector; + const container = page.locator(`.Calendar-container-${parsedSelector}`); + return { + container, + firstDayOfData: container.locator(`.Calendar-day--${parsedSelector}.Calendar-day`).first(), + calendarDayhover: { + el: container.locator('.Calendar-day--HOVER'), + async text() { + return container.locator('.Calendar-day--HOVER').locator('.Calendar-weekday').textContent(); + }, + }, + }; +} +/** + * helper function to create a stat object with locators for the container, header, hoverBar, and hoverBarLabel + */ +function createStat(page, selector) { + const container = page.locator(`#Stat--${selector}`); + return { + container, + header: container.locator('[class^="Stat--chartTitleText"]'), + hoverBar: container.locator('.HoverBar'), + hoverBarLabel: container.locator('.HoverBarLabel'), + }; +} +// list of sections in the stats sidebar +const statsSideBarSection = [ + 'timeInRange', + 'readingsInRange', + 'averageGlucose', + 'totalInsulin', + 'carbs', + 'standardDev', + 'coefficientOfVariation', + 'sensorUsage', + 'glucoseManagementIndicator', + 'totalInsulin', + 'averageDailyDose', +]; +let PatientDataBasicsPage = (() => { + var _a; + let _instanceExtraInitializers = []; + let _goto_decorators; + return _a = class PatientDataBasicsPage { + constructor(page) { + this.page = __runInitializers(this, _instanceExtraInitializers); + this.page = page; + this.url = '/patients/data/basics'; + this.emailInput = page.getByRole('textbox', { name: 'Email' }); + this.navigationBar = new NavigationSection(page); + this.navigationSubMenu = new PatientNav(page); + this.headerBgReading = page.getByRole('heading', { name: 'BG readings' }); + this.headerBolusing = page.getByRole('heading', { name: 'Bolusing' }); + this.statsSidebar = { + toggleContainer: page.locator('.toggle-container'), + async toggleTo(toState) { + const activeToggleState = await page + .locator(".toggle-container span[class*='TwoOptionToggle--active']") + .innerText(); + if (activeToggleState === 'BGM' && toState === 'CGM') { + await this.toggleContainer.click(); + } + else if (activeToggleState === 'CGM' && toState === 'BGM') { + await this.toggleContainer.click(); + } + }, + ...Object.fromEntries(statsSideBarSection.map(stat => [stat, createStat(page, stat)])), + }; + // charts + this.bgReadingsSection = createSection(page, 'fingersticks'); + this.bolusingSection = createSection(page, 'boluses'); + this.tubingPrimeSection = { + ...createSection(page, 'tubing-primes'), + settings: page.locator('.SiteChangeSelector-option').first(), + settingsOption: { + fillTubing: page.getByLabel('Tubing Fill'), + fillCannula: page.getByLabel('Cannula Fill'), + }, + tubingIcons: page.locator('.Change--tubing').first(), + cannulaIcons: page.locator('.Change--cannula').first(), + filledDay: createSection(page, 'tubing-primes') + .container.locator('.Calendar-day') + .filter({ has: page.locator('.Change-daysSince-text') }) + .first(), + }; + this.basalsSection = createSection(page, 'basals'); + } + async goto() { + await this.page.goto(this.url); + } + }, + (() => { + const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0; + _goto_decorators = [step('Navigate to the basics page')]; + __esDecorate(_a, null, _goto_decorators, { kind: "method", name: "goto", static: false, private: false, access: { has: obj => "goto" in obj, get: obj => obj.goto }, metadata: _metadata }, null, _instanceExtraInitializers); + if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata }); + })(), + _a; +})(); +export default PatientDataBasicsPage; diff --git a/dist/page-objects/patient/DailyPage.d.ts b/dist/page-objects/patient/DailyPage.d.ts new file mode 100644 index 0000000..fd4c533 --- /dev/null +++ b/dist/page-objects/patient/DailyPage.d.ts @@ -0,0 +1,11 @@ +import { Page } from '@playwright/test'; +import DailyChartSection from '@components/daily-chart.js'; +import PatientNav from '@pom/patient/PatientNavigation.js'; +import NavigationSection from '@components/navigation.section.js'; +export default class PatientDataDailyPage { + page: Page; + navigationBar: NavigationSection; + navigationSubMenu: PatientNav; + dailyChart: DailyChartSection; + constructor(page: Page); +} diff --git a/dist/page-objects/patient/DailyPage.js b/dist/page-objects/patient/DailyPage.js new file mode 100644 index 0000000..01a824e --- /dev/null +++ b/dist/page-objects/patient/DailyPage.js @@ -0,0 +1,11 @@ +import DailyChartSection from '@components/daily-chart.js'; +import PatientNav from '@pom/patient/PatientNavigation.js'; +import NavigationSection from '@components/navigation.section.js'; +export default class PatientDataDailyPage { + constructor(page) { + this.page = page; + this.navigationBar = new NavigationSection(page); + this.navigationSubMenu = new PatientNav(page); + this.dailyChart = new DailyChartSection(page); + } +} diff --git a/dist/page-objects/patient/PatientNavigation.d.ts b/dist/page-objects/patient/PatientNavigation.d.ts new file mode 100644 index 0000000..6ee3791 --- /dev/null +++ b/dist/page-objects/patient/PatientNavigation.d.ts @@ -0,0 +1,13 @@ +import { Locator, Page } from '@playwright/test'; +export interface PageNavVerify { + name: string; + link: Locator; + verifyURL: string; + verifyElement: Locator; + closeButton?: Locator; +} +export default class PatientNav { + readonly page: Page; + readonly pages: Record<'ViewData' | 'Basics' | 'ChartDateRange' | 'Daily' | 'ChartDate' | 'BGLog' | 'Trends' | 'Devices' | 'Print' | 'Profile' | 'ProfileEdit' | 'Share' | 'ShareData' | 'UploadData', PageNavVerify>; + constructor(page: Page); +} diff --git a/dist/page-objects/patient/PatientNavigation.js b/dist/page-objects/patient/PatientNavigation.js new file mode 100644 index 0000000..0c536a5 --- /dev/null +++ b/dist/page-objects/patient/PatientNavigation.js @@ -0,0 +1,97 @@ +export default class PatientNav { + // currentDate: Locator; + constructor(page) { + this.page = page; + this.pages = { + ViewData: { + name: 'ViewData', + link: page.getByRole('button', { name: 'View Data View' }), + verifyURL: 'data', + verifyElement: page.locator('div.patient-data-subnav-inner'), + }, + Basics: { + name: 'Basics', + link: page.getByRole('link', { name: 'Basics' }), + verifyURL: 'data/basics', + verifyElement: page.locator('.js-basics.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + ChartDateRange: { + name: 'ChartDateRange', + link: page + .locator('button svg, .css-15vjjnj svg, [aria-label*="calendar"], [title*="calendar"]') + .first(), // Calendar icon in blue navigation bar + verifyURL: '', + verifyElement: page.locator('#printDateRangePickerInner').locator('*').first(), // Any content inside the dialog + closeButton: page.getByRole('button', { name: 'close dialog' }), + }, + Daily: { + name: 'Daily', + link: page.getByRole('link', { name: 'Daily' }), + verifyURL: 'data/daily', + verifyElement: page.locator('.js-daily.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + ChartDate: { + name: 'ChartDate', + link: page.locator('#tidelineLabel .css-15vjjnj svg'), // Using the same calendar icon selector + verifyURL: '', + verifyElement: page.getByRole('heading', { name: 'Chart Date' }), + closeButton: page.getByRole('button', { name: 'close dialog' }), + }, + BGLog: { + name: 'BGLog', + link: page.getByRole('link', { name: 'BG Log' }), + verifyURL: 'data/bglog', + verifyElement: page.locator('.js-bgLog.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + Trends: { + name: 'Trends', + link: page.getByRole('link', { name: 'Trends' }), + verifyURL: 'data/trends', + verifyElement: page.locator('.js-trends.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + Devices: { + name: 'Devices', + link: page.getByRole('link', { name: 'Devices' }), + verifyURL: 'data/devices', + verifyElement: page.locator('.js-settings.patient-data-subnav-tablink.patient-data-subnav-active'), + }, + Print: { + name: 'Print', + link: page.getByRole('link', { name: 'Print PDF report Print' }), // Print link from the snapshot + verifyURL: '', + verifyElement: page.getByRole('heading', { name: 'Print Report' }), // Assuming modal title + closeButton: page.getByRole('button', { name: 'close dialog' }), + }, + Profile: { + name: 'Profile', + link: page.getByRole('button', { name: 'Profile Profile' }), + verifyURL: '', + verifyElement: page.getByRole('button', { name: 'Edit' }), // Edit button is visible on profile page + }, + ProfileEdit: { + name: 'ProfileEdit', + link: page.getByRole('button', { name: 'Edit' }), + verifyURL: 'profile', + verifyElement: page.getByRole('button', { name: 'Save changes' }), // Save changes button appears when in edit mode + }, + Share: { + name: 'Share', + link: page.getByRole('button', { name: 'Share Share' }), + verifyURL: 'share', + verifyElement: page.getByRole('heading', { name: 'Access Management' }), + }, + ShareData: { + name: 'ShareData', + link: page.getByRole('button', { name: 'Share Data' }), + verifyURL: 'share/invite', + verifyElement: page.getByRole('heading', { name: 'Share your data' }), + }, + UploadData: { + name: 'UploadData', + link: page.getByRole('button', { name: 'Upload Data Upload' }), + verifyURL: 'upload', + verifyElement: page.getByRole('heading', { name: 'Upload Data' }), + }, + }; + } +} diff --git a/dist/page-objects/patient/ProfilePage.d.ts b/dist/page-objects/patient/ProfilePage.d.ts new file mode 100644 index 0000000..f37a6f7 --- /dev/null +++ b/dist/page-objects/patient/ProfilePage.d.ts @@ -0,0 +1,22 @@ +import { Page } from '@playwright/test'; +export declare class ProfilePage { + readonly page: Page; + private fieldLocators; + constructor(page: Page); + fillField(field: keyof typeof this.fieldLocators, value: string): Promise; + selectDiagnosisType(index: number): Promise; + getCurrentDiagnosisIndex(): Promise; + fillFullName(name: string): Promise; + fillBirthDate(date: string): Promise; + fillMRN(mrn: string): Promise; + fillDiagnosisDate(date: string): Promise; + fillClinicalNotes(notes: string): Promise; + fillEmail(email: string): Promise; + saveProfile(): Promise; + /** + * Checks if the edit button is displayed and validates against expected state + * @param shouldBeVisible - Boolean indicating whether the edit button should be visible + * @throws Error if the actual visibility doesn't match the expected state + */ + editButtonDisplays(shouldBeVisible: boolean): Promise; +} diff --git a/dist/page-objects/patient/ProfilePage.js b/dist/page-objects/patient/ProfilePage.js new file mode 100644 index 0000000..87a80b0 --- /dev/null +++ b/dist/page-objects/patient/ProfilePage.js @@ -0,0 +1,111 @@ +export class ProfilePage { + constructor(page) { + this.page = page; + this.fieldLocators = { + fullName: this.page.getByRole('textbox', { name: 'Full name' }), + birthDate: this.page.getByRole('textbox', { name: 'Date of birth' }), + mrn: this.page.getByRole('textbox', { name: 'MRN' }), + diagnosisDate: this.page.getByRole('textbox', { name: 'Date of diagnosis' }), + clinicalNotes: this.page.getByRole('textbox', { name: 'Anything you would like to share' }), + email: this.page.getByRole('textbox', { name: /email/i }), + }; + } + // Generic fill method for text fields + async fillField(field, value) { + const locator = this.fieldLocators[field]; + if (!locator) + throw new Error(`No locator defined for field: ${field}`); + if (await locator.isVisible({ timeout: 3000 }).catch(() => false)) { + await locator.fill(value); + } + else { + throw new Error(`Field '${field}' not found or not visible`); + } + } + // Select a diagnosis type from the dropdown + async selectDiagnosisType(index) { + const diagnosisCombo = this.page.getByRole('combobox', { name: 'Diagnosed as' }); + if (await diagnosisCombo.isVisible({ timeout: 3000 })) { + await diagnosisCombo.selectOption({ index }); + } + } + // Get the current diagnosis index from the dropdown (needed for setting a new diagnosis) + async getCurrentDiagnosisIndex() { + const diagnosisCombo = this.page.getByRole('combobox', { name: 'Diagnosed as' }); + if (await diagnosisCombo.isVisible({ timeout: 3000 })) { + const currentValue = await diagnosisCombo.inputValue(); + const options = await diagnosisCombo.locator('option').all(); + // Find current index by checking option values + for (let i = 0; i < options.length; i++) { + const optionValue = await options[i].getAttribute('value'); + if (optionValue === currentValue) { + return i; + } + } + } + return 1; // Default to 1 if not found + } + // For backwards compatibility, keep these as wrappers (optional) + async fillFullName(name) { + return this.fillField('fullName', name); + } + async fillBirthDate(date) { + return this.fillField('birthDate', date); + } + async fillMRN(mrn) { + return this.fillField('mrn', mrn); + } + async fillDiagnosisDate(date) { + return this.fillField('diagnosisDate', date); + } + async fillClinicalNotes(notes) { + return this.fillField('clinicalNotes', notes); + } + async fillEmail(email) { + return this.fillField('email', email); + } + async saveProfile() { + // Save button locators + const saveButtons = [ + this.page.getByRole('button', { name: 'Save changes' }), + this.page.getByRole('button', { name: 'Save Profile' }), + this.page.getByRole('button', { name: 'Save' }), + ]; + // Wait for the PUT request to complete after clicking save + const saveProfilePromise = this.page.waitForResponse(response => response.url().includes('/metadata/') && + response.url().includes('/profile') && + response.request().method() === 'PUT'); + let clicked = false; + for (const btn of saveButtons) { + if (await btn.isVisible({ timeout: 5000 }).catch(() => false)) { + await btn.click(); + clicked = true; + break; + } + } + if (!clicked) + throw new Error('No save button found'); + // Wait for the PUT request to complete (with timeout) + try { + await saveProfilePromise; + } + catch (error) { + console.log('āš ļø PUT request timeout - continuing anyway'); + } + } + /** + * Checks if the edit button is displayed and validates against expected state + * @param shouldBeVisible - Boolean indicating whether the edit button should be visible + * @throws Error if the actual visibility doesn't match the expected state + */ + async editButtonDisplays(shouldBeVisible) { + const editButton = this.page.getByRole('button', { name: 'Edit' }); + const isEditButtonVisible = await editButton.isVisible({ timeout: 3000 }).catch(() => false); + if (shouldBeVisible && !isEditButtonVisible) { + throw new Error('Edit button should be visible but was not found'); + } + else if (!shouldBeVisible && isEditButtonVisible) { + throw new Error('Edit button should not be visible for this user - security violation!'); + } + } +} diff --git a/dist/page-objects/patient/components/daily-chart.d.ts b/dist/page-objects/patient/components/daily-chart.d.ts new file mode 100644 index 0000000..6e7de56 --- /dev/null +++ b/dist/page-objects/patient/components/daily-chart.d.ts @@ -0,0 +1,11 @@ +import { Locator, Page } from '@playwright/test'; +export default class DailyChartSection { + page: Page; + container: Locator; + dayLabel: Locator; + newNote: Locator; + buttons: { + refresh: Locator; + }; + constructor(page: Page); +} diff --git a/dist/page-objects/patient/components/daily-chart.js b/dist/page-objects/patient/components/daily-chart.js new file mode 100644 index 0000000..51c4f46 --- /dev/null +++ b/dist/page-objects/patient/components/daily-chart.js @@ -0,0 +1,11 @@ +export default class DailyChartSection { + constructor(page) { + this.page = page; + this.container = page.locator('div.patient-data-content'); + this.dayLabel = this.container.locator('text.d3-day-label').filter({ visible: true }); + this.newNote = this.container.locator('image.newNoteIcon'); + this.buttons = { + refresh: this.container.getByRole('button', { name: 'Refresh' }), + }; + } +} diff --git a/dist/playwright.config.d.ts b/dist/playwright.config.d.ts new file mode 100644 index 0000000..9c39b85 --- /dev/null +++ b/dist/playwright.config.d.ts @@ -0,0 +1,2 @@ +declare const _default: import("@playwright/test").PlaywrightTestConfig<{}, {}>; +export default _default; diff --git a/dist/playwright.config.js b/dist/playwright.config.js new file mode 100644 index 0000000..647a368 --- /dev/null +++ b/dist/playwright.config.js @@ -0,0 +1,108 @@ +import { defineConfig, devices } from '@playwright/test'; +import path from 'node:path'; +import env from './utilities/env'; +const xrayOptions = { + embedAnnotationsAsProperties: true, + textContentAnnotations: ['test_description', 'testrun_comment'], + embedAttachmentsAsProperty: 'testrun_evidence', + outputFile: 'test-output/test-results.xml', +}; +// Helper to detect BrowserStack run +const isBrowserStack = Boolean(process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESS_KEY); +function buildBrowserStackEndpoint(testName) { + const caps = { + browser: 'chrome', + browser_version: 'latest', + os: 'os x', + os_version: 'catalina', + name: testName, + build: process.env.CI_BUILD_NUMBER || 'local-run', + 'browserstack.username': process.env.BROWSERSTACK_USERNAME, + 'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY, + }; + return `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify(caps))}`; +} +export default defineConfig({ + testDir: './tests', + outputDir: './test-results', // Custom output directory + globalSetup: require.resolve(path.join(__dirname, 'tests/global-setup')), + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + timeout: 60000, + expect: { + toHaveScreenshot: { maxDiffPixelRatio: 0.2 }, + }, + reporter: [ + ['html', { open: 'never', outputFolder: 'playwright-report' }], + ['json', { outputFile: 'test-results/last-run.json' }], + ['junit', xrayOptions], + ['./utilities/xray-json-reporter.ts'], + ], + use: { + baseURL: env.BASE_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + // Custom test attachment naming + testIdAttribute: 'data-testid', + }, + projects: [ + { + name: 'chromium-personal', + testMatch: '**/personal/**/*.spec.ts', + use: { + ...devices['Desktop Chrome'], + storageState: 'tests/.auth/personal.json', + headless: false, + }, + }, + { + name: 'chromium-claimed', + testMatch: '**/claimed/**/*.spec.ts', + use: { + ...devices['Desktop Chrome'], + storageState: 'tests/.auth/claimed.json', + headless: false, + }, + }, + { + name: 'chromium-clinician', + testMatch: '**/clinician/**/*.spec.ts', + use: { + ...devices['Desktop Chrome'], + storageState: 'tests/.auth/clinician.json', + headless: false, + }, + }, + ...(isBrowserStack + ? [ + { + name: 'bs-chrome-personal', + testMatch: '**/patient/**/*.spec.ts', + use: { + storageState: 'tests/.auth/personal.json', + connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Personal Patient Tests') }, + }, + }, + { + name: 'bs-chrome-claimed', + testMatch: '**/claimed/**/*.spec.ts', + use: { + storageState: 'tests/.auth/claimed.json', + connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Claimed Patient Tests') }, + }, + }, + { + name: 'bs-chrome-clinician', + testMatch: '**/clinician/**/*.spec.ts', + use: { + storageState: 'tests/.auth/clinician.json', + connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Clinician Tests') }, + }, + }, + ] + : []), + ], +}); diff --git a/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.d.ts b/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js b/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js new file mode 100644 index 0000000..e95b07d --- /dev/null +++ b/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js @@ -0,0 +1,146 @@ +import { test } from '../../fixtures/base'; +import { test as patientTest } from '../../fixtures/patient-helpers'; +import { test as accountTest } from '../../fixtures/account-helpers'; +import { test as clinicTest } from '../../fixtures/clinic-helpers'; +import { createNetworkHelper } from '../../fixtures/network-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { AccountSettingsPage } from '../../../page-objects/account/AccountSettingsPage'; +import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; +const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; +const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; +test.describe('Claimed Account Settings edit (Full Name only) updates Profile endpoint and visually updates for user, clinic, and shared member', () => { + test.setTimeout(120000); // 2 minute timeout for multi-phase test + let api; + let putCapture; + let newName; // Declare at test level scope + test('should allow navigation to account settings, edit full name, and verify profile update for claimed, shared, and clinician users', { + tag: createValidatedTags([ + TEST_TAGS.PATIENT, + TEST_TAGS.CLINICIAN, // Added clinician tag + TEST_TAGS.CLAIMED, + TEST_TAGS.SHARED_MEMBER, // Added shared member tag + TEST_TAGS.API, + TEST_TAGS.UI, + TEST_TAGS.HIGH, + TEST_TAGS.API_PROFILE, + ]), + }, async ({ page }) => { + // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== + // Step 1: Log in to clinician account and setup network capture + await test.step('Given claimed account has been logged in', async () => { + api = createNetworkHelper(page); + await api.startCapture(); + await page.goto('/data'); + await patientTest.patient.setup(page); + }); + // Step 2: Navigate to account settings + await test.step('When user navigates to account settings', async () => { + await accountTest.account.navigateTo('AccountSettings', page); + }); + // Step 3: GET response is pulled and validated + await test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Create new acccount settings page for the following test + const accountSettingsPage = new AccountSettingsPage(page); + // Step 4: Change the Full Name field to a new value + await test.step('When user updates the Full Name field', async () => { + newName = `Claimed User Updated ${Math.floor(Math.random() * 10000)}`; // Remove let declaration + const nameInput = page.getByRole('textbox', { name: /full name/i }); + await nameInput.fill(newName); + }); + // Step 5: Tap the Save button + await test.step('When user taps the save button', async () => { + await accountSettingsPage.saveButton.click(); + }); + // Step 6: Confirm save changes message displays + await test.step('Then the save changes message displays', async () => { + await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); + }); + // Step 7: Validate PUT request and save value + await test.stepNoScreenshot('Then PUT request is validated and name is set to new value', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + putCapture = api + .getCaptures() + .find((req) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) + throw new Error('No PUT /profile request captured'); + if (!putCapture.requestBody || + !putCapture.requestBody.fullName || + putCapture.requestBody.fullName !== newName) { + throw new Error(`PUT request did not set fullName to ${newName}`); + } + }); + // Step 8: Navigate to Profile page + await test.step('When user navigates to Profile page', async () => { + await patientTest.patient.navigateTo('Profile', page); + }); + // Step 9: Confirm GET request matches the saved PUT request + await test.stepNoScreenshot('Then GET request matches the saved PUT request', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + // Get all captures and find the LATEST GET request (after the PUT) + const allCaptures = api.getCaptures(); + const putIndex = allCaptures.findIndex(req => req === putCapture); + // Find GET requests that occurred AFTER the PUT request + const laterGetCaptures = allCaptures + .slice(putIndex + 1) + .filter((req) => req.method === 'GET' && req.url.includes('/profile')); + if (laterGetCaptures.length === 0) { + throw new Error('No GET /profile request captured after the PUT request'); + } + // Use the most recent GET request + const getCapture = laterGetCaptures[laterGetCaptures.length - 1]; + if (!getCapture.responseBody || + getCapture.responseBody.fullName !== putCapture.requestBody.fullName) { + console.log('GET response fullName:', getCapture.responseBody.fullName); + console.log('PUT request fullName:', putCapture.requestBody.fullName); + console.log('Total captures:', allCaptures.length); + console.log('PUT index:', putIndex); + console.log('Later GET captures found:', laterGetCaptures.length); + throw new Error('GET response fullName does not match PUT request fullName'); + } + }); + // ========== PHASE 2: SHARED USER VIEWS PROFILE ========== + // Step 10: Switch to shared user authentication and go directly to Profile + await test.step('When shared user views claimed user profile', async () => { + await accountTest.account.switchUser('shared', page); + await page.goto('/data'); + await patientTest.patient.setup(page); + // Wait a moment for the page to stabilize after user switch + await page.waitForTimeout(500); + // Navigate directly to Profile in the same step to avoid redundancy + await patientTest.patient.navigateTo('Profile', page); + }); + // Step 11: Verify Edit button is not present for shared users + await test.step('Then Edit button should not be present for shared patients', async () => { + const profilePage = new ProfilePage(page); + await profilePage.editButtonDisplays(false); + }); + // Step 12: Validate shared user sees updated profile data + await test.stepNoScreenshot('Then shared user sees view-only claimed profile data with matching data', async () => { + await api.compareEndpointResponse('profile-metadata-get', putCapture); + }); + // ========== PHASE 3: CLINICIAN VIEWS PROFILE ========== + // Step 13: Switch to clinician user authentication + await test.step('When clinician accesses patient workspace', async () => { + await accountTest.account.switchUser('clinician', page); + await page.goto('/'); + await clinicTest.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); + }); + // Step 14: Access the specific claimed patient that was modified by the producer test + await test.step('When user accesses the claimed patient modified by producer test', async () => { + await clinicTest.clinician.findAndAccessPatientByPartialName(CLAIMED_PATIENT_SEARCH, page); + // Navigate directly to Profile in the same step to avoid redundancy + await clinicTest.clinician.navigateTo('Profile', page); + }); + // Step 15: Verify Edit button is not present for claimed patients viewed by clinicians + await test.step('Then Edit button should not be present for claimed patients', async () => { + const profilePage = new ProfilePage(page); + await profilePage.editButtonDisplays(false); + }); + // Step 16: Validate clinician sees updated profile data + await test.stepNoScreenshot('Then clinician sees claimed profile data with matching data and no save access', async () => { + await api.compareEndpointResponse('profile-metadata-get', putCapture); + }); + }); +}); diff --git a/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.d.ts b/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js b/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js new file mode 100644 index 0000000..47da045 --- /dev/null +++ b/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js @@ -0,0 +1,124 @@ +import { test } from '../../fixtures/base'; +import { test as patientTest } from '../../fixtures/patient-helpers'; +import { test as clinicTest } from '../../fixtures/clinic-helpers'; +import { test as accountTest } from '../../fixtures/account-helpers'; +import { createNetworkHelper } from '../../fixtures/network-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; +const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; +const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; +test.describe('Comprehensive Profile Access Test: Edit as Claimed, View as Shared and Clinician', () => { + test('should edit claimed profile then verify view-only access for shared and clinician users', { + tag: createValidatedTags([ + TEST_TAGS.PATIENT, // User Type (required) + TEST_TAGS.CLINICIAN, // User Type (required) + TEST_TAGS.CLAIMED, + TEST_TAGS.SHARED_MEMBER, + TEST_TAGS.API, // Test Type (required) + TEST_TAGS.UI, // Test Type (required) + TEST_TAGS.HIGH, // Priority (required) + TEST_TAGS.API_PROFILE, // Feature (optional) + ]), + }, async ({ page }) => { + let api; + let producerPutCapture; + // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== + // Step 1: Claimed account has been logged in + await test.step('Given claimed account has been logged in', async () => { + api = createNetworkHelper(page); + await api.startCapture(); + await page.goto('/data'); + await patientTest.patient.setup(page); + }); + // Step 2: User navigates to Profile page + await test.step('When user navigates to Profile page', async () => { + await patientTest.patient.navigateTo('Profile', page); + }); + // Step 3: GET response is pulled and validated + await test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Step 4: Confirm edit button and click it + await test.step('When user selects Edit button', async () => { + await patientTest.patient.navigateTo('ProfileEdit', page); + }); + // Initialize ProfilePage for steps 4 and 5 + const profilePage = new ProfilePage(page); + // Step 5: Change profile fields (confirmed user access) + await test.step('When user updates profile fields', async () => { + const testRunId = Math.floor(Math.random() * 10000); + const updatedName = `Claimed User Updated ${testRunId}`; + const birthYear = 1985 + (testRunId % 10); + const diagnosisYear = birthYear + 20; + const birthDate = `01/15/${birthYear}`; + const diagnosisDate = `03/10/${diagnosisYear}`; + // Generate random 15-letter string for clinical notes + const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); + // Get current diagnosis index and calculate next one (1-7, wrapping) + const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); + let nextDiagnosisIndex = currentDiagnosisIndex + 1; + if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { + nextDiagnosisIndex = 1; + } + // Update fields using ProfilePage methods + await profilePage.fillFullName(updatedName); + await profilePage.fillBirthDate(birthDate); + await profilePage.fillDiagnosisDate(diagnosisDate); + await profilePage.selectDiagnosisType(nextDiagnosisIndex); + await profilePage.fillClinicalNotes(randomString); + }); + // Step 6: Save profile edit + await test.step('When user saves profile changes', async () => { + await profilePage.saveProfile(); + }); + // Step 7: PUT response is validated and saved for comparison + await test.stepNoScreenshot('Then profile endpoint responds with PUT request consistent with schema', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putSchema = await import('../../../endpoint-schema/profile-endpoints'); + const schema = putSchema.putProfileMetadataSchema; + producerPutCapture = api.getLatestCaptureMatching(schema.method, schema.url); + }); + //= ========= SHARED MEMEBER VIEWS PROFILE ========== + // Step 8: Switch to shared user authentication + await test.step('When shared user views claimed user profile', async () => { + await accountTest.account.switchUser('shared', page); + await page.goto('/data'); + await patientTest.patient.navigateTo('ViewData', page); + }); + // Step 9: Navigate to profile page + await test.step('When user navigates to Profile page', async () => { + await patientTest.patient.navigateTo('Profile', page); + }); + // Step 10: Confirm edit button is not present + await test.step('Then Edit button should not be present for shared patients', async () => { + await profilePage.editButtonDisplays(false); + }); + // Step 11: Validate GET response and compare it against the + await test.stepNoScreenshot('Then shared user sees view-only claimed profile data with matching data', async () => { + await api.compareEndpointResponse('profile-metadata-get', producerPutCapture); + }); + // ========== CLINICIAN VIEWS PROFILE ========== + // Step 12: Switch to clinician authentication and navigate to patient profile + await test.step('When clinician accesses patient workspace', async () => { + await accountTest.account.switchUser('clinician', page); + await page.goto('/'); + await clinicTest.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); + }); + // Step 13: Access the specific claimed patient that was modified by the producer test + await test.step('When user accesses the claimed patient modified by producer test', async () => { + await clinicTest.clinician.findAndAccessPatientByPartialName(CLAIMED_PATIENT_SEARCH, page); + }); + // Step 14: Navigate to profile + await test.step('When user navigates to Profile page', async () => { + await clinicTest.clinician.navigateTo('Profile', page); + }); + // Step 15: Confirm edit button is not present + await test.step('Then Edit button should not be present for claimed patients', async () => { + await profilePage.editButtonDisplays(false); + }); + // Step 16: Validate GET response and confirm appropriate permissions + await test.stepNoScreenshot('Then clinician sees claimed profile data with matching data and no save access', async () => { + await api.compareEndpointResponse('profile-metadata-get', producerPutCapture); + }); + }); +}); diff --git a/dist/tests/claimed/API-User/claimed-email-edit.spec.d.ts b/dist/tests/claimed/API-User/claimed-email-edit.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/claimed/API-User/claimed-email-edit.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/claimed/API-User/claimed-email-edit.spec.js b/dist/tests/claimed/API-User/claimed-email-edit.spec.js new file mode 100644 index 0000000..4b8ec83 --- /dev/null +++ b/dist/tests/claimed/API-User/claimed-email-edit.spec.js @@ -0,0 +1,93 @@ +import { test } from '../../fixtures/base'; +import { test as patientTest } from '../../fixtures/patient-helpers'; +import { test as accountTest } from '../../fixtures/account-helpers'; +import { createNetworkHelper } from '../../fixtures/network-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { AccountSettingsPage } from '../../../page-objects/account/AccountSettingsPage'; +test.describe('Clinician Account Settings Access', () => { + // API Test cases require this to capture network activity + let api; + test('should allow navigation to account settings and capture GET response', { + tag: createValidatedTags([ + TEST_TAGS.PATIENT, + TEST_TAGS.CLAIMED, + TEST_TAGS.API, + TEST_TAGS.UI, + TEST_TAGS.HIGH, + TEST_TAGS.API_USER, + ]), + }, async ({ page }) => { + // Step 1: Log in to clinician account and setup network capture + await test.step('Given clinician has been logged in', async () => { + api = createNetworkHelper(page); + await api.startCapture(); + await page.goto('/data'); + await patientTest.patient.setup(page); + }); + // Step 2: Navigate to account settings + await test.step('When user navigates to account settings', async () => { + await accountTest.account.navigateTo('AccountSettings', page); + }); + // Step 3: Validate profile GET response + await test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema ', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Setup for Account Settings page and previous email for reset + const accountSettingsPage = new AccountSettingsPage(page); + let originalEmail = ''; + // Step 4: Read and change email field to temporary value + await test.step('When user updates the email field', async () => { + originalEmail = await accountSettingsPage.emailInput.inputValue(); + await accountSettingsPage.emailInput.fill('qa+TempEdit@tidepool.org'); + }); + // Step 5: Tap the save button + await test.step('When user taps the save button', async () => { + await accountSettingsPage.saveButton.click(); + }); + // Step 6: Confirm save changes message displays + await test.step('Then the save changes message displays', async () => { + await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); + }); + // Step 7: Validate PUT request and email value + await test.stepNoScreenshot('Then PUT request is validated and email is set to new value', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putCapture = api + .getCaptures() + .find((req) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) + throw new Error('No PUT /profile request captured'); + if (!putCapture.requestBody || + !putCapture.requestBody.email || + putCapture.requestBody.email !== 'qa+TempEdit@tidepool.org') { + throw new Error('PUT request did not set email to qa+TempEdit@tidepool.org'); + } + }); + // Step 8: Change email field to temporary value + await test.step('When user sets the email field to the previous value', async () => { + await accountSettingsPage.emailInput.fill(originalEmail); + }); + // Step 9: Tap the save button + await test.step('When user taps the save button', async () => { + await accountSettingsPage.saveButton.click(); + }); + // Step 10: Confirm save changes message displays + await test.step('Then the save changes message displays', async () => { + await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); + }); + // Step 7: Validate PUT request and email value + await test.stepNoScreenshot('Then PUT request is validated and email is set to new value', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putCapture = api + .getCaptures() + .find((req) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) + throw new Error('No PUT /profile request captured'); + if (!putCapture.requestBody || + !putCapture.requestBody.email || + putCapture.requestBody.email !== originalEmail) { + throw new Error('PUT request did not set email to originalEmail'); + } + }); + await api.stopCapture(); + }); +}); diff --git a/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.d.ts b/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js b/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js new file mode 100644 index 0000000..5285fee --- /dev/null +++ b/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js @@ -0,0 +1,89 @@ +import { test } from '../../fixtures/clinic-helpers'; +import { createNetworkHelper } from '../../fixtures/network-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; +test.describe('Custodial patients are allowed access and modification of profile details', () => { + // Define the workspace and patient at top level + const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; + const CUSTODIAL_PATIENT_SEARCH = 'Custodial Patient'; + // API Test cases require this to capture network activity + let api; + test('should allow navigation to profile details and edit profile fields', { + tag: createValidatedTags([ + TEST_TAGS.CLINICIAN, // User Type (required) + TEST_TAGS.API, // Test Type (required) + TEST_TAGS.UI, // Test Type (required) + TEST_TAGS.HIGH, // Priority (required) + TEST_TAGS.API_PROFILE, // Feature (optional) + ]), + }, async ({ page }, testInfo) => { + // Step 1: Log in to clinician account and setup network capture + await test.step('Given clinician has been logged in', async () => { + api = createNetworkHelper(page); + await api.startCapture(); + await test.clinician.setup(page); + }); + // Step 2: Navigate to workspace + await test.step('When user navigates to desired workspace', async () => { + await test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); + }); + // Step 3: Access custodial patient + await test.step('When user accesses a custodial patient summary', async () => { + await test.clinician.findAndAccessPatientByPartialName(CUSTODIAL_PATIENT_SEARCH, page); + }); + // Step 4: Navigate to profile + await test.step('When user navigates to Profile page', async () => { + await test.clinician.navigateTo('Profile', page); + }); + // Step 5: Capture GET response + await test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Step 6: Open Edit Profile + await test.step('When user selects Edit button', async () => { + await test.clinician.navigateTo('ProfileEdit', page); + }); + // Create Profile page for following steps + const profilePage = new ProfilePage(page); + // Step 7: Change profile fields (custodial access) + await test.step('When user updates profile fields', async () => { + // Generate completely unique values for this custodial test run + const randomSeed = Math.random(); + const randomId = Math.floor(randomSeed * 10000); + const updatedName = `Custodial Patient Updated ${Math.floor(randomId * 10000)}`; + const birthYear = 1980 + (randomId % 15); + const diagnosisYear = birthYear + 25; + const birthDate = `05/20/${birthYear}`; + const diagnosisDate = `08/15/${diagnosisYear}`; + // Generate random 15-digit MRN + const randomMRN = Array.from({ length: 15 }, () => Math.floor(Math.random() * 10).toString()).join(''); + // Generate random 15-letter string for clinical notes + const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); + // Generate unique email + const email = `webuiautomation+custodialEdit${randomId}@tidepool.org`; + // Get current diagnosis index and calculate next one (1-7, wrapping) + const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); + let nextDiagnosisIndex = currentDiagnosisIndex + 1; + if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { + nextDiagnosisIndex = 1; + } + // Update fields using ProfilePage methods + await profilePage.fillFullName(updatedName); + await profilePage.fillBirthDate(birthDate); + await profilePage.fillMRN(randomMRN); + await profilePage.fillDiagnosisDate(diagnosisDate); + await profilePage.selectDiagnosisType(nextDiagnosisIndex); + await profilePage.fillEmail(email); + await profilePage.fillClinicalNotes(randomString); + }); + // Step 8: Save profile edit + await test.step('When user saves profile changes', async () => { + await profilePage.saveProfile(); + }); + // Step 9: Check profile PUT response + await test.step('Then profile endpoint responds with PUT request consistent with schema [no-screenshot]', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + }); + await api.stopCapture(); + }); +}); diff --git a/dist/tests/clinician/add-patient.spec.d.ts b/dist/tests/clinician/add-patient.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/clinician/add-patient.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/clinician/add-patient.spec.js b/dist/tests/clinician/add-patient.spec.js new file mode 100644 index 0000000..65a9915 --- /dev/null +++ b/dist/tests/clinician/add-patient.spec.js @@ -0,0 +1,33 @@ +import { expect, test } from '@fixtures/base'; +import ClinicianDashboardPage from '@pom/clinician/ClinicianDashboardPage'; +import WorkspacesPage from '@pom/clinician/WorkspacesPage'; +test.describe('Add new patient', () => { + // Use a unique patient name for each test run to avoid collisions + const patientName = `Test Patient Playwright ${Date.now()}`; + const patientBirthdate = '01/01/1990'; + test.beforeEach(async () => { + await test.step('Given user has been logged in and navigated to base URL', async () => { }); + }); + test('should successfully add a new patient', async ({ page }) => { + const workspacesPage = new WorkspacesPage(page); + const clinicWorkspacePage = new ClinicianDashboardPage(page); + await test.step('Given the user is on the workspaces page', async () => { + await workspacesPage.goto(); + await workspacesPage.header.waitFor({ state: 'visible' }); + }); + await test.step('When user selects the first workspace', async () => { + await workspacesPage.visitFirstClinic(); + await clinicWorkspacePage.waitForLoadState(); // Wait for clinic page elements + }); + await test.step('When user adds a new patient via dialog', async () => { + await clinicWorkspacePage.openAndFillAddPatientDialog(patientName, patientBirthdate); + await clinicWorkspacePage.submitAddPatientDialog(); + await clinicWorkspacePage.closeBringDataDialog(); + }); + await test.step('Then the new patient should appear in the patient list', async () => { + await clinicWorkspacePage.searchForPatient(patientName); + const patientCell = clinicWorkspacePage.getPatientCellByName(patientName); + await expect(patientCell).toBeVisible(); + }); + }); +}); diff --git a/dist/tests/clinician/create-clinic-workspace.spec.d.ts b/dist/tests/clinician/create-clinic-workspace.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/clinician/create-clinic-workspace.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/clinician/create-clinic-workspace.spec.js b/dist/tests/clinician/create-clinic-workspace.spec.js new file mode 100644 index 0000000..e1363cc --- /dev/null +++ b/dist/tests/clinician/create-clinic-workspace.spec.js @@ -0,0 +1,81 @@ +import { expect, test } from '@fixtures/base'; +import ClinicCreationPage from '@pom/clinician/ClinicCreationPage'; +import WorkspacesPage from '@pom/clinician/WorkspacesPage'; +import { randomUUID } from 'node:crypto'; +test.describe('Create clinic workspace', () => { + const uniqueSuffix = randomUUID().substring(0, 8); + const clinicName = `Test Clinic ${uniqueSuffix}`; + let workspacesPage; + let clinicCreationPage; + test.beforeEach(async ({ page }) => { + workspacesPage = new WorkspacesPage(page); + clinicCreationPage = new ClinicCreationPage(page); + }); + test('should successfully create a new clinic workspace', async ({ page }) => { + await test.step('Given user is on the workspaces page', async () => { + await workspacesPage.goto(); + await expect(workspacesPage.header).toBeVisible(); + await expect(workspacesPage.createClinicButton).toBeVisible(); + }); + await test.step("When user clicks on the 'Create a New Clinic' button", async () => { + await workspacesPage.createClinicButton.click(); + // Wait for the clinic details page to load + await expect(page).toHaveURL(/clinic-details\/new/); + await expect(clinicCreationPage.pageHeader).toBeVisible(); + }); + await test.step('When user fills in all the required clinic information', async () => { + // Fill the clinic form with test data + await clinicCreationPage.fillClinicForm({ + clinicName, + teamType: 'Provider Practice', + state: 'California', + address: '123 Test Street', + city: 'Test City', + zipCode: '12345', + }); + // Verify blood glucose units (mg/dL is pre-selected) + await expect(clinicCreationPage.mgdlRadio).toBeChecked(); + // Verify the admin acknowledgment checkbox is checked + await expect(clinicCreationPage.adminAcknowledgeCheckbox).toBeChecked(); + // Verify Create Workspace button is enabled + await expect(clinicCreationPage.createWorkspaceButton).toBeEnabled(); + }); + await test.step("When user clicks on the 'Create Workspace' button", async () => { + await clinicCreationPage.createWorkspaceButton.click(); + // Wait for redirect to workspaces page + await expect(page).toHaveURL('/workspaces'); + }); + await test.step('Then user should see the new clinic in the list and a success message', async () => { + // Verify success message is shown + const successMessage = page.getByText(`"${clinicName}" clinic created`); + await expect(successMessage).toBeVisible(); + // Verify the new clinic appears in the list + const clinicHeaderLocator = page.getByRole('heading', { name: clinicName }); + await expect(clinicHeaderLocator).toBeVisible(); + // Verify the clinic has the necessary action buttons + const clinicContainer = page + .locator('.workspace-item-clinic') + .filter({ has: clinicHeaderLocator }); + await expect(clinicContainer.getByRole('button', { name: 'Leave Clinic' })).toBeVisible(); + await expect(clinicContainer.getByRole('button', { name: 'Go To Workspace' })).toBeVisible(); + }); + }); + test('should create a new clinic with the simplified createClinic method', async ({ page }) => { + // Navigate to the workspaces page + await page.goto('/workspaces'); + await expect(workspacesPage.header).toBeVisible(); + // Click the "Create a New Clinic" button + await workspacesPage.createClinicButton.click(); + await expect(page).toHaveURL(/clinic-details\/new/); + // Use the simplified method to create a clinic in one step + await clinicCreationPage.createClinic(clinicName); + // Verify we're back on the workspaces page + await expect(page).toHaveURL('/workspaces'); + // Verify the clinic was created + const successMessage = page.getByText(`"${clinicName}" clinic created`); + await expect(successMessage).toBeVisible(); + // Verify the clinic appears in the list + const clinicHeaderLocator = page.getByRole('heading', { name: clinicName }); + await expect(clinicHeaderLocator).toBeVisible(); + }); +}); diff --git a/dist/tests/clinician/edit-clinic-address.spec.d.ts b/dist/tests/clinician/edit-clinic-address.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/clinician/edit-clinic-address.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/clinician/edit-clinic-address.spec.js b/dist/tests/clinician/edit-clinic-address.spec.js new file mode 100644 index 0000000..936d9b5 --- /dev/null +++ b/dist/tests/clinician/edit-clinic-address.spec.js @@ -0,0 +1,42 @@ +import { expect, test } from '@fixtures/base'; +import ClinicAdminPage from '@pom/clinician/WorkspaceSettingsPage'; +import WorkspacesPage from '@pom/clinician/WorkspacesPage'; +test.describe('Edit clinic address', () => { + const newAddress = `123 Test Street ${Date.now()}`; // Unique address for test run + let clinicAdminPage; + let workspacesPage; + test.beforeEach(async ({ page }) => { + clinicAdminPage = new ClinicAdminPage(page); + workspacesPage = new WorkspacesPage(page); + await test.step('Given user has navigated to the Clinic Admin page', async () => { + await workspacesPage.goto(); + await workspacesPage.visitFirstClinic(); + await page.goto('/clinic-admin'); + await clinicAdminPage.waitForLoadState(); // Wait for clinic admin page elements + await clinicAdminPage.clinicDetailsHeader.waitFor({ state: 'visible' }); + }); + }); + test('should successfully edit the clinic address', async ({ page }) => { + await test.step('When user clicks the "Edit" button for workspace details', async () => { + await clinicAdminPage.editDetailsButton.click(); + await clinicAdminPage.editClinicModal.waitFor({ state: 'visible' }); + }); + await test.step('Then user sees the modal for Editing workspace details', async () => { + await expect(clinicAdminPage.editClinicModalTitle).toBeVisible(); + await expect(clinicAdminPage.addressInput).toBeVisible(); + }); + await test.step('When user changes the address', async () => { + await clinicAdminPage.addressInput.fill(newAddress); + }); + await test.step('When user clicks on "Save changes"', async () => { + await clinicAdminPage.saveChangesButton.click(); + await clinicAdminPage.editClinicModal.waitFor({ state: 'hidden' }); // Wait for modal to close + }); + await test.step('Then user sees the updated address on the page', async () => { + // Wait for the details section to potentially update + await page.waitForTimeout(1000); // Small wait for potential DOM update + const detailsText = clinicAdminPage.clinicDetailsSection; + await expect(detailsText).toContainText(newAddress); + }); + }); +}); diff --git a/dist/tests/clinician/filter-patient.spec.d.ts b/dist/tests/clinician/filter-patient.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/clinician/filter-patient.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/clinician/filter-patient.spec.js b/dist/tests/clinician/filter-patient.spec.js new file mode 100644 index 0000000..e4abb1c --- /dev/null +++ b/dist/tests/clinician/filter-patient.spec.js @@ -0,0 +1,65 @@ +import { expect, test } from '@fixtures/base'; +import ClinicianDashboardPage from '@pom/clinician/ClinicianDashboardPage'; +import WorkspacesPage from '@pom/clinician/WorkspacesPage'; +test.describe('Filter patients in clinic', () => { + // Use unique patient names for each test run + const timestamp = Date.now(); + const patientName1 = `Filter Patient A ${timestamp}`; + const patientName2 = `Filter Patient B ${timestamp}`; + const patientBirthdate = '01/01/1995'; // Shared birthdate for simplicity + let workspacesPage; + let clinicWorkspacePage; + test.beforeEach(async ({ page }) => { + workspacesPage = new WorkspacesPage(page); + clinicWorkspacePage = new ClinicianDashboardPage(page); + await test.step('Given user has been logged in and navigated to base URL', async () => { + await workspacesPage.goto(); + await page.waitForURL(workspacesPage.url); + await workspacesPage.header.waitFor({ state: 'visible' }); + }); + await test.step('Given the user is on the first clinic workspace', async () => { + await workspacesPage.visitFirstClinic(); + await clinicWorkspacePage.waitForLoadState(); // Wait for clinic page elements + }); + await test.step('Given two patients exist', async () => { + // Add first patient + await clinicWorkspacePage.openAndFillAddPatientDialog(patientName1, patientBirthdate); + await clinicWorkspacePage.submitAddPatientDialog(); + await clinicWorkspacePage.closeBringDataDialog(); + // Ensure the first patient is added before adding the second + await expect(clinicWorkspacePage.getPatientCellByName(patientName1)).toBeVisible({ + timeout: 10000, + }); + // Add second patient + await clinicWorkspacePage.openAndFillAddPatientDialog(patientName2, patientBirthdate); + await clinicWorkspacePage.submitAddPatientDialog(); + await clinicWorkspacePage.closeBringDataDialog(); + // Ensure the second patient is also added + await expect(clinicWorkspacePage.getPatientCellByName(patientName2)).toBeVisible({ + timeout: 10000, + }); + }); + }); + test('should successfully filter patients by name', async () => { + await test.step("When user filters by the first patient's name", async () => { + await clinicWorkspacePage.searchForPatient(patientName1); + }); + await test.step('Then only the first patient should be visible', async () => { + const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); + const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); + await expect(patientCell1).toBeVisible(); + await expect(patientCell2).not.toBeVisible(); + }); + await test.step('When user clears the filter', async () => { + // Assuming a method like clearPatientSearch exists or searchForPatient('') clears + await clinicWorkspacePage.searchForPatient(''); // Clear search by searching for empty string + // Or potentially: await clinicWorkspacePage.clearPatientSearch(); + }); + await test.step('Then both patients should be visible again', async () => { + const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); + const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); + await expect(patientCell1).toBeVisible(); + await expect(patientCell2).toBeVisible(); + }); + }); +}); diff --git a/dist/tests/fixtures/account-helpers.d.ts b/dist/tests/fixtures/account-helpers.d.ts new file mode 100644 index 0000000..21ab3cd --- /dev/null +++ b/dist/tests/fixtures/account-helpers.d.ts @@ -0,0 +1,20 @@ +import { test as base } from '@fixtures/base'; +import AccountNav from '@pom/account/AccountNavigation'; +import type { Page } from '@playwright/test'; +/** + * Switch user authentication context by loading different storageState + * @param userType - The user type corresponding to the storageState file (e.g., 'shared', 'clinician', 'claimed') + * @param page - The Playwright page instance + */ +declare function switchUser(userType: string, page: Page): Promise; +/** + * Core navigation function that handles account navigation consistently + */ +declare function navigateTo(targetPage: keyof AccountNav['pages'], page: Page): Promise; +declare const test: typeof base & { + account: { + navigateTo: typeof navigateTo; + switchUser: typeof switchUser; + }; +}; +export { test }; diff --git a/dist/tests/fixtures/account-helpers.js b/dist/tests/fixtures/account-helpers.js new file mode 100644 index 0000000..0e92578 --- /dev/null +++ b/dist/tests/fixtures/account-helpers.js @@ -0,0 +1,84 @@ +import { test as base } from '@fixtures/base'; +import AccountNav from '@pom/account/AccountNavigation'; +/** + * Switch user authentication context by loading different storageState + * @param userType - The user type corresponding to the storageState file (e.g., 'shared', 'clinician', 'claimed') + * @param page - The Playwright page instance + */ +async function switchUser(userType, page) { + try { + // Import fs dynamically + const fs = await import('node:fs'); + // Load the specified user's storage state + const storageStatePath = `tests/.auth/${userType}.json`; + const storageState = JSON.parse(fs.readFileSync(storageStatePath, 'utf-8')); + // Clear existing cookies first + await page.context().clearCookies(); + // Set cookies from the new user's storage state + if (storageState.cookies) { + await page.context().addCookies(storageState.cookies); + } + // Set localStorage from the new user's storage state + if (storageState.origins) { + for (const origin of storageState.origins) { + await page.addInitScript(originData => { + if (originData.localStorage) { + for (const item of originData.localStorage) { + localStorage.setItem(item.name, item.value); + } + } + }, origin); + } + } + console.log(`āœ… Successfully switched to ${userType} user authentication`); + } + catch (error) { + throw new Error(`Failed to switch to ${userType} user: ${error}`); + } +} +/** + * Core navigation function that handles account navigation consistently + */ +async function navigateTo(targetPage, page) { + const nav = new AccountNav(page); + const pageConfig = nav.pages[targetPage]; + try { + // Single page check at start + if (page.isClosed()) + return; + // Quick DOM ready check only + await page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => { }); + // Open navigation menu if needed (only for non-AccountNav targets) + if (targetPage !== 'AccountNav') { + const menuVisible = await nav.pages.AccountNav.verifyElement + .isVisible({ timeout: 1000 }) + .catch(() => false); + if (!menuVisible) { + await nav.pages.AccountNav.link.click(); + await nav.pages.AccountNav.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + } + } + // Handle logout specially + if (targetPage === 'Logout') { + await pageConfig.link.click(); + await page + .waitForURL(/.*login.*/, { waitUntil: 'domcontentloaded', timeout: 5000 }) + .catch(() => { }); + } + else { + // Standard navigation - click and verify + await pageConfig.link.click(); + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + } + } + catch (error) { + if (!page.isClosed()) + throw error; + } +} +const test = base; +test.account = { + navigateTo, + switchUser, +}; +export { test }; diff --git a/dist/tests/fixtures/base.d.ts b/dist/tests/fixtures/base.d.ts new file mode 100644 index 0000000..2b00097 --- /dev/null +++ b/dist/tests/fixtures/base.d.ts @@ -0,0 +1,23 @@ +import { Page, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, TestType } from '@playwright/test'; +interface CustomFixtures { + timeLogger: Page; + timeStepLogger: Page; + stepTimer: Page; + stepScreenshoter: Page; + exceptionLogger: Page; +} +export declare const test: TestType; +export { expect } from '@playwright/test'; +/** + * Decorator function for wrapping POM methods in a test.step. + * + * Use it without a step name `@step()`. + * + * Or with a step name `@step("Search something")`. + * + * @param stepName - The name of the test step. + * @returns A decorator function that can be used to decorate test methods. + */ +export declare function step(stepName?: string): (target: any, context: ClassMethodDecoratorContext) => (this: { + name: string; +}, ...args: any[]) => Promise; diff --git a/dist/tests/fixtures/base.js b/dist/tests/fixtures/base.js new file mode 100644 index 0000000..ccbeab6 --- /dev/null +++ b/dist/tests/fixtures/base.js @@ -0,0 +1,219 @@ +import { test as base, } from '@playwright/test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +// Define the test type with custom fixtures +export const test = base.extend({ + page: async ({ page }, use, testInfo) => { + const modifiedTestInfo = testInfo; + modifiedTestInfo.snapshotSuffix = ''; + modifiedTestInfo.snapshotPath = name => `${testInfo.file}-snapshots/${name}`; + // Make testInfo globally available for network helpers + globalThis.testInfo = testInfo; + try { + await use(page); + } + finally { + // Clean up after test + delete globalThis.testInfo; + } + }, + timeLogger: [ + async ({ page }, use, testInfo) => { + testInfo.annotations.push({ + type: 'Start', + description: new Date().toISOString(), + }); + await use(page); + testInfo.annotations.push({ + type: 'End', + description: new Date().toISOString(), + }); + }, + { auto: true }, + ], + timeStepLogger: [ + async ({ page }, use, testInfo) => { + const startTime = Date.now(); + console.time(`[test] ${testInfo.title}`); + await use(page); + console.timeEnd(`[test] ${testInfo.title}`); + const endTime = Date.now(); + const duration = endTime - startTime; + testInfo.annotations.push({ + type: 'Duration', + description: `${duration}ms`, + }); + testInfo.annotations.push({ + type: 'End', + description: new Date().toISOString(), + }); + }, + { auto: true }, + ], + stepTimer: [ + async ({ page }, use, testInfo) => { + const originalStep = test.step; + const stepTimings = new Map(); + // Create a new step function with the same interface as the original + const newStep = function newStepWrapper(name, fn) { + return originalStep.call(this, name, async (stepInfo) => { + const startTime = Date.now(); + console.time(`[step] ${name}`); + const result = await fn(stepInfo); + console.timeEnd(`[step] ${name}`); + const endTime = Date.now(); + const duration = endTime - startTime; + stepTimings.set(name, duration); + testInfo.annotations.push({ + type: `Step Duration: ${name}`, + description: `${duration}ms`, + }); + return result; + }); + }; + // Add the skip method to match the original test.step interface + newStep.skip = function skipStep(name, fn) { + return originalStep.skip.call(this, name, fn); + }; + // Replace the original step with our enhanced version + test.step = newStep; + await use(page); + // Restore original test.step + test.step = originalStep; + }, + { auto: true }, + ], + stepScreenshoter: [ + async ({ page }, use, testInfo) => { + const originalStep = test.step; + let stepCounter = 0; + // Create a safe directory name based on test info + const testDirName = path.basename(testInfo.file, '.spec.ts').replace(/[^a-z0-9]/gi, '-'); + const screenshotDir = path.join('test-results', testDirName); + // Store current step name for network helpers + let currentStepName = ''; + // Make step counter accessible globally for network helper + globalThis.__stepCounter = { + get: () => stepCounter, + increment: () => ++stepCounter, + getDirectory: () => screenshotDir, + getCurrentStepName: () => currentStepName, + setCurrentStepName: (name) => { + currentStepName = name; + }, + }; + // Clean up existing screenshots from previous runs + try { + await fs.promises.access(screenshotDir); + await fs.promises.rm(screenshotDir, { recursive: true, force: true }); + } + catch { + // Directory doesn't exist, no need to clean up + } + // Create a new step function that takes screenshots after completion and attaches them to the report + const newStep = function newStepScreenshot(name, fn) { + return originalStep.call(this, name, async (stepInfo) => { + // Set current step name for network helpers (clean name without [no-screenshot]) + const stepCounterObj = globalThis.__stepCounter; + if (stepCounterObj) { + const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); + stepCounterObj.setCurrentStepName(cleanName); + } + const result = await fn(stepInfo); + // Skip screenshot if step name contains [no-screenshot] + if (name.includes('[no-screenshot]')) { + return result; + } + // Take screenshot after step completion + stepCounter += 1; + try { + if (!page.isClosed()) { + // Use clean name for filename (without [no-screenshot]) + const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); + const screenshotName = `step-${stepCounter.toString().padStart(2, '0')}-${cleanName.toLowerCase().replace(/[^a-z0-9]/g, '-')}.png`; + // Take screenshot directly to buffer (no local file) + const screenshot = await page.screenshot({ + fullPage: true, + }); + // Attach to Playwright report AND force test-results folder creation + if (testInfo && typeof testInfo.attach === 'function') { + await testInfo.attach(screenshotName, { + body: screenshot, + contentType: 'image/png', + }); + // Also save to test-results for organized viewing (single source) + const testResultsDir = path.join(testInfo.outputDir, 'attachments'); + await fs.promises.mkdir(testResultsDir, { recursive: true }); + const screenshotPath = path.join(testResultsDir, screenshotName); + await fs.promises.writeFile(screenshotPath, screenshot); + } + } + } + catch (error) { } + return result; + }); + }; + // Add the skip method to match the original test.step interface + newStep.skip = function skipStepScreenshot(name, fn) { + return originalStep.skip.call(this, name, fn); + }; + // Add a custom stepNoScreenshot function for API validation steps + const stepNoScreenshot = function stepNoScreenshot(name, fn) { + return originalStep.call(this, name, async (stepInfo) => { + // Set current step name for network helpers (clean name) + const stepCounterObj = globalThis.__stepCounter; + if (stepCounterObj) { + stepCounterObj.setCurrentStepName(name); + } + const result = await fn(stepInfo); + // No screenshot taken for this step type + // console.log(`ā­ļø API step completed without screenshot: ${name}`); + return result; + }); + }; + // Replace the original step with our enhanced version + test.step = newStep; + // Add the no-screenshot step function to the test object + test.stepNoScreenshot = stepNoScreenshot; + await use(page); + // Restore original test.step + test.step = originalStep; + }, + { auto: true }, + ], + exceptionLogger: [ + async ({ page }, use, testInfo) => { + const errors = []; + page.on('pageerror', (error) => { + errors.push(error); + }); + await use(page); + if (errors.length > 0) { + await testInfo.attach('frontend-exceptions', { + body: errors.map(error => `${error.message}\n${error.stack}`).join('\n---------\n'), + }); + throw new Error('Some frontend exceptions occurred'); + } + }, + { auto: true }, + ], +}); +export { expect } from '@playwright/test'; +/** + * Decorator function for wrapping POM methods in a test.step. + * + * Use it without a step name `@step()`. + * + * Or with a step name `@step("Search something")`. + * + * @param stepName - The name of the test step. + * @returns A decorator function that can be used to decorate test methods. + */ +export function step(stepName) { + return function decorator(target, context) { + return function replacementMethod(...args) { + const name = `${stepName || context.name} (${this.name})`; + return test.step(name, async () => await target.call(this, ...args)); + }; + }; +} diff --git a/dist/tests/fixtures/clinic-helpers.d.ts b/dist/tests/fixtures/clinic-helpers.d.ts new file mode 100644 index 0000000..170b58e --- /dev/null +++ b/dist/tests/fixtures/clinic-helpers.d.ts @@ -0,0 +1,61 @@ +import { test as base } from '@fixtures/base'; +import type { Page } from '@playwright/test'; +import ClinicianNav from '../../page-objects/clinician/ClinicianNavigation'; +export type WorkspaceKey = 'AdminClinicBase' | 'AdminClinicEnterprise' | 'MemberClinicBase' | 'MemberClinicEnterprise' | 'NonMemberClinicBase' | 'NonMemberClinicEnterprise' | 'PartnerClinicBase' | 'PartnerClinicEnterprise'; +export type PageKey = 'PatientList' | 'WorkspaceSettings' | 'AddPatient' | 'Profile' | 'ProfileEdit'; +/** + * Initialize clinician navigation helpers after login + */ +declare function setupClinicianSession(page: Page): Promise; +/** + * Navigate to workspace selection page + */ +declare function navigateToWorkspaceSelection(page: Page): Promise; +/** + * Navigate to a specific workspace using hardcoded workspace key + */ +declare function navigateToWorkspace(workspaceKey: WorkspaceKey, page: Page): Promise; +/** + * Core navigation function that handles workspace prerequisites and page navigation + */ +declare function navigateTo(targetPage: PageKey, page: Page, workspaceKey?: WorkspaceKey): Promise; +/** + * Execute test logic across multiple workspaces + */ +declare function executeAcrossWorkspaces(workspaceConfigs: { + workspaceKey: WorkspaceKey; +}[], action: (config: { + workspaceKey: WorkspaceKey; +}) => Promise, page: Page): Promise; +/** + * Find and access any patient whose name contains the search term (optimized version) + * @param searchTerm - Partial name to search for (e.g., "Custodial") + * @param page - The Playwright page object + * @returns The full name of the patient that was accessed + */ +declare function findAndAccessPatientByPartialName(searchTerm: string, page: Page): Promise; +/** + * Find and access any available patient (fastest option) + * @param page - The Playwright page object + * @returns The full name of the first patient that was accessed + */ +declare function findAndAccessAnyPatient(page: Page): Promise; +/** + * Access a specific patient by name and navigate to their summary page + * @param patientName - The name of the patient to access + * @param page - The Playwright page object + */ +declare function accessPatient(patientName: string, page: Page): Promise; +declare const test: typeof base & { + clinician: { + navigateTo: typeof navigateTo; + navigateToWorkspace: typeof navigateToWorkspace; + navigateToWorkspaceSelection: typeof navigateToWorkspaceSelection; + executeAcrossWorkspaces: typeof executeAcrossWorkspaces; + accessPatient: typeof accessPatient; + findAndAccessPatientByPartialName: typeof findAndAccessPatientByPartialName; + findAndAccessAnyPatient: typeof findAndAccessAnyPatient; + setup: typeof setupClinicianSession; + }; +}; +export { test }; diff --git a/dist/tests/fixtures/clinic-helpers.js b/dist/tests/fixtures/clinic-helpers.js new file mode 100644 index 0000000..31fd2d1 --- /dev/null +++ b/dist/tests/fixtures/clinic-helpers.js @@ -0,0 +1,274 @@ +import { test as base } from '@fixtures/base'; +import ClinicianNav from '../../page-objects/clinician/ClinicianNavigation'; +import ClinicianDashboardPage from '../../page-objects/clinician/ClinicianDashboardPage'; +import AccountNav from '../../page-objects/account/AccountNavigation'; +/** + * Initialize clinician navigation helpers after login + */ +async function setupClinicianSession(page) { + // Wait for clinician navigation to be available + const nav = new ClinicianNav(page); + // Navigate to login and setup clinic session if needed + if (!page.url().includes('clinic-workspace')) { + await page.goto('/login'); + // Add any necessary login steps here + } + console.log('šŸ„ Clinic session setup complete'); + return nav; +} +/** + * Navigate to workspace selection page + */ +async function navigateToWorkspaceSelection(page) { + const accountNav = new AccountNav(page); + // Open the account navigation menu first + await accountNav.pages.AccountNav.link.click(); + // Then click the ManageWorkspaces option + await accountNav.pages.ManageWorkspaces.link.click(); + // Verify we're on the workspace selection page using the known verification element + await accountNav.pages.ManageWorkspaces.verifyElement.waitFor({ + state: 'visible', + timeout: 5000, + }); + // console.log('āœ… Navigated to workspace selection page'); +} +/** + * Navigate to a specific workspace using hardcoded workspace key + */ +async function navigateToWorkspace(workspaceKey, page) { + const clinicianNav = new ClinicianNav(page); + // First navigate to workspace selection if not already there + if (!page.url().includes('workspaces')) { + await navigateToWorkspaceSelection(page); + } + // Click on the specific workspace using the page object locator + await clinicianNav.workspaces[workspaceKey].link.click(); + // Verify we're in the correct workspace using URL verification + await page.waitForURL(new RegExp(clinicianNav.workspaces[workspaceKey].verifyURL), { + timeout: 5000, + }); + // console.log(`āœ… Successfully navigated to workspace: ${clinicianNav.workspaces[workspaceKey].name}`); +} +/** + * Core navigation function that handles workspace prerequisites and page navigation + */ +async function navigateTo(targetPage, page, workspaceKey) { + const clinicianNav = new ClinicianNav(page); + const pageConfig = clinicianNav.pages[targetPage]; + // Ensure we're in a workspace context (but don't auto-switch if already in one) + const isInWorkspaceContext = page.url().includes('clinic-workspace') || + page.url().includes('/patients/') || + page.url().includes('/profile'); + if (!isInWorkspaceContext) { + const defaultWorkspace = workspaceKey || 'AdminClinicBase'; + await navigateToWorkspace(defaultWorkspace, page); + } + else if (workspaceKey) { + // Only switch if specifically requested and we can verify we're in wrong workspace + const currentUrl = page.url(); + const targetWorkspacePattern = clinicianNav.workspaces[workspaceKey].verifyURL; + if (!currentUrl.includes(targetWorkspacePattern)) { + await navigateToWorkspace(workspaceKey, page); + } + } + // Handle page-specific prerequisites + if (targetPage === 'AddPatient') { + // AddPatient might need to be on PatientList first + if (!page.url().includes('patients')) { + await clinicianNav.pages.PatientList.link.click(); + await clinicianNav.pages.PatientList.verifyElement.waitFor({ + state: 'visible', + timeout: 5000, + }); + } + } + // Perform the actual navigation + try { + await pageConfig.link.click(); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`Failed to click ${targetPage}: ${errorMessage}`); + throw error; + } + // Verify navigation succeeded + try { + if (pageConfig.verifyURL) { + await page.waitForURL(`**/*${pageConfig.verifyURL}*`, { timeout: 5000 }); + } + if (pageConfig.verifyElement) { + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + } + // console.log(`āœ… Navigated to page: ${targetPage}`); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + // console.log(`Navigation verification failed for ${targetPage}: ${errorMessage}`); + } +} +/** + * Execute test logic across multiple workspaces + */ +async function executeAcrossWorkspaces(workspaceConfigs, action, page) { + for (const config of workspaceConfigs) { + console.log(`šŸ”„ Executing across workspace: ${config.workspaceKey}`); + // Navigate to the workspace + await navigateToWorkspace(config.workspaceKey, page); + // Execute the action + await action(config); + // Navigate back to workspace selection for next iteration + if (workspaceConfigs.indexOf(config) < workspaceConfigs.length - 1) { + await navigateToWorkspaceSelection(page); + } + } +} +/** + * Find and access any patient whose name contains the search term (optimized version) + * @param searchTerm - Partial name to search for (e.g., "Custodial") + * @param page - The Playwright page object + * @returns The full name of the patient that was accessed + */ +async function findAndAccessPatientByPartialName(searchTerm, page) { + const dashboard = new ClinicianDashboardPage(page); + // If empty search term, find any available patient + if (!searchTerm || searchTerm.trim() === '') { + return findAndAccessAnyPatient(page); + } + // Strategy 1: Fill search field THEN click Show All (proven fastest method) + try { + await dashboard.searchInput.fill(searchTerm); + await page.waitForTimeout(500); + const showAllButton = page + .getByRole('button', { name: 'Show All' }) + .or(page.getByRole('button', { name: 'Show all' })) + .or(page.getByText('Show All')) + .or(page.getByText('Show all')); + if (await showAllButton.isVisible({ timeout: 1000 })) { + await showAllButton.click(); + await page.waitForTimeout(1000); + const searchResultCells = await dashboard.patientListTable.getByRole('cell').all(); + if (searchResultCells.length > 0) { + for (const cell of searchResultCells) { + const cellText = await cell.textContent(); + if (cellText && cellText.toLowerCase().includes(searchTerm.toLowerCase())) { + await cell.click(); + await page.waitForTimeout(600); + return cellText.trim(); + } + } + } + } + else { + await dashboard.searchInput.press('Enter'); + await page.waitForTimeout(1000); + const searchResultCells = await dashboard.patientListTable.getByRole('cell').all(); + if (searchResultCells.length > 0) { + for (const cell of searchResultCells) { + const cellText = await cell.textContent(); + if (cellText && cellText.toLowerCase().includes(searchTerm.toLowerCase())) { + await cell.click(); + await page.waitForTimeout(600); + return cellText.trim(); + } + } + } + } + } + catch (error) { + // Silent fallback to any patient + } + // Strategy 2: Fallback to any available patient if specific search fails + try { + return await findAndAccessAnyPatient(page); + } + catch (fallbackError) { + throw new Error(`No patient found containing "${searchTerm}" and no fallback patients available`); + } +} +/** + * Find and access any available patient (fastest option) + * @param page - The Playwright page object + * @returns The full name of the first patient that was accessed + */ +async function findAndAccessAnyPatient(page) { + const dashboard = new ClinicianDashboardPage(page); + try { + // Clear search to show all patients + await dashboard.searchInput.click(); + await dashboard.searchInput.fill(' '); + await page.waitForTimeout(500); + await dashboard.searchInput.fill(''); + await page.waitForTimeout(1500); + let allCells = await dashboard.patientListTable.getByRole('cell').all(); + // If no cells, try pressing Enter on empty search + if (allCells.length === 0) { + await dashboard.searchInput.press('Enter'); + await page.waitForTimeout(1500); + allCells = await dashboard.patientListTable.getByRole('cell').all(); + } + // Find the first cell that looks like a patient name + for (const cell of allCells) { + const cellText = await cell.textContent(); + if (cellText && cellText.trim().length > 3 && cellText.includes(' ')) { + await cell.click(); + await page.waitForTimeout(800); + return cellText.trim(); + } + } + throw new Error('No patient names found in table'); + } + catch (error) { + throw new Error(`Failed to find any patient: ${error}`); + } +} +/** + * Access a specific patient by name and navigate to their summary page + * @param patientName - The name of the patient to access + * @param page - The Playwright page object + */ +async function accessPatient(patientName, page) { + const dashboard = new ClinicianDashboardPage(page); + console.log(`šŸ” Searching for patient: ${patientName}`); + // Try optimized search first + await dashboard.searchForPatient(patientName); + await page.waitForTimeout(1000); // Reduced wait time + // Check if search worked + const patientCell = dashboard.getPatientCellByName(patientName); + const isVisible = await patientCell.isVisible({ timeout: 2000 }); + if (isVisible) { + console.log(`šŸ‘¤ Found patient via search: ${patientName}`); + await patientCell.click(); + await page.waitForTimeout(1000); + console.log(`āœ… Successfully accessed patient summary for: ${patientName}`); + return; + } + // If search failed, fall back to show all + find + console.log(`šŸ”„ Search failed, trying show all approach...`); + const showAllButton = page.getByRole('button', { name: 'Show All' }); + if (await showAllButton.isVisible({ timeout: 1000 })) { + await showAllButton.click(); + await page.waitForTimeout(1500); + } + // Try again after showing all + const isVisibleAfterShowAll = await patientCell.isVisible({ timeout: 2000 }); + if (isVisibleAfterShowAll) { + await patientCell.click(); + await page.waitForTimeout(1000); + // console.log(`āœ… Successfully accessed patient summary for: ${patientName}`); + return; + } + // If still not found, throw error + throw new Error(`Patient "${patientName}" not found in current workspace`); +} +const test = base; +test.clinician = { + navigateTo, + navigateToWorkspace, + navigateToWorkspaceSelection, + executeAcrossWorkspaces, + accessPatient, + findAndAccessPatientByPartialName, + findAndAccessAnyPatient, + setup: setupClinicianSession, +}; +export { test }; diff --git a/dist/tests/fixtures/network-helpers.d.ts b/dist/tests/fixtures/network-helpers.d.ts new file mode 100644 index 0000000..78ad092 --- /dev/null +++ b/dist/tests/fixtures/network-helpers.d.ts @@ -0,0 +1,112 @@ +import { Page } from '@playwright/test'; +import { type EndpointName } from '../../endpoint-schema/endpoint-registry'; +export interface NetworkCapture { + url: string; + method: string; + requestBody?: any; + responseBody?: any; + statusCode?: number; + timestamp: number; +} +/** + * Simple network helper for API validation + */ +export declare class NetworkHelper { + private page; + private captures; + private isCapturing; + constructor(page: Page); + startCapture(): Promise; + stopCapture(): Promise; + waitForEndpoint(endpointName: string, method: string, timeout?: number): Promise; + getCaptures(): NetworkCapture[]; + /** + * Simple helper to validate endpoint requests by URL pattern and method + */ + validateEndpointRequests(urlPattern: string, method: string): NetworkCapture[]; + /** + * Save all captures to a JSON file + */ + saveCapturesTo(filename: string, testInfo?: import('@playwright/test').TestInfo): Promise; + /** + * Print a summary of all captures to console + */ + printCaptureSummary(): void; + /** + * Get captures filtered by status code + */ + getCapturesByStatus(statusCode: number): NetworkCapture[]; + /** + * Get the most recent capture matching method and URL pattern + */ + getLatestCaptureMatching(method: string, urlPattern: RegExp): NetworkCapture | null; + /** + * Get all captures for a specific endpoint + */ + getCapturesForEndpoint(endpointName: string): NetworkCapture[]; + /** + * Get all captures + */ + getAllCaptures(): NetworkCapture[]; + /** + * Save API response as JSON attachment and to organized test-results folder + */ + saveApiResponse(response: any, endpoint: string, method: string, fileName: string, testInfo?: import('@playwright/test').TestInfo): Promise; + /** + * Validate and save API response for any endpoint defined in the endpoint registry + * @param endpointName - The endpoint name from the registry (e.g., 'profile-metadata-get') + * @returns The captured network request or null if not found + */ + validateEndpointResponse(endpointName: EndpointName): Promise; + /** + * Save network capture for producer/consumer test patterns + * @param endpointName - The endpoint to save + * @param testName - Name of the test (used for file naming) + * @returns The saved network capture or null + */ + saveForDependentTests(endpointName: EndpointName, testName: string): Promise; + /** + * Load producer test data for consumer tests + * @param testName - Name of the producer test (used for file naming) + * @returns The loaded network capture or null + */ + loadFromProducerTest(testName: string): NetworkCapture | null; + /** + * Validate data consistency between producer and consumer responses + * @param producerCapture - The producer test network capture + * @param consumerCapture - The consumer test network capture + * @param fieldsToValidate - Array of field paths to validate (e.g., ['fullName', 'patient.birthday']) + * @param requiredFields - Array of fields that must exist and match (defaults to common required fields) + */ + validateDataConsistency(producerCapture: NetworkCapture, consumerCapture: NetworkCapture, fieldsToValidate?: string[], requiredFields?: string[]): void; + /** + * Helper method to get nested object values using dot notation + * @param obj - The object to search + * @param path - The dot-notation path (e.g., 'patient.birthday') + * @returns The value at the path or undefined + */ + private getNestedValue; + /** + * Validate producer-consumer data consistency for profile endpoints + * @param producerEndpointName - The PUT endpoint name (e.g., 'profile-metadata-put') + * @param consumerEndpointName - The GET endpoint name (e.g., 'profile-metadata-get') + * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) + * @throws Error if validation fails + */ + validateProducerConsumerData(producerEndpointName: EndpointName, consumerEndpointName: EndpointName, fieldsToValidate?: string[]): Promise; + /** + * Private method to validate endpoint response without generating JSON file + * @param endpointName - The endpoint name from the registry + * @returns The captured network request or null if not found + */ + private validateEndpointResponseSilent; + /** + * Complete validation workflow for a user viewing profile data + * Validates both API schema and data consistency in one call + * @param consumerEndpointName - The GET endpoint name + * @param producerCapture - The stored PUT capture from the producer + * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) + */ + compareEndpointResponse(consumerEndpointName: EndpointName, producerCapture: NetworkCapture, fieldsToValidate?: string[]): Promise; +} +export declare function createNetworkHelper(page: Page): NetworkHelper; diff --git a/dist/tests/fixtures/network-helpers.js b/dist/tests/fixtures/network-helpers.js new file mode 100644 index 0000000..09fb0cf --- /dev/null +++ b/dist/tests/fixtures/network-helpers.js @@ -0,0 +1,442 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { getEndpointSchema, } from '../../endpoint-schema/endpoint-registry'; +const ENDPOINTS = { + profile: /\/data\/[^\/]+$/, // GET requests for patient data + profileUpdate: /\/data\/[^\/]+$/, // PUT requests for patient data updates + profileMetrics: /\/metrics\/thisuser\//, + profileMessage: /\/message\/notes\//, +}; +/** + * Simple network helper for API validation + */ +export class NetworkHelper { + constructor(page) { + this.captures = []; + this.isCapturing = false; + this.page = page; + } + async startCapture() { + if (this.isCapturing) + return; + // Only intercept API requests we care about to avoid interfering with other requests + const apiPatterns = [ + '**/data/**', + '**/metrics/**', + '**/message/**', + '**/auth/**', + '**/v1/**', + '**/metadata/**', + '**/user/**', + '**/users/**', + '**/profile/**', + ]; + for (const pattern of apiPatterns) { + await this.page.route(pattern, async (route) => { + const request = route.request(); + try { + const response = await route.fetch(); + let requestBody; + let responseBody; + try { + requestBody = request.postDataJSON(); + } + catch { + requestBody = request.postData(); + } + try { + responseBody = await response.json(); + } + catch { + responseBody = await response.text(); + } + this.captures.push({ + url: request.url(), + method: request.method(), + requestBody, + responseBody, + statusCode: response.status(), + timestamp: Date.now(), + }); + await route.fulfill({ response }); + } + catch (error) { + // If there's an error, continue the request without handling + try { + await route.continue(); + } + catch { + // Route might already be handled, ignore + } + } + }); + } + this.isCapturing = true; + } + async stopCapture() { + if (!this.isCapturing) + return; + // Remove all API route handlers + const apiPatterns = ['**/data/**', '**/metrics/**', '**/message/**', '**/auth/**', '**/v1/**']; + for (const pattern of apiPatterns) { + await this.page.unroute(pattern); + } + this.isCapturing = false; + } + async waitForEndpoint(endpointName, method, timeout = 30000) { + const pattern = ENDPOINTS[endpointName]; + if (!pattern) { + throw new Error(`Unknown endpoint: ${endpointName}`); + } + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + const matches = this.captures.filter(capture => pattern.test(capture.url) && capture.method.toLowerCase() === method.toLowerCase()); + if (matches.length > 0) { + return matches[matches.length - 1]; // Return latest match + } + await this.page.waitForTimeout(100); + } + throw new Error(`${method} request to ${endpointName} not found within ${timeout}ms`); + } + getCaptures() { + return [...this.captures]; + } + /** + * Simple helper to validate endpoint requests by URL pattern and method + */ + validateEndpointRequests(urlPattern, method) { + return this.captures.filter(c => c.url.includes(urlPattern) && c.method === method); + } + /** + * Save all captures to a JSON file + */ + async saveCapturesTo(filename, testInfo) { + const logDir = path.join(process.cwd(), 'log'); + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + // Create capture data + const captureData = { + timestamp: new Date().toISOString(), + totalCaptures: this.captures.length, + captures: this.captures, + }; + // Use Playwright's automatic attachment instead of manual file writing + if (testInfo && typeof testInfo.attach === 'function') { + await testInfo.attach(filename, { + body: JSON.stringify(captureData, null, 2), + contentType: 'application/json', + }); + console.log(`šŸ“„ Network captures attached to Playwright report: ${filename}`); + } + else { + console.log(`šŸ“„ Network captures ready (${this.captures.length} captures)`); + } + } + /** + * Print a summary of all captures to console + */ + printCaptureSummary() { + console.log(`\nšŸ“Š Network Capture Summary (${this.captures.length} total requests):`); + console.log('='.repeat(60)); + this.captures.forEach((capture, index) => { + const timestamp = new Date(capture.timestamp).toLocaleTimeString(); + console.log(`${index + 1}. ${capture.method} ${capture.statusCode} - ${capture.url}`); + console.log(` Time: ${timestamp}`); + if (capture.requestBody) { + console.log(` Request: ${JSON.stringify(capture.requestBody).substring(0, 100)}...`); + } + console.log(''); + }); + } + /** + * Get captures filtered by status code + */ + getCapturesByStatus(statusCode) { + return this.captures.filter(c => c.statusCode === statusCode); + } + /** + * Get the most recent capture matching method and URL pattern + */ + getLatestCaptureMatching(method, urlPattern) { + const matches = this.captures + .filter(c => c.method === method && urlPattern.test(c.url)) + .sort((a, b) => b.timestamp - a.timestamp); + return matches.length > 0 ? matches[0] : null; + } + /** + * Get all captures for a specific endpoint + */ + getCapturesForEndpoint(endpointName) { + const pattern = ENDPOINTS[endpointName]; + if (!pattern) { + throw new Error(`Unknown endpoint: ${endpointName}`); + } + return this.captures.filter(c => pattern.test(c.url)); + } + /** + * Get all captures + */ + getAllCaptures() { + return [...this.captures]; + } + /** + * Save API response as JSON attachment and to organized test-results folder + */ + async saveApiResponse(response, endpoint, method, fileName, testInfo) { + const responseData = { + _request: { + method, + endpoint, + }, + ...response, + }; + const jsonContent = JSON.stringify(responseData, null, 2); + // Attach to Playwright report AND save to organized test-results folder + if (testInfo && typeof testInfo.attach === 'function') { + await testInfo.attach(fileName, { + body: jsonContent, + contentType: 'application/json', + }); + // Also save to test-results for organized viewing (like screenshots) + const testResultsDir = path.join(testInfo.outputDir, 'attachments'); + await fs.promises.mkdir(testResultsDir, { recursive: true }); + const jsonPath = path.join(testResultsDir, fileName); + await fs.promises.writeFile(jsonPath, jsonContent, 'utf8'); + } + } + /** + * Validate and save API response for any endpoint defined in the endpoint registry + * @param endpointName - The endpoint name from the registry (e.g., 'profile-metadata-get') + * @returns The captured network request or null if not found + */ + async validateEndpointResponse(endpointName) { + const schema = getEndpointSchema(endpointName); + const request = this.getLatestCaptureMatching(schema.method, schema.url); + if (request?.responseBody) { + // Access the shared step counter from the stepScreenshoter fixture + const stepCounterObj = globalThis.__stepCounter; + if (stepCounterObj) { + const stepNumber = stepCounterObj.increment(); + const currentStepName = stepCounterObj.getCurrentStepName(); + // Create consistent filename with step number and step name (like screenshots) + const stepNameForFile = currentStepName + ? currentStepName.toLowerCase().replace(/[^a-z0-9]/g, '-') + : endpointName.replace(/[^a-z0-9]/gi, '-'); + const fileName = `step-${stepNumber.toString().padStart(2, '0')}-${stepNameForFile}-response.json`; + await this.saveApiResponse(request.responseBody, request.url, schema.method, fileName, globalThis.testInfo); + } + } + return request; + } + /** + * Save network capture for producer/consumer test patterns + * @param endpointName - The endpoint to save + * @param testName - Name of the test (used for file naming) + * @returns The saved network capture or null + */ + async saveForDependentTests(endpointName, testName) { + const schema = getEndpointSchema(endpointName); + const capture = this.getLatestCaptureMatching(schema.method, schema.url); + if (capture) { + // Create step-based filename for better organization + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const stepName = testName.toLowerCase().replace(/[^a-z0-9]/g, '-'); + const fileName = `step-api-${stepName}-${endpointName.replace(/[^a-z0-9]/gi, '-')}-${timestamp}.json`; + console.log(`āœ… Saved ${endpointName} response for dependent tests`); + // Use Playwright's automatic attachment instead of file system + const { testInfo } = globalThis; + if (testInfo && typeof testInfo.attach === 'function') { + await testInfo.attach(fileName, { + body: JSON.stringify(capture, null, 2), + contentType: 'application/json', + }); + } + return capture; + } + return null; + } + /** + * Load producer test data for consumer tests + * @param testName - Name of the producer test (used for file naming) + * @returns The loaded network capture or null + */ + loadFromProducerTest(testName) { + const filePath = path.join(process.cwd(), 'log', 'test-data-pipeline', `${testName}-response.json`); + if (fs.existsSync(filePath)) { + const fileContent = fs.readFileSync(filePath, 'utf-8'); + const capture = JSON.parse(fileContent); + console.log(`āœ… Loaded ${testName} response from producer test`); + return capture; + } + throw new Error(`Producer test data not found at: ${filePath}. Please run ${testName} test first.`); + } + /** + * Validate data consistency between producer and consumer responses + * @param producerCapture - The producer test network capture + * @param consumerCapture - The consumer test network capture + * @param fieldsToValidate - Array of field paths to validate (e.g., ['fullName', 'patient.birthday']) + * @param requiredFields - Array of fields that must exist and match (defaults to common required fields) + */ + validateDataConsistency(producerCapture, consumerCapture, fieldsToValidate, requiredFields = ['fullName']) { + // Use provided fields or fall back to a basic set for backward compatibility + const defaultFields = ['fullName', 'patient.fullName', 'patient.birthday', 'email']; + const fieldsToCheck = fieldsToValidate || defaultFields; + const producerData = producerCapture.responseBody; + const consumerData = consumerCapture.responseBody; + if (!producerData || !consumerData) { + throw new Error('Missing response data for consistency validation'); + } + console.log('šŸ” Validating data consistency:'); + // Only log full data in development mode + if (process.env.VERBOSE_VALIDATION) { + console.log('Producer:', JSON.stringify(producerData, null, 2)); + console.log('Consumer:', JSON.stringify(consumerData, null, 2)); + } + else { + console.log('Producer fullName:', producerData.fullName); + console.log('Consumer fullName:', consumerData.fullName); + } + // Validate each specified field + for (const fieldPath of fieldsToCheck) { + const producerValue = this.getNestedValue(producerData, fieldPath); + const consumerValue = this.getNestedValue(consumerData, fieldPath); + // Check if this field is marked as required + const isRequired = requiredFields.includes(fieldPath); + if (isRequired) { + if (producerValue === undefined || producerValue === null) { + throw new Error(`Required field ${fieldPath} is missing in producer data`); + } + if (consumerValue === undefined || consumerValue === null) { + throw new Error(`Required field ${fieldPath} is missing in consumer data`); + } + } + // For optional fields: only validate if the field exists in producer data + // If it exists in producer, it must also exist in consumer with same value + if (producerValue !== undefined && producerValue !== null) { + // Handle array comparison + if (Array.isArray(producerValue) && Array.isArray(consumerValue)) { + if (JSON.stringify(producerValue) !== JSON.stringify(consumerValue)) { + throw new Error(`${fieldPath} mismatch - Expected: ${JSON.stringify(producerValue)}, Got: ${JSON.stringify(consumerValue)}`); + } + } + else if (producerValue !== consumerValue) { + throw new Error(`${fieldPath} mismatch - Expected: ${producerValue}, Got: ${consumerValue}`); + } + } + // If producer value doesn't exist, consumer doesn't need to have it either (optional field) + } + console.log('āœ… Data consistency validated: consumer data reflects producer changes'); + } + /** + * Helper method to get nested object values using dot notation + * @param obj - The object to search + * @param path - The dot-notation path (e.g., 'patient.birthday') + * @returns The value at the path or undefined + */ + getNestedValue(obj, path) { + return path.split('.').reduce((current, key) => current?.[key], obj); + } + /** + * Validate producer-consumer data consistency for profile endpoints + * @param producerEndpointName - The PUT endpoint name (e.g., 'profile-metadata-put') + * @param consumerEndpointName - The GET endpoint name (e.g., 'profile-metadata-get') + * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) + * @throws Error if validation fails + */ + async validateProducerConsumerData(producerEndpointName, consumerEndpointName, fieldsToValidate) { + const producerSchema = getEndpointSchema(producerEndpointName); + const consumerSchema = getEndpointSchema(consumerEndpointName); + // Use provided fields, or consumer endpoint validation fields, or producer endpoint validation fields + const validationFields = fieldsToValidate || + consumerSchema.validationFields || + producerSchema.validationFields || ['fullName', 'email']; + // Use consumer endpoint required fields, or producer endpoint required fields, or default + const requiredFields = consumerSchema.requiredFields || + producerSchema.requiredFields || ['fullName']; + const producerCapture = this.getLatestCaptureMatching(producerSchema.method, producerSchema.url); + const consumerCapture = this.getLatestCaptureMatching(consumerSchema.method, consumerSchema.url); + if (!producerCapture) { + throw new Error(`No ${producerEndpointName} capture found for producer validation`); + } + if (!consumerCapture) { + throw new Error(`No ${consumerEndpointName} capture found for consumer validation`); + } + this.validateDataConsistency(producerCapture, consumerCapture, validationFields, requiredFields); + } + /** + * Private method to validate endpoint response without generating JSON file + * @param endpointName - The endpoint name from the registry + * @returns The captured network request or null if not found + */ + validateEndpointResponseSilent(endpointName) { + const schema = getEndpointSchema(endpointName); + const request = this.getLatestCaptureMatching(schema.method, schema.url); + return request; + } + /** + * Complete validation workflow for a user viewing profile data + * Validates both API schema and data consistency in one call + * @param consumerEndpointName - The GET endpoint name + * @param producerCapture - The stored PUT capture from the producer + * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) + */ + async compareEndpointResponse(consumerEndpointName, producerCapture, fieldsToValidate) { + // Get the endpoint schema to determine validation fields + const consumerSchema = getEndpointSchema(consumerEndpointName); + // Use provided fields, or endpoint-specific fields, or fall back to basic fields + const validationFields = fieldsToValidate || + consumerSchema.validationFields || ['fullName', 'patient.fullName', 'email']; + // Use endpoint-specific required fields, or default to fullName for backward compatibility + const requiredFields = consumerSchema.requiredFields || ['fullName']; + // Validate GET response schema without generating JSON file + const consumerCapture = this.validateEndpointResponseSilent(consumerEndpointName); + if (!consumerCapture) { + throw new Error(`No compare endpoint found`); + } + if (!producerCapture) { + throw new Error('No base endpoint found'); + } + // Generate comparison JSON file similar to validateEndpointResponse + const stepCounterObj = globalThis.__stepCounter; + if (stepCounterObj) { + // Increment for JSON file naming (this is correct behavior) + const stepNumber = stepCounterObj.increment(); + const currentStepName = stepCounterObj.getCurrentStepName(); + // Create comparison data object + const comparisonData = { + _comparison: { + description: `Data consistency comparison for ${consumerEndpointName}`, + timestamp: new Date().toISOString(), + fieldsValidated: validationFields, + requiredFields, + }, + original: { + url: producerCapture.url, + method: producerCapture.method, + timestamp: producerCapture.timestamp, + responseBody: producerCapture.responseBody, + }, + new: { + url: consumerCapture.url, + method: consumerCapture.method, + timestamp: consumerCapture.timestamp, + responseBody: consumerCapture.responseBody, + }, + }; + // Create consistent filename with step number and step name (like screenshots) + const stepNameForFile = currentStepName + ? currentStepName.toLowerCase().replace(/[^a-z0-9]/g, '-') + : consumerEndpointName.replace(/[^a-z0-9]/gi, '-'); + const fileName = `step-${stepNumber.toString().padStart(2, '0')}-${stepNameForFile}-comparison.json`; + // Save the comparison data using the unified approach + const { testInfo } = globalThis; + await this.saveApiResponse(comparisonData, consumerCapture.url, consumerCapture.method, fileName, testInfo); + } + // Validate data consistency using the determined validation fields and required fields + this.validateDataConsistency(producerCapture, consumerCapture, validationFields, requiredFields); + } +} +export function createNetworkHelper(page) { + return new NetworkHelper(page); +} diff --git a/dist/tests/fixtures/patient-helpers.d.ts b/dist/tests/fixtures/patient-helpers.d.ts new file mode 100644 index 0000000..03cb4d8 --- /dev/null +++ b/dist/tests/fixtures/patient-helpers.d.ts @@ -0,0 +1,18 @@ +import { test as base } from '@fixtures/base'; +import PatientNav from '@pom/patient/PatientNavigation'; +import type { Page } from '@playwright/test'; +/** + * Initialize patient navigation helpers after login + */ +declare function setupPatientSession(page: Page): Promise; +/** + * New scalable navigation function using state machine approach + */ +declare function navigateTo(targetPage: keyof PatientNav['pages'], page: Page): Promise; +declare const test: typeof base & { + patient: { + navigateTo: typeof navigateTo; + setup: typeof setupPatientSession; + }; +}; +export { test }; diff --git a/dist/tests/fixtures/patient-helpers.js b/dist/tests/fixtures/patient-helpers.js new file mode 100644 index 0000000..9e06284 --- /dev/null +++ b/dist/tests/fixtures/patient-helpers.js @@ -0,0 +1,477 @@ +import { test as base } from '@fixtures/base'; +import PatientNav from '@pom/patient/PatientNavigation'; +import env from '../../utilities/env'; +/** + * Initialize patient navigation helpers after login + */ +async function setupPatientSession(page) { + // Wait for patient navigation to be available + const nav = new PatientNav(page); + await Promise.all([ + nav.pages.ViewData.link.waitFor({ state: 'visible' }), + nav.pages.Profile.link.waitFor({ state: 'visible' }), + ]); + return nav; +} +/** + * Close any open modal dialogs that might block navigation + */ +async function closeOpenDialogs(page) { + try { + if (page.isClosed()) + return; + // Simple and fast: just press Escape twice to close any modals + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); + } + catch (error) { + // Ignore errors in dialog closing - they're not critical + } +} +/** + * Check if we're in a context where patient navigation is supported + */ +async function isInPatientContext(nav, page) { + try { + // Check if any patient navigation elements are visible + const patientElements = [nav.pages.ViewData.link, nav.pages.Profile.link, nav.pages.Share.link]; + for (const element of patientElements) { + if (await element.isVisible({ timeout: 1000 })) { + return true; + } + } + return false; + } + catch { + return false; + } +} +/** + * Get current page state by checking URL and visible elements + */ +async function getCurrentPageState(nav, page) { + const url = page.url(); + // Check each page in order of specificity + for (const [pageName, pageConfig] of Object.entries(nav.pages)) { + try { + if (pageConfig.verifyURL && url.includes(pageConfig.verifyURL)) { + if (pageConfig.verifyElement && + (await pageConfig.verifyElement.isVisible({ timeout: 1000 }))) { + return pageName; + } + } + } + catch { + // Continue checking other pages + } + } + return 'unknown'; +} +/** + * Navigation strategies for different page types + */ +const navigationStrategies = { + // Basic page navigation + default: [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'check-patient-context', + condition: async (state) => !(await isInPatientContext(state.nav, state.page)), + action: async (state) => { + console.log('Not in patient context, navigating to /data URL to reset'); + // Navigate to /data endpoint specifically, not just base URL + await state.page.goto(`${env.BASE_URL}/data`); + await state.page.waitForLoadState('domcontentloaded'); + // Wait for patient navigation to be available + await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); + console.log('Successfully reset to patient context via /data URL'); + }, + }, + { + name: 'wait-for-loading', + action: async (state) => { + const loading = state.page.getByText('Loading...', { exact: true }); + try { + await loading.waitFor({ state: 'hidden', timeout: 3000 }); + } + catch { + // Loading might not be visible + } + }, + }, + { + name: 'navigate-click', + action: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + await pageConfig.link.click({ timeout: 5000 }); + }, + }, + { + name: 'verify-navigation', + verify: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + if (pageConfig.verifyElement) { + try { + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + return true; + } + catch { + return false; + } + } + return true; + }, + }, + ], + // Profile page - handle account settings conflict + Profile: [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'check-patient-context', + condition: async (state) => !(await isInPatientContext(state.nav, state.page)), + action: async (state) => { + console.log('Not in patient context, navigating to /data URL to reset'); + // Navigate to /data endpoint specifically, not just base URL + await state.page.goto(`${env.BASE_URL}/data`); + await state.page.waitForLoadState('domcontentloaded'); + // Wait for patient navigation to be available + await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); + console.log('Successfully reset to patient context via /data URL'); + }, + }, + { + name: 'handle-account-settings-conflict', + condition: async (state) => state.page.url().includes('/profile') && + (await state.page + .getByRole('heading', { name: /account/i }) + .or(state.page.getByText('Account Settings')) + .or(state.page.getByText('Account')) + .or(state.page.locator('.profile-subnav-title').getByText('Account')) + .isVisible() + .catch(() => false)), + action: async (state) => { + console.log('On account settings page, redirecting to base URL first'); + await state.page.goto(env.BASE_URL); + await state.page.waitForTimeout(500); + }, + }, + { + name: 'navigate-click', + action: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + await pageConfig.link.click({ timeout: 5000 }); + }, + }, + { + name: 'verify-navigation', + verify: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + if (pageConfig.verifyElement) { + try { + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + return true; + } + catch { + return false; + } + } + return true; + }, + }, + ], + // Modal dialogs + modal: [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'navigate-click', + action: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + await pageConfig.link.click({ timeout: 5000 }); + }, + }, + { + name: 'wait-for-modal', + action: async (state) => { + await state.page.waitForTimeout(500); + }, + }, + ], + // Data pages that need ViewData prerequisite + 'data-page': [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'ensure-data-view', + condition: async (state) => !state.page.url().includes('/data/'), + action: async (state) => { + await state.nav.pages.ViewData.link.click(); + await state.nav.pages.ViewData.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + }, + }, + { + name: 'navigate-click', + action: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + await pageConfig.link.click({ timeout: 5000 }); + }, + }, + { + name: 'verify-navigation', + verify: async (state) => { + const pageConfig = state.nav.pages[state.targetPage]; + if (pageConfig.verifyElement) { + try { + await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + return true; + } + catch { + return false; + } + } + return true; + }, + }, + ], + // ShareData requires Share main page to be accessible first + ShareData: [ + { + name: 'close-dialogs', + action: async (state) => closeOpenDialogs(state.page), + }, + { + name: 'check-patient-context', + condition: async (state) => !(await isInPatientContext(state.nav, state.page)), + action: async (state) => { + console.log('Not in patient context, navigating to /data URL to reset'); + await state.page.goto(`${env.BASE_URL}/data`); + await state.page.waitForLoadState('domcontentloaded'); + await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); + console.log('Successfully reset to patient context via /data URL'); + }, + }, + { + name: 'ensure-share-dependency', + action: async (state) => { + // First ensure Share main page is accessible + try { + await state.nav.pages.Share.link.waitFor({ state: 'visible', timeout: 3000 }); + console.log('Share dependency met - Share button is accessible'); + } + catch { + console.log('Share dependency not met - performing URL reset to /data'); + await state.page.goto(`${env.BASE_URL}/data`); + await state.page.waitForLoadState('domcontentloaded'); + await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); + console.log('URL reset completed, Share dependency should now be available'); + } + }, + }, + { + name: 'navigate-to-share-first', + action: async (state) => { + // Navigate to Share main page first to establish context + try { + await state.nav.pages.Share.link.click({ timeout: 3000 }); + await state.nav.pages.Share.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); + console.log('Successfully navigated to Share main page'); + } + catch { + console.log('Could not reach Share main page, staying in current state'); + } + }, + }, + { + name: 'navigate-to-sharedata', + action: async (state) => { + // Now try to navigate to ShareData sub-page + try { + await state.nav.pages.ShareData.link.click({ timeout: 5000 }); + console.log('Successfully clicked ShareData button'); + } + catch { + console.log('ShareData button not available - this is expected and OK'); + } + }, + }, + { + name: 'verify-navigation', + verify: async (state) => { + // Try to verify ShareData first, fall back to Share if not available + try { + await state.nav.pages.ShareData.verifyElement.waitFor({ + state: 'visible', + timeout: 3000, + }); + console.log('āœ… ShareData page verified'); + return true; + } + catch { + try { + await state.nav.pages.Share.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); + console.log('āœ… Share main page verified (ShareData not available - this is OK)'); + return true; + } + catch { + console.log('Neither ShareData nor Share page could be verified'); + return false; + } + } + }, + }, + ], +}; +/** + * Page type mappings to determine which strategy to use + */ +const pageStrategies = { + ViewData: 'default', + Basics: 'data-page', + Daily: 'data-page', + BGLog: 'data-page', + Trends: 'data-page', + Devices: 'data-page', + Profile: 'Profile', + ProfileEdit: 'default', // TODO: Add prerequisite logic + Share: 'default', + ShareData: 'ShareData', // Uses dependency-aware strategy + UploadData: 'default', + ChartDateRange: 'modal', + ChartDate: 'modal', + Print: 'modal', +}; +/** + * Execute navigation strategy + */ +async function executeNavigationStrategy(state) { + const strategyName = pageStrategies[state.targetPage] || 'default'; + const strategy = navigationStrategies[strategyName]; + console.log(`Executing ${strategyName} strategy for ${state.targetPage}`); + for (const step of strategy) { + try { + // Check condition if present + if (step.condition && !(await step.condition(state))) { + console.log(`Skipping step ${step.name} - condition not met`); + continue; + } + console.log(`Executing step: ${step.name}`); + // Execute action if present + if (step.action) { + await step.action(state); + } + // Verify if present + if (step.verify && !(await step.verify(state))) { + console.log(`Step ${step.name} verification failed`); + return false; + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`Step ${step.name} failed:`, errorMessage); + return false; + } + } + return true; +} +/** + * New scalable navigation function using state machine approach + */ +async function navigateTo(targetPage, page) { + if (page.isClosed()) { + console.log(`Page is closed, cannot navigate to ${targetPage}`); + return; + } + const nav = new PatientNav(page); + const currentPage = await getCurrentPageState(nav, page); + const state = { + currentPage, + targetPage, + nav, + page, + }; + console.log(`Navigating from ${currentPage} to ${targetPage}`); + // Execute primary navigation strategy + const success = await executeNavigationStrategy(state); + if (!success) { + console.log(`Primary navigation failed, trying fallback strategies`); + // Fallback strategy - go to base URL and try again + if (targetPage === 'Profile') { + try { + console.log('Profile fallback: going to base URL and trying again'); + await page.goto(env.BASE_URL); + await page.waitForTimeout(500); + await nav.pages[targetPage].link.click({ timeout: 3000 }); + console.log(`Successfully navigated to ${targetPage} via fallback`); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`Profile fallback failed: ${errorMessage}`); + throw error; + } + } + else if (nav.pages[targetPage].verifyURL) { + // Generic URL fallback for pages with backup URLs + try { + let fallbackURL = env.BASE_URL; + // For sub-pages that might not be available, fall back to the main page + if (targetPage === 'ShareData') { + fallbackURL = `${env.BASE_URL}/share`; // Fall back to main Share page + } + else if (targetPage === 'ProfileEdit') { + fallbackURL = `${env.BASE_URL}/profile`; // Fall back to main Profile page + } + else if (['Basics', 'Daily', 'BGLog', 'Trends', 'Devices'].includes(targetPage)) { + fallbackURL = `${env.BASE_URL}/data`; // Fall back to main ViewData page + } + else if (nav.pages[targetPage].verifyURL) { + fallbackURL = `${env.BASE_URL}/${nav.pages[targetPage].verifyURL}`; + } + await page.goto(fallbackURL); + console.log(`Used backup URL for ${targetPage}: ${fallbackURL}`); + // For sub-pages that fall back to main pages, verify the main page elements + let { verifyElement } = nav.pages[targetPage]; + if (targetPage === 'ShareData') { + verifyElement = nav.pages.Share.verifyElement; // Verify main Share page instead + } + else if (targetPage === 'ProfileEdit') { + verifyElement = nav.pages.Profile.verifyElement; // Verify main Profile page instead + } + else if (['Basics', 'Daily', 'BGLog', 'Trends', 'Devices'].includes(targetPage)) { + verifyElement = nav.pages.ViewData.verifyElement; // Verify main ViewData page instead + } + // Wait for the fallback page to actually load and verify we're there + if (verifyElement) { + await verifyElement.waitFor({ + state: 'visible', + timeout: 10000, + }); + console.log(`āœ… Backup URL navigation to ${targetPage} verified successfully (using fallback verification)`); + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.log(`Backup URL failed: ${errorMessage}`); + throw error; + } + } + else { + throw new Error(`Navigation to ${targetPage} failed and no fallback available`); + } + } +} +const test = base; +test.patient = { + navigateTo, + setup: setupPatientSession, +}; +export { test }; diff --git a/dist/tests/fixtures/test-tags.d.ts b/dist/tests/fixtures/test-tags.d.ts new file mode 100644 index 0000000..8b9da8a --- /dev/null +++ b/dist/tests/fixtures/test-tags.d.ts @@ -0,0 +1,60 @@ +/** + * Test Tags Fixture + * + * Simple tag definitions for test organization and Xray integration. + */ +export declare const TEST_TAGS: { + /** + * Generate a Jira-related tag for linking tests to Jira tickets. + * Usage: TEST_TAGS.RELATED('JIRA-1234') => '@jira(JIRA-1234)' + */ + RELATED: (jiraId: string) => string; + BACK_SHORELINE: string; + BACK_CLINIC: string; + BACK_HIGHWATER: string; + BACK_HYDROPHONE: string; + BACK_PLATFORM: string; + BACK_SEAGULL: string; + BACK_TIDEWHISPERER: string; + BACK_MESSAGEAPI: string; + BACK_JELLYFISH: string; + BACK_GATEKEEPER: string; + BACK_EXPORT: string; + BACK_KEYCLOAK: string; + PATIENT: string; + CLINICIAN: string; + CUSTODIAL: string; + SHARED_MEMBER: string; + PERSONAL: string; + CLAIMED: string; + API: string; + UI: string; + SMOKE: string; + REGRESSION: string; + CRITICAL: string; + HIGH: string; + MEDIUM: string; + LOW: string; + API_PROFILE: string; + API_USER: string; +}; +export declare const TAG_CATEGORIES: { + USER_TYPES: string[]; + TEST_TYPES: string[]; + PRIORITIES: string[]; +}; +/** + * Validates that tags include at least one from each required category + * @param tags Array of tags to validate + * @returns Object with validation results + */ +export declare function validateRequiredTags(tags: string[]): { + isValid: boolean; + missing: string[]; + message: string; +}; +/** + * Helper function to create tags with validation + * Throws error if required tags are missing + */ +export declare function createValidatedTags(tags: string[]): string[]; diff --git a/dist/tests/fixtures/test-tags.js b/dist/tests/fixtures/test-tags.js new file mode 100644 index 0000000..26b2aa7 --- /dev/null +++ b/dist/tests/fixtures/test-tags.js @@ -0,0 +1,93 @@ +/** + * Test Tags Fixture + * + * Simple tag definitions for test organization and Xray integration. + */ +export const TEST_TAGS = { + /** + * Generate a Jira-related tag for linking tests to Jira tickets. + * Usage: TEST_TAGS.RELATED('JIRA-1234') => '@jira(JIRA-1234)' + */ + RELATED: (jiraId) => { + // Accepts formats like ABC-1234 or JIRA-1234 + const jiraPattern = /^[A-Z][A-Z0-9]+-\d+$/; + if (!jiraPattern.test(jiraId)) { + throw new Error(`Invalid Jira ID: ${jiraId}. Must match pattern ABC-1234.`); + } + return `@jira(${jiraId})`; + }, + // Backend Services + BACK_SHORELINE: '@back-shoreline', + BACK_CLINIC: '@back-clinic', + BACK_HIGHWATER: '@back-highwater', + BACK_HYDROPHONE: '@back-hydrophone', + BACK_PLATFORM: '@back-platform', + BACK_SEAGULL: '@back-seagull', + BACK_TIDEWHISPERER: '@back-tidewhisperer', + BACK_MESSAGEAPI: '@back-messageapi', + BACK_JELLYFISH: '@back-jellyfish', + BACK_GATEKEEPER: '@back-gatekeeper', + BACK_EXPORT: '@back-export', + BACK_KEYCLOAK: '@back-keycloak', + // User Types + PATIENT: '@patient', + CLINICIAN: '@clinician', + // User-Subtypes + CUSTODIAL: '@custodial', + SHARED_MEMBER: '@shared_member', + PERSONAL: '@personal', + CLAIMED: '@claimed', + // Test Types + API: '@api', + UI: '@ui', + SMOKE: '@smoke', + REGRESSION: '@regression', + // Priority + CRITICAL: '@critical', + HIGH: '@high', + MEDIUM: '@medium', + LOW: '@low', + // Endpoint API Testing + API_PROFILE: '@api_profile', + API_USER: '@api_user', +}; +// Tag Categories for Validation +export const TAG_CATEGORIES = { + USER_TYPES: [TEST_TAGS.PATIENT, TEST_TAGS.CLINICIAN], + TEST_TYPES: [TEST_TAGS.API, TEST_TAGS.UI, TEST_TAGS.SMOKE, TEST_TAGS.REGRESSION], + PRIORITIES: [TEST_TAGS.CRITICAL, TEST_TAGS.HIGH, TEST_TAGS.MEDIUM, TEST_TAGS.LOW], +}; +/** + * Validates that tags include at least one from each required category + * @param tags Array of tags to validate + * @returns Object with validation results + */ +export function validateRequiredTags(tags) { + const hasUserType = tags.some(tag => TAG_CATEGORIES.USER_TYPES.includes(tag)); + const hasTestType = tags.some(tag => TAG_CATEGORIES.TEST_TYPES.includes(tag)); + const hasPriority = tags.some(tag => TAG_CATEGORIES.PRIORITIES.includes(tag)); + const isValid = hasUserType && hasTestType && hasPriority; + const missing = []; + if (!hasUserType) + missing.push('User Type'); + if (!hasTestType) + missing.push('Test Type'); + if (!hasPriority) + missing.push('Priority'); + return { + isValid, + missing, + message: isValid ? 'All required tags present' : `Missing required tags: ${missing.join(', ')}`, + }; +} +/** + * Helper function to create tags with validation + * Throws error if required tags are missing + */ +export function createValidatedTags(tags) { + const validation = validateRequiredTags(tags); + if (!validation.isValid) { + throw new Error(`Test tags validation failed: ${validation.message}`); + } + return tags; +} diff --git a/dist/tests/global-setup.d.ts b/dist/tests/global-setup.d.ts new file mode 100644 index 0000000..b9988ec --- /dev/null +++ b/dist/tests/global-setup.d.ts @@ -0,0 +1,2 @@ +import { FullConfig } from '@playwright/test'; +export default function globalSetup(_config: FullConfig): Promise; diff --git a/dist/tests/global-setup.js b/dist/tests/global-setup.js new file mode 100644 index 0000000..4cd1e80 --- /dev/null +++ b/dist/tests/global-setup.js @@ -0,0 +1,41 @@ +import { chromium } from '@playwright/test'; +import LoginPage from '@pom/LoginPage'; +import fs from 'node:fs'; +import path from 'node:path'; +import env from '../utilities/env'; +async function loginUserType(role) { + const browser = await chromium.launch(); + const context = await browser.newContext({ + baseURL: process.env.BASE_URL, + }); + const page = await context.newPage(); + await page.goto(env.BASE_URL); + const loginPage = new LoginPage(page); + if (role === 'personal') { + await loginPage.login(env.PERSONAL_USERNAME, env.PERSONAL_PASSWORD); + await page.waitForURL('**/data'); + } + else if (role === 'claimed') { + await loginPage.login(env.CLAIMED_USERNAME, env.CLAIMED_PASSWORD); + await page.waitForURL('**/data'); + } + else if (role === 'shared') { + await loginPage.login(env.SHARED_USERNAME, env.SHARED_PASSWORD); + await page.waitForURL('**/data'); + } + else { + await loginPage.login(env.CLINICIAN_USERNAME, env.CLINICIAN_PASSWORD); + await page.waitForURL('**/workspaces'); + } + const authDir = path.resolve(process.cwd(), 'tests', '.auth'); + await fs.promises.mkdir(authDir, { recursive: true }); + const filePath = path.join(authDir, `${role}.json`); + await context.storageState({ path: filePath }); + await browser.close(); +} +export default async function globalSetup(_config) { + await loginUserType('personal'); + await loginUserType('claimed'); + await loginUserType('shared'); + await loginUserType('clinician'); +} diff --git a/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.d.ts b/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.js b/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.js new file mode 100644 index 0000000..6027330 --- /dev/null +++ b/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.js @@ -0,0 +1,73 @@ +import { test } from '../../fixtures/patient-helpers'; +import { createNetworkHelper } from '../../fixtures/network-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; +test.describe('Personal Accounts allow access and modification of profile details', () => { + // API Test cases require this to capture network activity + let api; + test('should allow navigation to profile details and edit profile fields', { + tag: createValidatedTags([ + TEST_TAGS.PATIENT, // User Type (required) + TEST_TAGS.PERSONAL, // User Subtype (required) + TEST_TAGS.API, // Test Type (required) + TEST_TAGS.UI, // Test Type (required) + TEST_TAGS.HIGH, // Priority (required) + TEST_TAGS.API_PROFILE, // Feature (optional) + ]), + }, async ({ page }) => { + // Step 1: Log in to personal account and setup network capture + await test.step('Given personal account has been logged in', async () => { + api = createNetworkHelper(page); + await api.startCapture(); + await page.goto('/data'); + await test.patient.setup(page); + // Step 2: Navigate to profile + await test.step('When user navigates to Profile page', async () => { + await test.patient.navigateTo('Profile', page); + }); + // Step 3: Check profile GET response + await test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + // Step 4: Open Edit Profile + await test.step('When user selects Edit button', async () => { + await test.patient.navigateTo('ProfileEdit', page); + }); + // Initialize ProfilePage for steps 4 and 5 + const profilePage = new ProfilePage(page); + // Step 5: Change profile fields (confirmed user access) + await test.step('When user updates profile fields', async () => { + // Generate completely unique values for this confirmed user test run + const testRunId = Math.floor(Math.random() * 10000); + const updatedName = `Personal Patient Updated ${testRunId}`; + const birthYear = 1985 + (testRunId % 10); + const diagnosisYear = birthYear + 20; + const birthDate = `01/15/${birthYear}`; + const diagnosisDate = `03/10/${diagnosisYear}`; + // Generate random 15-letter string for clinical notes + const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); + // Get current diagnosis index and calculate next one (1-7, wrapping) + const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); + let nextDiagnosisIndex = currentDiagnosisIndex + 1; + if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { + nextDiagnosisIndex = 1; + } + // Update fields using ProfilePage methods + await profilePage.fillFullName(updatedName); + await profilePage.fillBirthDate(birthDate); + await profilePage.fillDiagnosisDate(diagnosisDate); + await profilePage.selectDiagnosisType(nextDiagnosisIndex); + await profilePage.fillClinicalNotes(randomString); + }); + // Step 6: Save profile edit + await test.step('When user saves profile changes', async () => { + await profilePage.saveProfile(); + }); + // Step 7: Check profile PUT response + await test.stepNoScreenshot('Then profile endpoint responds with PUT request consistent with schema', async () => { + await api.validateEndpointResponse('profile-metadata-put'); + }); + await api.stopCapture(); + }); + }); +}); diff --git a/dist/tests/personal/basic-functionality.spec.d.ts b/dist/tests/personal/basic-functionality.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/personal/basic-functionality.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/personal/basic-functionality.spec.js b/dist/tests/personal/basic-functionality.spec.js new file mode 100644 index 0000000..84da7d1 --- /dev/null +++ b/dist/tests/personal/basic-functionality.spec.js @@ -0,0 +1,235 @@ +// @ts-check +import { expect, test } from '@fixtures/base'; +import PatientDataBasicsPage from '@pom/patient/BasicsPage'; +import PatientDataDailyPage from '@pom/patient/DailyPage'; +test.describe('Patient Data Navigation and Visualization', () => { + test.beforeEach(async ({ page }) => { + await test.step('Given user has been logged in', async () => { + const basicsPage = new PatientDataBasicsPage(page); + await basicsPage.goto(); + // await page.getByText("Loading").waitFor({ state: "detached", timeout: 10000 }); + }); + }); + // BG readings dashboard functionality + test('should display daily chart when selecting a date from basics page', async ({ page }) => { + const basicsPage = new PatientDataBasicsPage(page); + const dailyPage = new PatientDataDailyPage(page); + let selectedDateText; + await test.step('When the navigation bar is visible', async () => { + await basicsPage.navigationBar.buttons.viewData.waitFor({ + state: 'visible', + }); + }); + await test.step('When the user clicks on the most recent day', async () => { + const recentDayElement = basicsPage.bgReadingsSection.firstDayOfData; + await recentDayElement.waitFor({ state: 'visible' }); + await recentDayElement.hover(); + selectedDateText = await basicsPage.bgReadingsSection.calendarDayhover.text(); + await basicsPage.bgReadingsSection.calendarDayhover.el.click(); + }); + await test.step('Then the daily chart is visible and correctly rendered', async () => { + const chartContainer = dailyPage.dailyChart.container; + await chartContainer.waitFor({ state: 'visible' }); + if (!selectedDateText) { + throw new Error('Selected date text is null'); + } + // Verify the selected date matches the displayed date + // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); + // Capture chart screenshot for visual regression + await expect(chartContainer).toHaveScreenshot('daily-chart-1.png'); + }); + }); + // Bolus dashboard functionality + test('should display bolus dashboard when selecting a date from basics page', async ({ page, }) => { + const basicsPage = new PatientDataBasicsPage(page); + const dailyPage = new PatientDataDailyPage(page); + let selectedDateText; + await test.step('When the navigation bar is visible', async () => { + await basicsPage.navigationBar.buttons.viewData.waitFor({ + state: 'visible', + }); + }); + await test.step('When the user clicks on the most recent day', async () => { + const recentDayElement = basicsPage.bolusingSection.firstDayOfData; + await recentDayElement.waitFor({ state: 'visible' }); + await recentDayElement.hover(); + selectedDateText = await basicsPage.bolusingSection.calendarDayhover.text(); + await basicsPage.bolusingSection.calendarDayhover.el.click(); + }); + await test.step('Then the daily chart is visible and correctly rendered', async () => { + const chartContainer = dailyPage.dailyChart.container; + await chartContainer.waitFor({ state: 'visible' }); + if (!selectedDateText) { + throw new Error('Selected date text is null'); + } + // Verify the selected date matches the displayed date + // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); + // Capture chart screenshot for visual regression + await expect(chartContainer).toHaveScreenshot('daily-chart-2.png'); + }); + }); + // Infusion Site Changes dashboard functionality + test('should display Infusion site changes dashboard when selecting a date from basics page', async ({ page, }) => { + const basicsPage = new PatientDataBasicsPage(page); + const dailyPage = new PatientDataDailyPage(page); + let selectedDateText; + await test.step('When the infusion site changes dashboard is visible', async () => { + // Verify dashboard title and initial state + // await expect(basicsPage.tubingPrimeSection.title).toBeVisible(); + // await expect(basicsPage.tubingPrimeSection.description).toHaveText( + // "We are using Fill Cannula to visualize your infusion site changes." + // ); + }); + await test.step('When testing Fill Cannula functionality', async () => { + // Verify radio button options + await basicsPage.tubingPrimeSection.settingsOption.fillCannula.waitFor({ + state: 'visible', + timeout: 60000, + }); + await expect(basicsPage.tubingPrimeSection.settingsOption.fillCannula).toBeVisible(); + await expect(basicsPage.tubingPrimeSection.settingsOption.fillTubing).toBeVisible(); + // Select Fill Cannula and verify highlighted days + await basicsPage.tubingPrimeSection.settingsOption.fillCannula.click(); + // // Verify duration indicator is visible + // await expect( + // basicsPage.tubingPrimeSection.durationIndicator + // ).toContainText("4 days"); + // Verify cannula icons are visible and tubing icons are not + await expect(basicsPage.tubingPrimeSection.cannulaIcons).toBeAttached(); + await expect(basicsPage.tubingPrimeSection.tubingIcons).not.toBeAttached(); + // Select a highlighted day + const highlightedDay = basicsPage.tubingPrimeSection.filledDay; + await highlightedDay.hover(); + selectedDateText = await basicsPage.tubingPrimeSection.calendarDayhover.text(); + await basicsPage.tubingPrimeSection.calendarDayhover.el.click(); + }); + await test.step('Then the daily chart shows correct cannula fill date', async () => { + const chartContainer = dailyPage.dailyChart.container; + await chartContainer.waitFor({ state: 'visible' }); + if (!selectedDateText) { + throw new Error('Selected date text is null'); + } + // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); + await expect(chartContainer).toHaveScreenshot('daily-chart-cannula.png'); + }); + // Return to basics page and test Fill Tubing Option + await test.step('When testing Fill Tubing functionality', async () => { + // Navigate back to basics + await test.step('When the navigation bar is visible', async () => { + await basicsPage.navigationBar.buttons.viewData.waitFor({ + state: 'visible', + }); + }); + // await basicsPage.navigationSubMenu.links.basics.click(); + await basicsPage.tubingPrimeSection.settings.waitFor({ + state: 'visible', + }); + // Click settings and select Fill Tubing + await basicsPage.tubingPrimeSection.settings.click(); + await basicsPage.tubingPrimeSection.settingsOption.fillTubing.click(); + // Verify filled tubing day is visible and cannula day is not + await expect(basicsPage.tubingPrimeSection.tubingIcons).toBeAttached(); + await expect(basicsPage.tubingPrimeSection.cannulaIcons).not.toBeAttached(); + // Click on the most recent day with tubing fill + const tubingDay = basicsPage.tubingPrimeSection.filledDay; + await tubingDay.hover(); + selectedDateText = await basicsPage.tubingPrimeSection.calendarDayhover.text(); + await basicsPage.tubingPrimeSection.calendarDayhover.el.click(); + }); + await test.step('Then the daily chart shows correct tubing fill date', async () => { + const chartContainer = dailyPage.dailyChart.container; + await chartContainer.waitFor({ state: 'visible' }); + if (!selectedDateText) { + throw new Error('Selected date text is null'); + } + // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); + await expect(chartContainer).toHaveScreenshot('daily-chart-tubing.png'); + }); + }); + // TODO: Previous test doesn't test values. Should we? :) + // Readings in range functionality + test('The hover over elements in sidebar shows correct values', async ({ page }) => { + // Stats for BGM + const expectedHeadersReadingInRange = [ + { header: 'Readings Below Range', value: 3 }, + { header: 'Readings Below Range', value: 0 }, + { header: 'Readings In Range', value: 71 }, + { header: 'Readings Above Range', value: 24 }, + { header: 'Readings Above Range', value: 2 }, + ]; + const basicsPage = new PatientDataBasicsPage(page); + await test.step('When the navigation bar is visible', async () => { + await basicsPage.navigationBar.buttons.viewData.waitFor({ + state: 'visible', + }); + }); + // Other BGM tooltip functionality + await basicsPage.statsSidebar.toggleTo('BGM'); + for (let i = 0; i < 5; i += 1) { + const bar = basicsPage.statsSidebar.readingsInRange.hoverBar.nth(i); + const barLabel = basicsPage.statsSidebar.readingsInRange.hoverBarLabel.nth(i); + await test.step('When the user hovers over the Avg. Daily Readings In Range chart', async () => { + await bar.hover(); + }); + await test.step('Then the correct header is visible', async () => { + await expect + .soft(basicsPage.statsSidebar.readingsInRange.header) + .toContainText(expectedHeadersReadingInRange[i].header); + }); + await test.step('Then the correct value is visible', async () => { + await expect + .soft(barLabel) + .toContainText(expectedHeadersReadingInRange[i].value.toString()); + }); + } + // Stats for CGM + // Time in range functionality + const expectedHeadersTimeInRange = [ + { header: 'Time Below Range', value: 0.1 }, + { header: 'Time Below Range', value: 1 }, + { header: 'Time In Range', value: 90 }, + { header: 'Time Above Range', value: 9 }, + { header: 'Time Above Range', value: 0.3 }, + ]; + await basicsPage.statsSidebar.toggleTo('CGM'); + for (let i = 0; i < expectedHeadersTimeInRange.length; i += 1) { + const bar = basicsPage.statsSidebar.timeInRange.hoverBar.nth(i); + const barLabel = basicsPage.statsSidebar.timeInRange.hoverBarLabel.nth(i); + await test.step('When the user hovers over the Avg. Daily Time In Range chart', async () => { + await bar.hover(); + }); + await test.step('Then the correct header is visible', async () => { + await expect + .soft(basicsPage.statsSidebar.timeInRange.header) + .toContainText(expectedHeadersTimeInRange[i].header); + }); + await test.step('Then the correct value is visible', async () => { + await expect.soft(barLabel).toContainText(expectedHeadersTimeInRange[i].value.toString()); + }); + } + }); + // Other CGM tooltip functionality + test('other CGM tooltip functionality', async ({ page }) => { + const basicsPage = new PatientDataBasicsPage(page); + await basicsPage.statsSidebar.toggleTo('CGM'); + const expectedHeadersTimeInRange = [ + { header: 'Basal Insulin', value: 14.7, percentage: 44 }, + { header: 'Bolus Insulin', value: 18.8, percentage: 56 }, + ]; + for (let i = 0; i < expectedHeadersTimeInRange.length; i += 1) { + const bar = basicsPage.statsSidebar.totalInsulin.hoverBar.nth(i); + const barLabel = basicsPage.statsSidebar.totalInsulin.hoverBarLabel.nth(i); + await test.step('When the user hovers over the Avg. Daily Total Insulin chart', async () => { + await bar.hover(); + }); + await test.step('Then the correct header is visible', async () => { + await expect + .soft(basicsPage.statsSidebar.timeInRange.header) + .toContainText(expectedHeadersTimeInRange[i].header); + }); + await test.step('Then the correct value is visible', async () => { + await expect.soft(barLabel).toContainText(expectedHeadersTimeInRange[i].value.toString()); + }); + } + }); +}); diff --git a/dist/tests/personal/login.spec.d.ts b/dist/tests/personal/login.spec.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/tests/personal/login.spec.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/tests/personal/login.spec.js b/dist/tests/personal/login.spec.js new file mode 100644 index 0000000..c9ece3c --- /dev/null +++ b/dist/tests/personal/login.spec.js @@ -0,0 +1,61 @@ +// @ts-check +import { expect, test } from '@fixtures/base'; +import LoginPage from 'page-objects/LoginPage'; +import WorkspacesPage from '@pom/clinician/WorkspacesPage'; +import env from '../../utilities/env'; +// make sure we don't have any cookies or origins +test.use({ storageState: { cookies: [], origins: [] } }); +// Possible testcases: https://tidepool.atlassian.net/jira/software/c/projects/WEB/issues/?jql=project%20%3D%20%22WEB%22%20AND%20type%20%3D%20Test%20AND%20textfields%20~%20%22login%22%20ORDER%20BY%20created%20DESC +test.describe('Login into application', () => { + test('should work with valid credentials for clinician with multiple clinics', async ({ page, }) => { + const loginPage = new LoginPage(page); + await test.step('When user is logged into application', async () => { + await loginPage.goto(); + await loginPage.login(env.CLINICIAN_USERNAME, env.CLINICIAN_PASSWORD); + }); + await test.step('Then the user is redirected to workspaces page', async () => { + const workspacesPage = new WorkspacesPage(page); + await page.waitForURL(workspacesPage.url); + await expect(workspacesPage.header).toBeVisible(); + }); + }); + test('should show error message with invalid credentials', async ({ page }) => { + const loginPage = new LoginPage(page); + await test.step('When user attempts to login with invalid credentials', async () => { + await loginPage.goto(); + // Enter email + await page.fill('#username', 'invalid@email.com'); + await page.click('#kc-login'); + }); + await test.step('Then error message should be displayed', async () => { + // Wait for the error message to appear + await expect(page.locator('#input-error-username')).toBeVisible(); + await expect(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); + }); + }); + test('should validate email format', async ({ page }) => { + const loginPage = new LoginPage(page); + await test.step('When user attempts to login with invalid email format', async () => { + await loginPage.goto(); + // Enter invalid email format + await page.fill('#username', 'invalidemail'); + await page.click('#kc-login'); + }); + await test.step('Then email validation error should be displayed', async () => { + // Check for email validation error message + await expect(page.locator('#input-error-username')).toBeVisible(); + await expect(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); + }); + }); + test('should show error message with invalid credentials 1', async ({ page }) => { + const loginPage = new LoginPage(page); + await test.step('When user is logged into application', async () => { + await loginPage.goto(); + await loginPage.login(env.CLINICIAN_USERNAME, `${env.CLINICIAN_PASSWORD}1`); + }); + await test.step('Then error message should be displayed', async () => { + await expect(page.locator('#input-error')).toBeVisible(); + await expect(page.locator('#input-error')).toContainText('Invalid password.'); + }); + }); +}); diff --git a/dist/utilities/annotations.d.ts b/dist/utilities/annotations.d.ts new file mode 100644 index 0000000..915938f --- /dev/null +++ b/dist/utilities/annotations.d.ts @@ -0,0 +1,15 @@ +import { TestInfo } from '@playwright/test'; +/** + * Interface for test annotations used in JIRA integration + */ +interface TestAnnotations { + testKey: string; + testSummary: string; + requirements: string; + testDescription: string; +} +/** + * Add test annotations to the test info for JIRA integration + */ +export default function addTestAnnotations(testInfo: TestInfo, annotations: TestAnnotations): void; +export {}; diff --git a/dist/utilities/annotations.js b/dist/utilities/annotations.js new file mode 100644 index 0000000..faf1f84 --- /dev/null +++ b/dist/utilities/annotations.js @@ -0,0 +1,21 @@ +/** + * Add test annotations to the test info for JIRA integration + */ +export default function addTestAnnotations(testInfo, annotations) { + testInfo.annotations.push({ + type: 'test_key', + description: annotations.testKey, + }); + testInfo.annotations.push({ + type: 'test_summary', + description: annotations.testSummary, + }); + testInfo.annotations.push({ + type: 'requirements', + description: annotations.requirements, + }); + testInfo.annotations.push({ + type: 'test_description', + description: annotations.testDescription, + }); +} diff --git a/dist/utilities/env.d.ts b/dist/utilities/env.d.ts new file mode 100644 index 0000000..637f194 --- /dev/null +++ b/dist/utilities/env.d.ts @@ -0,0 +1,17 @@ +declare const _default: { + BASE_URL: string; + PERSONAL_USERNAME: string; + PERSONAL_PASSWORD: string; + CLAIMED_USERNAME: string; + CLAIMED_PASSWORD: string; + SHARED_USERNAME: string; + SHARED_PASSWORD: string; + CLINICIAN_USERNAME: string; + CLINICIAN_PASSWORD: string; + TARGET_ENV: "qa1" | "qa2" | "qa3" | "qa4" | "qa5" | "production" | "prd" | "int"; + BROWSERSTACK_USERNAME?: string | undefined; + BROWSERSTACK_ACCESS_KEY?: string | undefined; + XRAY_CLIENT_ID?: string | undefined; + XRAY_CLIENT_SECRET?: string | undefined; +}; +export default _default; diff --git a/dist/utilities/env.js b/dist/utilities/env.js new file mode 100644 index 0000000..5c69186 --- /dev/null +++ b/dist/utilities/env.js @@ -0,0 +1,37 @@ +import dotenv from 'dotenv'; +import z from 'zod'; +dotenv.config(); +const envSchema = z.object({ + BROWSERSTACK_USERNAME: z.string().optional(), + BROWSERSTACK_ACCESS_KEY: z.string().optional(), + PERSONAL_USERNAME: z.string(), + PERSONAL_PASSWORD: z.string(), + CLAIMED_USERNAME: z.string(), + CLAIMED_PASSWORD: z.string(), + SHARED_USERNAME: z.string(), + SHARED_PASSWORD: z.string(), + CLINICIAN_USERNAME: z.string(), + CLINICIAN_PASSWORD: z.string(), + TARGET_ENV: z.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production', 'prd', 'int']), + XRAY_CLIENT_ID: z.string().optional(), + XRAY_CLIENT_SECRET: z.string().optional(), +}); +const env = envSchema.safeParse(process.env); +if (!env.success) { + console.error('āŒ Invalid environment variables:\n', env.error.format()); + throw new Error('Invalid environment variables. Check your .env file.'); +} +const URL_MAP = { + qa1: 'https://qa1.development.tidepool.org', + qa2: 'https://qa2.development.tidepool.org', + qa3: 'https://qa3.development.tidepool.org', + qa4: 'https://qa4.development.tidepool.org', + qa5: 'https://qa5.development.tidepool.org', + production: 'https://app.tidepool.org', + prd: 'https://app.tidepool.org', // Alias for production + int: 'https://int.development.tidepool.org', // Integration environment +}; +export default { + ...env.data, + BASE_URL: URL_MAP[env.data.TARGET_ENV], +}; diff --git a/dist/utilities/xray-json-reporter.d.ts b/dist/utilities/xray-json-reporter.d.ts new file mode 100644 index 0000000..2846c31 --- /dev/null +++ b/dist/utilities/xray-json-reporter.d.ts @@ -0,0 +1,93 @@ +import { FullConfig, FullResult, Suite, TestCase, TestResult } from '@playwright/test/reporter'; +interface XrayTestStep { + action: string; + data?: string; + result?: string; + status: 'PASS' | 'FAIL' | 'PENDING'; + actualResult?: string; + evidences?: Array<{ + data: string; + filename: string; + contentType: string; + }>; +} +interface XrayTest { + testKey?: string; + testInfo: { + summary: string; + type: 'Manual' | 'Cucumber' | 'Generic'; + projectKey: string; + labels?: string[]; + }; + status: 'PASS' | 'FAIL' | 'PENDING' | 'EXECUTING'; + comment?: string; + evidences?: Array<{ + data: string; + filename: string; + contentType: string; + }>; + steps?: XrayTestStep[]; + examples?: string[]; +} +interface XrayExecutionResult { + info: { + summary: string; + description: string; + version?: string; + testPlanKey?: string; + testExecutionKey?: string; + startDate: string; + finishDate: string; + testEnvironments?: string[]; + }; + tests: XrayTest[]; +} +/** + * Unified Xray JSON Reporter for Playwright + * Maps rich Playwright test data to Xray's JSON format with step-by-step evidence + */ +declare class XrayJsonReporter { + private styles; + private startTime; + private endTime; + /** + * Authenticates with Xray API using client credentials + */ + authenticateWithXray(): Promise; + /** + * Converts file to base64 string for Xray evidence + */ + private fileToBase64; + /** + * Extracts step information from test annotations + */ + private extractSteps; + /** + * Maps Playwright test result to Xray test format + */ + private mapPlaywrightTestToXray; + /** + * Converts Playwright JSON results to Xray format + */ + convertPlaywrightJsonToXray(playwrightJsonPath: string): Promise; + /** + * Recursively processes test suites + */ + private processSuite; + /** + * Uploads Xray execution result to Xray + */ + uploadToXray(xrayResult: XrayExecutionResult): Promise; + /** + * Main method to process and upload results + */ + processAndUpload(playwrightJsonPath: string): Promise; + /** + * Reporter lifecycle methods for direct Playwright integration + */ + onBegin(_config: FullConfig, suite: Suite): void; + onTestBegin(test: TestCase, _result: TestResult): void; + onTestEnd(test: TestCase, result: TestResult): void; + onEnd(result: FullResult): Promise; +} +export default XrayJsonReporter; diff --git a/dist/utilities/xray-json-reporter.js b/dist/utilities/xray-json-reporter.js new file mode 100644 index 0000000..a6094f1 --- /dev/null +++ b/dist/utilities/xray-json-reporter.js @@ -0,0 +1,263 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import env from './env'; +/** + * Unified Xray JSON Reporter for Playwright + * Maps rich Playwright test data to Xray's JSON format with step-by-step evidence + */ +class XrayJsonReporter { + constructor() { + this.styles = { + success: 'āœ…', + error: 'āŒ', + info: 'ā„¹ļø', + warning: 'ā›”ļø', + upload: 'šŸš€', + test: '🧪', + separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + }; + this.startTime = ''; + this.endTime = ''; + } + /** + * Authenticates with Xray API using client credentials + */ + async authenticateWithXray() { + try { + console.log(`${this.styles.info} Authenticating with Xray...`); + const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: env.XRAY_CLIENT_ID, + client_secret: env.XRAY_CLIENT_SECRET, + }), + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + } + const token = await response.text(); + console.log(`${this.styles.success} Successfully authenticated with Xray`); + return token.replace(/"/g, ''); // Remove quotes from token + } + catch (error) { + console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); + throw error; + } + } + /** + * Converts file to base64 string for Xray evidence + */ + async fileToBase64(filePath) { + try { + const fileBuffer = fs.readFileSync(filePath); + return fileBuffer.toString('base64'); + } + catch (error) { + console.warn(`${this.styles.warning} Could not read file ${filePath}:`, error); + return ''; + } + } + /** + * Extracts step information from test annotations + */ + extractSteps(annotations, attachments) { + const steps = []; + const stepAnnotations = annotations.filter(ann => ann.type.startsWith('Step Duration:')); + for (const stepAnn of stepAnnotations) { + const stepName = stepAnn.type.replace('Step Duration: ', ''); + const duration = stepAnn.description; + // Find associated step attachments + const stepAttachments = attachments.filter(att => att.name.toLowerCase().includes(stepName.toLowerCase().substring(0, 20))); + const step = { + action: stepName, + data: `Duration: ${duration}`, + result: stepName.includes('Then') ? stepName : undefined, + status: 'PASS', // Will be updated based on test result + evidences: [] + }; + // Add evidence for this step + for (const attachment of stepAttachments) { + if (attachment.path && fs.existsSync(attachment.path)) { + step.evidences?.push({ + data: await this.fileToBase64(attachment.path), + filename: path.basename(attachment.path), + contentType: attachment.contentType || 'application/octet-stream' + }); + } + } + steps.push(step); + } + return steps; + } + /** + * Maps Playwright test result to Xray test format + */ + async mapPlaywrightTestToXray(testCase, testResult) { + const tags = testCase.tags || []; + const annotations = testResult.annotations || []; + const attachments = testResult.attachments || []; + // Extract steps from annotations + const steps = await this.extractSteps(annotations, attachments); + // Mark failed steps if test failed + if (testResult.status !== 'passed' && steps.length > 0) { + steps[steps.length - 1].status = 'FAIL'; + steps[steps.length - 1].actualResult = testResult.error?.message || 'Test failed'; + } + // Collect test-level evidence (screenshots, videos) + const testEvidences = []; + for (const attachment of attachments) { + if (attachment.path && fs.existsSync(attachment.path)) { + // Add main test evidence (final screenshots, videos, etc.) + if (attachment.name.includes('screenshot') || attachment.name.includes('video')) { + testEvidences.push({ + data: await this.fileToBase64(attachment.path), + filename: attachment.name, + contentType: attachment.contentType || 'application/octet-stream' + }); + } + } + } + const xrayTest = { + testInfo: { + summary: testCase.title, + type: 'Generic', + projectKey: 'XT', // Could be made configurable + labels: tags + }, + status: testResult.status === 'passed' ? 'PASS' : + testResult.status === 'skipped' ? 'PENDING' : 'FAIL', + comment: testResult.error?.message, + evidences: testEvidences, + steps: steps.length > 0 ? steps : undefined + }; + return xrayTest; + } + /** + * Converts Playwright JSON results to Xray format + */ + async convertPlaywrightJsonToXray(playwrightJsonPath) { + const jsonContent = fs.readFileSync(playwrightJsonPath, 'utf8'); + const playwrightResult = JSON.parse(jsonContent); + const tests = []; + // Process all test suites + for (const suite of playwrightResult.suites || []) { + await this.processSuite(suite, tests); + } + const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; + const targetEnv = process.env.TARGET_ENV || 'qa1'; + const xrayResult = { + info: { + summary: `Playwright Test Execution - ${new Date().toISOString()}`, + description: `Automated test execution for ${targetEnv} environment`, + version: '1.0', + testExecutionKey: testExecKey !== 'none' ? testExecKey : undefined, + startDate: playwrightResult.stats?.startTime || new Date().toISOString(), + finishDate: new Date(new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + + (playwrightResult.stats?.duration || 0)).toISOString(), + testEnvironments: [targetEnv] + }, + tests + }; + return xrayResult; + } + /** + * Recursively processes test suites + */ + async processSuite(suite, tests) { + // Process specs in this suite + for (const spec of suite.specs || []) { + for (const test of spec.tests || []) { + for (const result of test.results || []) { + const xrayTest = await this.mapPlaywrightTestToXray(spec, result); + tests.push(xrayTest); + } + } + } + // Process nested suites + for (const nestedSuite of suite.suites || []) { + await this.processSuite(nestedSuite, tests); + } + } + /** + * Uploads Xray execution result to Xray + */ + async uploadToXray(xrayResult) { + try { + console.log(`${this.styles.info} Uploading test execution to Xray...`); + const token = await this.authenticateWithXray(); + const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(xrayResult), + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + } + const result = await response.json(); + console.log(`${this.styles.success} Successfully uploaded to Xray. Execution Key: ${result.key}`); + } + catch (error) { + console.error(`${this.styles.error} Failed to upload to Xray:`, error); + throw error; + } + } + /** + * Main method to process and upload results + */ + async processAndUpload(playwrightJsonPath) { + if (!(env.XRAY_CLIENT_ID && env.XRAY_CLIENT_SECRET)) { + console.log(`${this.styles.warning} No Xray credentials found, skipping upload to JIRA Xray`); + return; + } + try { + console.log(`${this.styles.info} Processing Playwright results...`); + const xrayResult = await this.convertPlaywrightJsonToXray(playwrightJsonPath); + // Save converted result for debugging + fs.writeFileSync('test-results/xray-execution.json', JSON.stringify(xrayResult, null, 2)); + await this.uploadToXray(xrayResult); + console.log(`${this.styles.upload} Xray upload completed successfully`); + } + catch (error) { + console.error(`${this.styles.error} Failed to process and upload:`, error); + throw error; + } + } + /** + * Reporter lifecycle methods for direct Playwright integration + */ + onBegin(_config, suite) { + this.startTime = new Date().toISOString(); + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); + console.log(`${this.styles.separator}\n`); + } + onTestBegin(test, _result) { + console.log(`${this.styles.test} Starting: ${test.title}`); + } + onTestEnd(test, result) { + const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; + console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); + } + async onEnd(result) { + this.endTime = new Date().toISOString(); + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.info} Test Run Summary:`); + console.log(`Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`); + console.log(`Duration: ${result.duration}ms`); + console.log(`${this.styles.separator}\n`); + // Auto-upload if JSON results are available + const jsonPath = 'test-results/last-run.json'; + if (fs.existsSync(jsonPath)) { + await this.processAndUpload(jsonPath); + } + } +} +export default XrayJsonReporter; diff --git a/dist/utilities/xray-reporter.d.ts b/dist/utilities/xray-reporter.d.ts new file mode 100644 index 0000000..a81cd71 --- /dev/null +++ b/dist/utilities/xray-reporter.d.ts @@ -0,0 +1,44 @@ +import { FullConfig, FullResult, Suite, TestCase, TestResult } from '@playwright/test/reporter'; +/** + * Reporter class for uploading test results to Xray + */ +declare class XRayReporter { + private styles; + constructor(); + /** + * Authenticates with Xray API using client credentials + * @returns {Promise} The authentication token + * @throws {Error} If authentication fails + */ + authenticateWithXray(): Promise; + /** + * Uploads test results to Xray + * @param {string} token - The authentication token + * @param {string} xmlContent - The JUnit XML content to upload + * @returns {Promise} + * @throws {Error} If upload fails + */ + uploadTestResults(token: string, xmlContent: string): Promise; + /** + * Called when test run begins + * @param suite - Test suite object containing all tests + */ + onBegin(_config: FullConfig, suite: Suite): void; + /** + * Called when a test begins + * @param test - Test case object + */ + onTestBegin(test: TestCase, _result: TestResult): void; + /** + * Called when a test ends + * @param {Object} test - Test case object + * @param {Object} result - Test result object containing status and other details + */ + onTestEnd(test: TestCase, result: TestResult): void; + /** + * Called when all tests have finished + * @param result - Full test run result object containing status and duration + */ + onEnd(result: FullResult): Promise; +} +export default XRayReporter; diff --git a/dist/utilities/xray-reporter.js b/dist/utilities/xray-reporter.js new file mode 100644 index 0000000..523584c --- /dev/null +++ b/dist/utilities/xray-reporter.js @@ -0,0 +1,129 @@ +import fs from 'node:fs'; +import env from './env'; +/** + * Reporter class for uploading test results to Xray + */ +class XRayReporter { + constructor() { + this.styles = { + success: 'āœ…', + error: 'āŒ', + info: 'ā„¹ļø', + warning: 'ā›”ļø', + upload: 'šŸš€', + test: '🧪', + separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + }; + } + /** + * Authenticates with Xray API using client credentials + * @returns {Promise} The authentication token + * @throws {Error} If authentication fails + */ + async authenticateWithXray() { + try { + console.log(`${this.styles.info} Authenticating with Xray...`); + const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: env.XRAY_CLIENT_ID, + client_secret: env.XRAY_CLIENT_SECRET, + }), + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}, ${response.body}`); + } + const data = await response.json(); + console.log(`${this.styles.success} Successfully authenticated with Xray`); + return data.token; + } + catch (error) { + console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); + throw error; + } + } + /** + * Uploads test results to Xray + * @param {string} token - The authentication token + * @param {string} xmlContent - The JUnit XML content to upload + * @returns {Promise} + * @throws {Error} If upload fails + */ + async uploadTestResults(token, xmlContent) { + try { + console.log(`${this.styles.info} Uploading test results to Xray...`); + const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution/junit?projectKey=XT&testPlanKey=XT-380', { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + Authorization: `Bearer ${token}`, + }, + body: xmlContent, + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + } + console.log(`${this.styles.success} Successfully uploaded test results to Xray`); + } + catch (error) { + console.error(`${this.styles.error} Failed to upload test results to Xray:`, error); + throw error; + } + } + /** + * Called when test run begins + * @param suite - Test suite object containing all tests + */ + onBegin(_config, suite) { + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); + console.log(`${this.styles.separator}\n`); + } + /** + * Called when a test begins + * @param test - Test case object + */ + onTestBegin(test, _result) { + console.log(`${this.styles.test} Starting: ${test.title}`); + } + /** + * Called when a test ends + * @param {Object} test - Test case object + * @param {Object} result - Test result object containing status and other details + */ + onTestEnd(test, result) { + const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; + console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); + } + /** + * Called when all tests have finished + * @param result - Full test run result object containing status and duration + */ + async onEnd(result) { + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.info} Test Run Summary:`); + console.log(`Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`); + console.log(`Duration: ${result.duration}ms`); + console.log(`${this.styles.separator}\n`); + if (!(env.XRAY_CLIENT_ID || env.XRAY_CLIENT_SECRET)) { + console.log(`${this.styles.warning} No Xray client ID or secret found, skipping upload to JIRA Xray`); + return; + } + try { + console.log(`${this.styles.info} Reading test results file...`); + const testResults = fs.readFileSync('./test-results/test-results.xml', 'utf8'); + const token = await this.authenticateWithXray(); + await this.uploadTestResults(token, testResults); + console.log(`${this.styles.upload} Successfully uploaded test results to Xray`); + } + catch (error) { + console.error(`${this.styles.error} Failed to process test results:`, error); + } + console.log(`${this.styles.separator}\n`); + } +} +export default XRayReporter; diff --git a/docs/XRAY_INTEGRATION.md b/docs/XRAY_INTEGRATION.md new file mode 100644 index 0000000..706224c --- /dev/null +++ b/docs/XRAY_INTEGRATION.md @@ -0,0 +1,166 @@ +# Xray Integration Documentation + +## Overview +This project uses a unified JSON-based Xray integration that captures rich test data from Playwright and uploads it to Xray with step-by-step evidence including screenshots, videos, and test annotations. + +## Architecture + +### 1. **Playwright Configuration** (`playwright.config.ts`) +- **JSON Reporter**: Generates `test-results/last-run.json` with complete test data +- **Xray JSON Reporter**: Custom reporter that automatically uploads to Xray +- **Legacy XML Reporter**: Still available for backward compatibility + +```typescript +reporter: [ + ['html', { open: 'never', outputFolder: 'playwright-report' }], + ['json', { outputFile: 'test-results/last-run.json' }], // New JSON format + ['junit', xrayOptions], // Legacy XML format + ['./utilities/xray-json-reporter.ts'], // Auto-upload to Xray +], +``` + +### 2. **Xray JSON Reporter** (`utilities/xray-json-reporter.ts`) +**Features:** +- Maps Playwright test steps to Xray test steps with individual evidence +- Attaches screenshots per step (e.g., `step-01-given-clinician-has-been-logged-in.png`) +- Includes test tags, annotations, and custom properties +- Embeds video evidence for failed tests +- Supports test execution key parameter for linking to existing test executions + +**Data Mapping:** +- **Test Steps**: Extracts from `Step Duration:` annotations +- **Evidence**: Screenshots, videos, JSON responses per step +- **Status**: Pass/Fail/Pending with detailed failure messages +- **Metadata**: Environment, build info, test tags + +### 3. **CircleCI Integration** (`.circleci/config.yml`) +**Simplified Workflow:** +1. Run tests → Generate `test-results/last-run.json` +2. Build TypeScript utilities +3. Upload to Xray using `node utilities/upload-to-xray.js` + +**Environment Variables:** +- `TEST_EXECUTION_KEY`: Links results to existing Xray test execution +- `XRAY_CLIENT_ID`: Xray API authentication +- `XRAY_CLIENT_SECRET`: Xray API authentication +- `TARGET_ENV`: Test environment (qa1, qa2, etc.) + +## Usage + +### Local Development +```bash +# Run tests and auto-upload to Xray (if credentials available) +npm test + +# Manual upload of existing results +npm run upload-to-xray test-results/last-run.json + +# Build TypeScript utilities +npm run build +``` + +### CI/CD Pipeline +Tests automatically upload to Xray when: +- `XRAY_CLIENT_ID` and `XRAY_CLIENT_SECRET` are available +- `TEST_EXECUTION_KEY` parameter is provided +- JSON results file exists + +### Test Tagging +Use test tags to organize and filter results in Xray: +```typescript +{ + tag: createValidatedTags([ + TEST_TAGS.PATIENT, + TEST_TAGS.API, + TEST_TAGS.HIGH, + TEST_TAGS.API_USER, + ]), +} +``` + +## Xray JSON Format + +### Test Execution Structure +```json +{ + "info": { + "summary": "Playwright Test Execution - 2025-08-22T19:50:15.680Z", + "testExecutionKey": "XT-123", + "testEnvironments": ["qa1"], + "startDate": "2025-08-22T19:50:15.680Z", + "finishDate": "2025-08-22T19:50:56.408Z" + }, + "tests": [...] +} +``` + +### Individual Test Structure +```json +{ + "testInfo": { + "summary": "should allow navigation to account settings", + "type": "Generic", + "projectKey": "XT", + "labels": ["patient", "api", "high"] + }, + "status": "PASS", + "evidences": [ + { + "data": "base64-encoded-screenshot", + "filename": "final-screenshot.png", + "contentType": "image/png" + } + ], + "steps": [ + { + "action": "Given clinician has been logged in", + "data": "Duration: 5193ms", + "status": "PASS", + "evidences": [ + { + "data": "base64-encoded-step-screenshot", + "filename": "step-01-given-clinician-has-been-logged-in.png", + "contentType": "image/png" + } + ] + } + ] +} +``` + +## Benefits Over Legacy XML + +| Feature | XML (Legacy) | JSON (New) | +|---------|-------------|------------| +| Test Steps | āŒ Basic only | āœ… Full step breakdown | +| Screenshots | āŒ Separate API calls | āœ… Embedded per step | +| Videos | āŒ Not supported | āœ… Embedded evidence | +| Custom Properties | āŒ Limited | āœ… Rich metadata | +| Test Tags | āŒ Basic | āœ… Full tag system | +| Debugging Info | āŒ Minimal | āœ… Comprehensive | + +## Migration Notes + +### Current State +- **JSON**: Primary integration with rich evidence +- **XML**: Available for backward compatibility +- **Duplicate Steps**: Removed from CircleCI + +### Future Cleanup +Once fully validated, remove: +- `xrayOptions` configuration in `playwright.config.ts` +- `['junit', xrayOptions]` reporter +- Legacy `utilities/xray-reporter.ts` file + +## Troubleshooting + +### Common Issues +1. **Missing JSON file**: Ensure `json` reporter is enabled in Playwright config +2. **Upload failures**: Check Xray credentials and network connectivity +3. **Step evidence missing**: Verify step naming conventions in test annotations +4. **TypeScript compilation**: Run `npm run build` before upload + +### Debug Information +- Generated JSON saved to `test-results/xray-execution.json` +- Full logs available in CircleCI build output +- Test step timing and evidence captured in annotations \ No newline at end of file diff --git a/package.json b/package.json index 683da48..db80757 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "test:ui": "TEST_TAGS='@ui' node utilities/test-runner.js", "test:patient": "TEST_TAGS='@patient' node utilities/test-runner.js", "test:clinician": "TEST_TAGS='@clinician' node utilities/test-runner.js", + "upload-to-xray": "node utilities/upload-to-xray.js", + "build": "tsc", "format": "prettier --write ." }, "repository": { diff --git a/playwright.config.ts b/playwright.config.ts index ce3dd73..b43418e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; import path from 'node:path'; import env from './utilities/env'; +// Legacy XML options - can be removed when fully migrated to JSON const xrayOptions = { embedAnnotationsAsProperties: true, textContentAnnotations: ['test_description', 'testrun_comment'], @@ -44,7 +45,9 @@ export default defineConfig({ reporter: [ ['html', { open: 'never', outputFolder: 'playwright-report' }], + ['json', { outputFile: 'test-results/last-run.json' }], ['junit', xrayOptions], + ['./utilities/xray-json-reporter.ts'], ], use: { diff --git a/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts b/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts index 34b7a58..679d208 100644 --- a/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts +++ b/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts @@ -13,6 +13,7 @@ const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; test.describe('Claimed Account Settings edit (Full Name only) updates Profile endpoint and visually updates for user, clinic, and shared member', () => { test.setTimeout(120000); // 2 minute timeout for multi-phase test + let api: ReturnType; let putCapture: any; let newName: string; // Declare at test level scope @@ -34,7 +35,7 @@ test.describe('Claimed Account Settings edit (Full Name only) updates Profile en async ({ page }) => { // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== - // Step 1: Log in to claimed account and setup network capture + // Step 1: Log in to clinician account and setup network capture await test.step('Given claimed account has been logged in', async () => { api = createNetworkHelper(page); await api.startCapture(); @@ -55,9 +56,12 @@ test.describe('Claimed Account Settings edit (Full Name only) updates Profile en }, ); + // Create new acccount settings page for the following test + // Create new acccount settings page for the following test const accountSettingsPage = new AccountSettingsPage(page); + // Step 4: Change the Full Name field to a new value await test.step('When user updates the Full Name field', async () => { newName = `Claimed User Updated ${Math.floor(Math.random() * 10000)}`; // Remove let declaration @@ -93,6 +97,23 @@ test.describe('Claimed Account Settings edit (Full Name only) updates Profile en } }, ); + await (test as any).stepNoScreenshot( + 'Then PUT request is validated and name is set to new value', + async () => { + await api.validateEndpointResponse('profile-metadata-put'); + putCapture = api + .getCaptures() + .find((req: any) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) throw new Error('No PUT /profile request captured'); + if ( + !putCapture.requestBody || + !putCapture.requestBody.fullName || + putCapture.requestBody.fullName !== newName + ) { + throw new Error(`PUT request did not set fullName to ${newName}`); + } + }, + ); // Step 8: Navigate to Profile page await test.step('When user navigates to Profile page', async () => { @@ -121,6 +142,31 @@ test.describe('Claimed Account Settings edit (Full Name only) updates Profile en // Use the most recent GET request const getCapture = laterGetCaptures[laterGetCaptures.length - 1]; + if ( + !getCapture.responseBody || + getCapture.responseBody.fullName !== putCapture.requestBody.fullName + ) { + await (test as any).stepNoScreenshot( + 'Then GET request matches the saved PUT request', + async () => { + await api.validateEndpointResponse('profile-metadata-get'); + + // Get all captures and find the LATEST GET request (after the PUT) + const allCaptures = api.getCaptures(); + const putIndex = allCaptures.findIndex(req => req === putCapture); + + // Find GET requests that occurred AFTER the PUT request + const laterGetCaptures = allCaptures + .slice(putIndex + 1) + .filter((req: any) => req.method === 'GET' && req.url.includes('/profile')); + + if (laterGetCaptures.length === 0) { + throw new Error('No GET /profile request captured after the PUT request'); + } + + // Use the most recent GET request + const getCapture = laterGetCaptures[laterGetCaptures.length - 1]; + if ( !getCapture.responseBody || getCapture.responseBody.fullName !== putCapture.requestBody.fullName @@ -134,6 +180,10 @@ test.describe('Claimed Account Settings edit (Full Name only) updates Profile en } }, ); + throw new Error('GET response fullName does not match PUT request fullName'); + } + }, + ); // ========== PHASE 2: SHARED USER VIEWS PROFILE ========== diff --git a/tsconfig.json b/tsconfig.json index fc958e2..50fd0d5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,10 +10,12 @@ "module": "commonjs", "moduleResolution": "node", "esModuleInterop": true, + "allowSyntheticDefaultImports": true, "resolveJsonModule": true, "strict": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "outDir": "./build" }, "include": ["**/*.ts", "page-objects/**/*.ts"], "exclude": ["node_modules"] diff --git a/utilities/upload-to-xray.js b/utilities/upload-to-xray.js new file mode 100644 index 0000000..be127b6 --- /dev/null +++ b/utilities/upload-to-xray.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +/** + * Standalone utility to upload Playwright JSON results to Xray + * Usage: node utilities/upload-to-xray.js [path-to-json-results] + */ + +const fs = require('fs'); +const path = require('path'); + +// Import the compiled TypeScript reporter +async function uploadResults() { + try { + // Import compiled CommonJS module + const XrayJsonReporter = require('../build/utilities/xray-json-reporter.js').default; + + const jsonPath = process.argv[2] || 'test-results/last-run.json'; + + if (!fs.existsSync(jsonPath)) { + console.error(`āŒ JSON results file not found: ${jsonPath}`); + process.exit(1); + } + + console.log(`šŸš€ Processing Playwright results from: ${jsonPath}`); + + const reporter = new XrayJsonReporter(); + await reporter.processAndUpload(jsonPath); + + console.log('āœ… Xray upload completed successfully'); + } catch (error) { + console.error('āŒ Failed to upload to Xray:', error); + process.exit(1); + } +} + +uploadResults(); \ No newline at end of file diff --git a/utilities/xray-json-reporter.ts b/utilities/xray-json-reporter.ts new file mode 100644 index 0000000..de2803a --- /dev/null +++ b/utilities/xray-json-reporter.ts @@ -0,0 +1,365 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { FullConfig, FullResult, Suite, TestCase, TestResult } from '@playwright/test/reporter'; +import env from './env'; + +interface XrayTestStep { + action: string; + data?: string; + result?: string; + status: 'PASS' | 'FAIL' | 'PENDING'; + actualResult?: string; + evidences?: Array<{ + data: string; + filename: string; + contentType: string; + }>; +} + +interface XrayTest { + testKey?: string; + testInfo: { + summary: string; + type: 'Manual' | 'Cucumber' | 'Generic'; + projectKey: string; + labels?: string[]; + }; + status: 'PASS' | 'FAIL' | 'PENDING' | 'EXECUTING'; + comment?: string; + evidences?: Array<{ + data: string; + filename: string; + contentType: string; + }>; + steps?: XrayTestStep[]; + examples?: string[]; +} + +interface XrayExecutionResult { + info: { + summary: string; + description: string; + version?: string; + testPlanKey?: string; + testExecutionKey?: string; + startDate: string; + finishDate: string; + testEnvironments?: string[]; + }; + tests: XrayTest[]; +} + +/** + * Unified Xray JSON Reporter for Playwright + * Maps rich Playwright test data to Xray's JSON format with step-by-step evidence + */ +class XrayJsonReporter { + private styles = { + success: 'āœ…', + error: 'āŒ', + info: 'ā„¹ļø', + warning: 'ā›”ļø', + upload: 'šŸš€', + test: '🧪', + separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + }; + + private startTime: string = ''; + private endTime: string = ''; + + /** + * Authenticates with Xray API using client credentials + */ + async authenticateWithXray(): Promise { + try { + console.log(`${this.styles.info} Authenticating with Xray...`); + const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: env.XRAY_CLIENT_ID, + client_secret: env.XRAY_CLIENT_SECRET, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + } + + const token = await response.text(); + console.log(`${this.styles.success} Successfully authenticated with Xray`); + return token.replace(/"/g, ''); // Remove quotes from token + } catch (error) { + console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); + throw error; + } + } + + /** + * Converts file to base64 string for Xray evidence + */ + private async fileToBase64(filePath: string): Promise { + try { + const fileBuffer = fs.readFileSync(filePath); + return fileBuffer.toString('base64'); + } catch (error) { + console.warn(`${this.styles.warning} Could not read file ${filePath}:`, error); + return ''; + } + } + + /** + * Extracts step information from test annotations + */ + private async extractSteps(annotations: any[], attachments: any[]): Promise { + const steps: XrayTestStep[] = []; + const stepAnnotations = annotations.filter(ann => + ann.type.startsWith('Step Duration:') + ); + + for (const stepAnn of stepAnnotations) { + const stepName = stepAnn.type.replace('Step Duration: ', ''); + const duration = stepAnn.description; + + // Find associated step attachments + const stepAttachments = attachments.filter(att => + att.name.toLowerCase().includes(stepName.toLowerCase().substring(0, 20)) + ); + + const step: XrayTestStep = { + action: stepName, + data: `Duration: ${duration}`, + result: stepName.includes('Then') ? stepName : undefined, + status: 'PASS', // Will be updated based on test result + evidences: [] + }; + + // Add evidence for this step + for (const attachment of stepAttachments) { + if (attachment.path && fs.existsSync(attachment.path)) { + step.evidences?.push({ + data: await this.fileToBase64(attachment.path), + filename: path.basename(attachment.path), + contentType: attachment.contentType || 'application/octet-stream' + }); + } + } + + steps.push(step); + } + + return steps; + } + + /** + * Maps Playwright test result to Xray test format + */ + private async mapPlaywrightTestToXray( + testCase: TestCase, + testResult: TestResult + ): Promise { + const tags = (testCase as any).tags || []; + const annotations = testResult.annotations || []; + const attachments = testResult.attachments || []; + + // Extract steps from annotations + const steps = await this.extractSteps(annotations, attachments); + + // Mark failed steps if test failed + if (testResult.status !== 'passed' && steps.length > 0) { + steps[steps.length - 1].status = 'FAIL'; + steps[steps.length - 1].actualResult = testResult.error?.message || 'Test failed'; + } + + // Collect test-level evidence (screenshots, videos) + const testEvidences: Array<{data: string; filename: string; contentType: string}> = []; + for (const attachment of attachments) { + if (attachment.path && fs.existsSync(attachment.path)) { + // Add main test evidence (final screenshots, videos, etc.) + if (attachment.name.includes('screenshot') || attachment.name.includes('video')) { + testEvidences.push({ + data: await this.fileToBase64(attachment.path), + filename: attachment.name, + contentType: attachment.contentType || 'application/octet-stream' + }); + } + } + } + + const xrayTest: XrayTest = { + testInfo: { + summary: testCase.title, + type: 'Generic', + projectKey: 'XT', // Could be made configurable + labels: tags + }, + status: testResult.status === 'passed' ? 'PASS' : + testResult.status === 'skipped' ? 'PENDING' : 'FAIL', + comment: testResult.error?.message, + evidences: testEvidences, + steps: steps.length > 0 ? steps : undefined + }; + + return xrayTest; + } + + /** + * Converts Playwright JSON results to Xray format + */ + async convertPlaywrightJsonToXray(playwrightJsonPath: string): Promise { + const jsonContent = fs.readFileSync(playwrightJsonPath, 'utf8'); + const playwrightResult = JSON.parse(jsonContent); + + const tests: XrayTest[] = []; + + // Process all test suites + for (const suite of playwrightResult.suites || []) { + await this.processSuite(suite, tests); + } + + const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; + const targetEnv = process.env.TARGET_ENV || 'qa1'; + + const xrayResult: XrayExecutionResult = { + info: { + summary: `Playwright Test Execution - ${new Date().toISOString()}`, + description: `Automated test execution for ${targetEnv} environment`, + version: '1.0', + testExecutionKey: testExecKey !== 'none' ? testExecKey : undefined, + startDate: playwrightResult.stats?.startTime || new Date().toISOString(), + finishDate: new Date( + new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + + (playwrightResult.stats?.duration || 0) + ).toISOString(), + testEnvironments: [targetEnv] + }, + tests + }; + + return xrayResult; + } + + /** + * Recursively processes test suites + */ + private async processSuite(suite: any, tests: XrayTest[]): Promise { + // Process specs in this suite + for (const spec of suite.specs || []) { + for (const test of spec.tests || []) { + for (const result of test.results || []) { + const xrayTest = await this.mapPlaywrightTestToXray(spec, result); + tests.push(xrayTest); + } + } + } + + // Process nested suites + for (const nestedSuite of suite.suites || []) { + await this.processSuite(nestedSuite, tests); + } + } + + /** + * Uploads Xray execution result to Xray + */ + async uploadToXray(xrayResult: XrayExecutionResult): Promise { + try { + console.log(`${this.styles.info} Uploading test execution to Xray...`); + + const token = await this.authenticateWithXray(); + + const response = await fetch( + 'https://xray.cloud.getxray.app/api/v2/import/execution', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(xrayResult), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + } + + const result = await response.json(); + console.log(`${this.styles.success} Successfully uploaded to Xray. Execution Key: ${result.key}`); + } catch (error) { + console.error(`${this.styles.error} Failed to upload to Xray:`, error); + throw error; + } + } + + /** + * Main method to process and upload results + */ + async processAndUpload(playwrightJsonPath: string): Promise { + if (!(env.XRAY_CLIENT_ID && env.XRAY_CLIENT_SECRET)) { + console.log( + `${this.styles.warning} No Xray credentials found, skipping upload to JIRA Xray` + ); + return; + } + + try { + console.log(`${this.styles.info} Processing Playwright results...`); + const xrayResult = await this.convertPlaywrightJsonToXray(playwrightJsonPath); + + // Save converted result for debugging + fs.writeFileSync( + 'test-results/xray-execution.json', + JSON.stringify(xrayResult, null, 2) + ); + + await this.uploadToXray(xrayResult); + console.log(`${this.styles.upload} Xray upload completed successfully`); + } catch (error) { + console.error(`${this.styles.error} Failed to process and upload:`, error); + throw error; + } + } + + /** + * Reporter lifecycle methods for direct Playwright integration + */ + onBegin(_config: FullConfig, suite: Suite): void { + this.startTime = new Date().toISOString(); + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); + console.log(`${this.styles.separator}\n`); + } + + onTestBegin(test: TestCase, _result: TestResult): void { + console.log(`${this.styles.test} Starting: ${test.title}`); + } + + onTestEnd(test: TestCase, result: TestResult): void { + const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; + console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); + } + + async onEnd(result: FullResult): Promise { + this.endTime = new Date().toISOString(); + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.info} Test Run Summary:`); + console.log( + `Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}` + ); + console.log(`Duration: ${result.duration}ms`); + console.log(`${this.styles.separator}\n`); + + // Auto-upload if JSON results are available + const jsonPath = 'test-results/last-run.json'; + if (fs.existsSync(jsonPath)) { + await this.processAndUpload(jsonPath); + } + } +} + +export default XrayJsonReporter; \ No newline at end of file From 0f8ceddbd5159bda67b7945c5bacdd741ba3927b Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 25 Aug 2025 15:28:29 -0400 Subject: [PATCH 16/60] Refactor Xray reporter and update .gitignore Refactored xray-json-reporter to improve code style, add a helper for status mapping, and use consistent array type annotations. Updated upload-to-xray.js to use node: imports and improved formatting. Added build/ and dist/ to .gitignore. Minor comment and formatting improvements in Playwright config. --- .gitignore | 2 + build/playwright.config.js | 1 + build/utilities/xray-json-reporter.js | 27 ++++--- utilities/upload-to-xray.js | 16 ++-- utilities/xray-json-reporter.ts | 103 +++++++++++++------------- 5 files changed, 80 insertions(+), 69 deletions(-) diff --git a/.gitignore b/.gitignore index 90071ce..b0625d2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ tests_output .vscode vrt/diff vrt/latest +build/ +dist/ # ui screens test_evidence diff --git a/build/playwright.config.js b/build/playwright.config.js index 2e08ea5..57baf55 100644 --- a/build/playwright.config.js +++ b/build/playwright.config.js @@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); const test_1 = require("@playwright/test"); const node_path_1 = __importDefault(require("node:path")); const env_1 = __importDefault(require("./utilities/env")); +// Legacy XML options - can be removed when fully migrated to JSON const xrayOptions = { embedAnnotationsAsProperties: true, textContentAnnotations: ['test_description', 'testrun_comment'], diff --git a/build/utilities/xray-json-reporter.js b/build/utilities/xray-json-reporter.js index c6d7c4a..01ea7fe 100644 --- a/build/utilities/xray-json-reporter.js +++ b/build/utilities/xray-json-reporter.js @@ -53,6 +53,16 @@ class XrayJsonReporter { throw error; } } + /** + * Maps Playwright test status to Xray status + */ + getTestStatus(status) { + if (status === 'passed') + return 'PASS'; + if (status === 'skipped') + return 'PENDING'; + return 'FAIL'; + } /** * Converts file to base64 string for Xray evidence */ @@ -82,7 +92,7 @@ class XrayJsonReporter { data: `Duration: ${duration}`, result: stepName.includes('Then') ? stepName : undefined, status: 'PASS', // Will be updated based on test result - evidences: [] + evidences: [], }; // Add evidence for this step for (const attachment of stepAttachments) { @@ -90,7 +100,7 @@ class XrayJsonReporter { step.evidences?.push({ data: await this.fileToBase64(attachment.path), filename: node_path_1.default.basename(attachment.path), - contentType: attachment.contentType || 'application/octet-stream' + contentType: attachment.contentType || 'application/octet-stream', }); } } @@ -121,7 +131,7 @@ class XrayJsonReporter { testEvidences.push({ data: await this.fileToBase64(attachment.path), filename: attachment.name, - contentType: attachment.contentType || 'application/octet-stream' + contentType: attachment.contentType || 'application/octet-stream', }); } } @@ -131,13 +141,12 @@ class XrayJsonReporter { summary: testCase.title, type: 'Generic', projectKey: 'XT', // Could be made configurable - labels: tags + labels: tags, }, - status: testResult.status === 'passed' ? 'PASS' : - testResult.status === 'skipped' ? 'PENDING' : 'FAIL', + status: this.getTestStatus(testResult.status), comment: testResult.error?.message, evidences: testEvidences, - steps: steps.length > 0 ? steps : undefined + steps: steps.length > 0 ? steps : undefined, }; return xrayTest; } @@ -163,9 +172,9 @@ class XrayJsonReporter { startDate: playwrightResult.stats?.startTime || new Date().toISOString(), finishDate: new Date(new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + (playwrightResult.stats?.duration || 0)).toISOString(), - testEnvironments: [targetEnv] + testEnvironments: [targetEnv], }, - tests + tests, }; return xrayResult; } diff --git a/utilities/upload-to-xray.js b/utilities/upload-to-xray.js index be127b6..61a923b 100644 --- a/utilities/upload-to-xray.js +++ b/utilities/upload-to-xray.js @@ -1,31 +1,29 @@ -#!/usr/bin/env node - /** * Standalone utility to upload Playwright JSON results to Xray * Usage: node utilities/upload-to-xray.js [path-to-json-results] */ -const fs = require('fs'); -const path = require('path'); +const fs = require('node:fs'); +const path = require('node:path'); // Import the compiled TypeScript reporter async function uploadResults() { try { // Import compiled CommonJS module const XrayJsonReporter = require('../build/utilities/xray-json-reporter.js').default; - + const jsonPath = process.argv[2] || 'test-results/last-run.json'; - + if (!fs.existsSync(jsonPath)) { console.error(`āŒ JSON results file not found: ${jsonPath}`); process.exit(1); } console.log(`šŸš€ Processing Playwright results from: ${jsonPath}`); - + const reporter = new XrayJsonReporter(); await reporter.processAndUpload(jsonPath); - + console.log('āœ… Xray upload completed successfully'); } catch (error) { console.error('āŒ Failed to upload to Xray:', error); @@ -33,4 +31,4 @@ async function uploadResults() { } } -uploadResults(); \ No newline at end of file +uploadResults(); diff --git a/utilities/xray-json-reporter.ts b/utilities/xray-json-reporter.ts index de2803a..9e37d3d 100644 --- a/utilities/xray-json-reporter.ts +++ b/utilities/xray-json-reporter.ts @@ -9,11 +9,11 @@ interface XrayTestStep { result?: string; status: 'PASS' | 'FAIL' | 'PENDING'; actualResult?: string; - evidences?: Array<{ + evidences?: { data: string; filename: string; contentType: string; - }>; + }[]; } interface XrayTest { @@ -26,11 +26,11 @@ interface XrayTest { }; status: 'PASS' | 'FAIL' | 'PENDING' | 'EXECUTING'; comment?: string; - evidences?: Array<{ + evidences?: { data: string; filename: string; contentType: string; - }>; + }[]; steps?: XrayTestStep[]; examples?: string[]; } @@ -64,8 +64,9 @@ class XrayJsonReporter { separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', }; - private startTime: string = ''; - private endTime: string = ''; + private startTime = ''; + + private endTime = ''; /** * Authenticates with Xray API using client credentials @@ -98,6 +99,15 @@ class XrayJsonReporter { } } + /** + * Maps Playwright test status to Xray status + */ + private getTestStatus(status: string): 'PASS' | 'FAIL' | 'PENDING' { + if (status === 'passed') return 'PASS'; + if (status === 'skipped') return 'PENDING'; + return 'FAIL'; + } + /** * Converts file to base64 string for Xray evidence */ @@ -116,17 +126,15 @@ class XrayJsonReporter { */ private async extractSteps(annotations: any[], attachments: any[]): Promise { const steps: XrayTestStep[] = []; - const stepAnnotations = annotations.filter(ann => - ann.type.startsWith('Step Duration:') - ); + const stepAnnotations = annotations.filter(ann => ann.type.startsWith('Step Duration:')); for (const stepAnn of stepAnnotations) { const stepName = stepAnn.type.replace('Step Duration: ', ''); const duration = stepAnn.description; - + // Find associated step attachments - const stepAttachments = attachments.filter(att => - att.name.toLowerCase().includes(stepName.toLowerCase().substring(0, 20)) + const stepAttachments = attachments.filter(att => + att.name.toLowerCase().includes(stepName.toLowerCase().substring(0, 20)), ); const step: XrayTestStep = { @@ -134,7 +142,7 @@ class XrayJsonReporter { data: `Duration: ${duration}`, result: stepName.includes('Then') ? stepName : undefined, status: 'PASS', // Will be updated based on test result - evidences: [] + evidences: [], }; // Add evidence for this step @@ -143,7 +151,7 @@ class XrayJsonReporter { step.evidences?.push({ data: await this.fileToBase64(attachment.path), filename: path.basename(attachment.path), - contentType: attachment.contentType || 'application/octet-stream' + contentType: attachment.contentType || 'application/octet-stream', }); } } @@ -158,8 +166,8 @@ class XrayJsonReporter { * Maps Playwright test result to Xray test format */ private async mapPlaywrightTestToXray( - testCase: TestCase, - testResult: TestResult + testCase: TestCase, + testResult: TestResult, ): Promise { const tags = (testCase as any).tags || []; const annotations = testResult.annotations || []; @@ -175,7 +183,7 @@ class XrayJsonReporter { } // Collect test-level evidence (screenshots, videos) - const testEvidences: Array<{data: string; filename: string; contentType: string}> = []; + const testEvidences: { data: string; filename: string; contentType: string }[] = []; for (const attachment of attachments) { if (attachment.path && fs.existsSync(attachment.path)) { // Add main test evidence (final screenshots, videos, etc.) @@ -183,7 +191,7 @@ class XrayJsonReporter { testEvidences.push({ data: await this.fileToBase64(attachment.path), filename: attachment.name, - contentType: attachment.contentType || 'application/octet-stream' + contentType: attachment.contentType || 'application/octet-stream', }); } } @@ -194,13 +202,12 @@ class XrayJsonReporter { summary: testCase.title, type: 'Generic', projectKey: 'XT', // Could be made configurable - labels: tags + labels: tags, }, - status: testResult.status === 'passed' ? 'PASS' : - testResult.status === 'skipped' ? 'PENDING' : 'FAIL', + status: this.getTestStatus(testResult.status), comment: testResult.error?.message, evidences: testEvidences, - steps: steps.length > 0 ? steps : undefined + steps: steps.length > 0 ? steps : undefined, }; return xrayTest; @@ -231,12 +238,12 @@ class XrayJsonReporter { testExecutionKey: testExecKey !== 'none' ? testExecKey : undefined, startDate: playwrightResult.stats?.startTime || new Date().toISOString(), finishDate: new Date( - new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + - (playwrightResult.stats?.duration || 0) + new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + + (playwrightResult.stats?.duration || 0), ).toISOString(), - testEnvironments: [targetEnv] + testEnvironments: [targetEnv], }, - tests + tests, }; return xrayResult; @@ -268,20 +275,17 @@ class XrayJsonReporter { async uploadToXray(xrayResult: XrayExecutionResult): Promise { try { console.log(`${this.styles.info} Uploading test execution to Xray...`); - + const token = await this.authenticateWithXray(); - - const response = await fetch( - 'https://xray.cloud.getxray.app/api/v2/import/execution', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(xrayResult), - } - ); + + const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(xrayResult), + }); if (!response.ok) { const errorText = await response.text(); @@ -289,7 +293,9 @@ class XrayJsonReporter { } const result = await response.json(); - console.log(`${this.styles.success} Successfully uploaded to Xray. Execution Key: ${result.key}`); + console.log( + `${this.styles.success} Successfully uploaded to Xray. Execution Key: ${result.key}`, + ); } catch (error) { console.error(`${this.styles.error} Failed to upload to Xray:`, error); throw error; @@ -301,22 +307,17 @@ class XrayJsonReporter { */ async processAndUpload(playwrightJsonPath: string): Promise { if (!(env.XRAY_CLIENT_ID && env.XRAY_CLIENT_SECRET)) { - console.log( - `${this.styles.warning} No Xray credentials found, skipping upload to JIRA Xray` - ); + console.log(`${this.styles.warning} No Xray credentials found, skipping upload to JIRA Xray`); return; } try { console.log(`${this.styles.info} Processing Playwright results...`); const xrayResult = await this.convertPlaywrightJsonToXray(playwrightJsonPath); - + // Save converted result for debugging - fs.writeFileSync( - 'test-results/xray-execution.json', - JSON.stringify(xrayResult, null, 2) - ); - + fs.writeFileSync('test-results/xray-execution.json', JSON.stringify(xrayResult, null, 2)); + await this.uploadToXray(xrayResult); console.log(`${this.styles.upload} Xray upload completed successfully`); } catch (error) { @@ -349,7 +350,7 @@ class XrayJsonReporter { console.log(`\n${this.styles.separator}`); console.log(`${this.styles.info} Test Run Summary:`); console.log( - `Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}` + `Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`, ); console.log(`Duration: ${result.duration}ms`); console.log(`${this.styles.separator}\n`); @@ -362,4 +363,4 @@ class XrayJsonReporter { } } -export default XrayJsonReporter; \ No newline at end of file +export default XrayJsonReporter; From 82c00cb10edd2320d7c94b3a999c5d8fab9c8de1 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 25 Aug 2025 15:35:24 -0400 Subject: [PATCH 17/60] Update CLAUDE.md with expanded test and config details Enhanced claude documentation to include new npm scripts for targeted test runs, expanded environment and tag filtering, and additional details on test tagging, reporting, and project structure. Updated environment and configuration file descriptions, added instructions for new test directories, and clarified integration with Xray and CircleCI. --- CLAUDE.md | 57 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 076be15..85d7421 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,19 +10,29 @@ This is a Playwright-based UI testing suite for Tidepool's web application, supp ### Testing Commands - `npm test` - Run all tests on qa1 environment -- `TARGET_ENV=qa2 playwright test` - Run tests on qa2 environment -- `TARGET_ENV=production playwright test` - Run tests on production +- `npm run test:qa2` - Run tests on qa2 environment +- `npm run test:prd` - Run tests on production +- `npm run test:smoke` - Run only smoke tests +- `npm run test:critical` - Run only critical tests +- `npm run test:api` - Run only API tests +- `npm run test:ui` - Run only UI tests +- `npm run test:patient` - Run only patient tests +- `npm run test:clinician` - Run only clinician tests - `npm run debug` - Debug tests with Playwright's debug mode -- `playwright test --project=chromium-patient` - Run only patient tests -- `playwright test --project=chromium-clinician` - Run only clinician tests +- `playwright test tests/specific-test.spec.ts` - Run a single test file +- `TARGET_ENV=qa2 TEST_TAGS='@smoke @critical' npm test` - Run tests with environment and tags ### Code Quality Commands +- `npm run check` - Run both linting and TypeScript checking - `npm run lint` - Run ESLint on TypeScript files - `npm run lint:fix` - Run ESLint with auto-fix +- `npm run typecheck` - Run TypeScript compiler check +- `npm run build` - Compile TypeScript files - `npm run format` - Format code with Prettier -### Report Generation +### Report Generation and Integration - `npm run merge-reports` - Merge XML test reports from different test suites +- `npm run upload-to-xray` - Upload test results to Xray (requires credentials) ## Architecture Overview @@ -43,13 +53,21 @@ The codebase follows the Page Object Model (POM) pattern with a clear separation ### Environment Management - **`utilities/env.ts`** - Centralized environment configuration using Zod validation -- Supports environments: qa1, qa2, qa3, qa4, qa5, production +- Supports environments: qa1, qa2, qa3, qa4, qa5, prd, int - Environment variables validated at startup +- **`utilities/test-runner.js`** - Dynamic test execution with environment and tag filtering ### Key Configuration Files -- **`playwright.config.ts`** - Playwright configuration with dual project setup (local + BrowserStack) +- **`playwright.config.ts`** - Playwright configuration with dual project setup (local + BrowserStack), includes JSON and Xray reporters - **`tsconfig.json`** - TypeScript configuration with path mapping for imports -- **`eslint.config.mjs`** - ESLint configuration using Airbnb Extended rules +- **`eslint.config.mjs`** - ESLint configuration using Airbnb Extended rules, includes test automation exceptions +- **`.circleci/config.yml`** - CI/CD pipeline with dynamic environment and tag support + +### Test Result Reporting +- **JSON Reporter**: Generates `test-results/last-run.json` with rich test data +- **Xray Integration**: `utilities/xray-json-reporter.ts` uploads test results with step-by-step evidence +- **HTML Reports**: Interactive reports in `playwright-report/` +- **CircleCI Integration**: Automated test result submission to Xray using testExecKey parameter ## Project-Specific Patterns @@ -78,13 +96,21 @@ Tests automatically detect BrowserStack environment variables and switch between - Dynamic test data generation (e.g., timestamps) to avoid test conflicts - Environment-specific URL mapping +### Test Tagging System +- **`tests/fixtures/test-tags.ts`** - Comprehensive tag system with validation +- **Required Categories**: User Types (@patient, @clinician), Test Types (@api, @ui, @smoke), Priorities (@critical, @high, @medium, @low) +- **Tag Filtering**: Supports AND logic (space-separated) and OR logic (comma-separated) +- **Dynamic Execution**: Use `TEST_TAGS` environment variable for selective test runs + ## Development Notes ### Adding New Tests -1. Create test files in appropriate directory (`tests/clinician/` or `tests/patient/`) +1. Create test files in appropriate directory (`tests/clinician/`, `tests/patient/`, `tests/claimed/`, `tests/personal/`) 2. Import custom fixtures: `import { expect, test } from '@fixtures/base'` 3. Use page objects with path aliases: `import LoginPage from '@pom/LoginPage'` 4. Follow the Given-When-Then pattern with `test.step()` blocks +5. Add test tags using `createValidatedTags()` from `@fixtures/test-tags` +6. Use project-specific imports for specialized fixtures (e.g., `network-helpers`, `patient-helpers`) ### Creating Page Objects 1. Extend the pattern established in existing page objects @@ -96,5 +122,14 @@ Tests automatically detect BrowserStack environment variables and switch between Required environment variables: - `PATIENT_USERNAME` / `PATIENT_PASSWORD` - `CLINICIAN_USERNAME` / `CLINICIAN_PASSWORD` -- `TARGET_ENV` (qa1, qa2, qa3, qa4, qa5, production) -- Optional: `BROWSERSTACK_USERNAME` / `BROWSERSTACK_ACCESS_KEY` \ No newline at end of file +- `TARGET_ENV` (qa1, qa2, qa3, qa4, qa5, prd, int) +- Optional: `BROWSERSTACK_USERNAME` / `BROWSERSTACK_ACCESS_KEY` +- Optional: `XRAY_CLIENT_ID` / `XRAY_CLIENT_SECRET` (for Xray integration) +- Optional: `TEST_TAGS` (for filtering tests by tags) + +### Project Structure Understanding +The test suite is organized by user authentication state: +- **`tests/personal/`** - Tests for personal (individual) patient accounts +- **`tests/claimed/`** - Tests for claimed patient accounts (connected to clinicians) +- **`tests/clinician/`** - Tests for clinician user flows +Each directory has separate authentication setup and isolated test execution. \ No newline at end of file From a817bc28a4dd8c6e8d5cb80ee693e630545586f2 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 25 Aug 2025 15:59:06 -0400 Subject: [PATCH 18/60] Refactor helpers, fix globals, and update docs Refactored clinic-helpers to remove duplicate findAndAccessAnyPatient, improved global stepCounter naming for network helpers, and added ESLint disables for process.exit and global require. Updated documentation and prompt files for clarity and formatting. Minor bugfixes and code style improvements in patient ProfilePage, patient-helpers, and test-runner utility. (Linting) --- .circleci/config.yml | 2 +- CLAUDE.md | 26 +++++++++++++++-- Prompts/CLINICIAN_NAVIGATION_SUMMARY.md | 30 +++++++++++++------ Prompts/ENDPOINT_SCALABILITY_DEMO.md | 22 ++++++++++++-- Prompts/test-scribe.md | 4 +-- docs/XRAY_INTEGRATION.md | 38 +++++++++++++++++++------ endpoint-schema/README.md | 30 +++++++++++-------- tests/fixtures/base.ts | 17 ++++++----- tests/fixtures/network-helpers.ts | 4 +-- utilities/test-runner.js | 7 +++-- utilities/upload-to-xray.js | 3 ++ 11 files changed, 133 insertions(+), 50 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index dd9d9a6..926015c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -290,4 +290,4 @@ jobs: workflows: commit-workflow: jobs: - - code-quality-check \ No newline at end of file + - code-quality-check diff --git a/CLAUDE.md b/CLAUDE.md index 85d7421..bea9571 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ This is a Playwright-based UI testing suite for Tidepool's web application, supp ## Essential Commands ### Testing Commands + - `npm test` - Run all tests on qa1 environment - `npm run test:qa2` - Run tests on qa2 environment - `npm run test:prd` - Run tests on production @@ -23,6 +24,7 @@ This is a Playwright-based UI testing suite for Tidepool's web application, supp - `TARGET_ENV=qa2 TEST_TAGS='@smoke @critical' npm test` - Run tests with environment and tags ### Code Quality Commands + - `npm run check` - Run both linting and TypeScript checking - `npm run lint` - Run ESLint on TypeScript files - `npm run lint:fix` - Run ESLint with auto-fix @@ -31,12 +33,14 @@ This is a Playwright-based UI testing suite for Tidepool's web application, supp - `npm run format` - Format code with Prettier ### Report Generation and Integration + - `npm run merge-reports` - Merge XML test reports from different test suites - `npm run upload-to-xray` - Upload test results to Xray (requires credentials) ## Architecture Overview ### Page Object Model Structure + The codebase follows the Page Object Model (POM) pattern with a clear separation: - **`page-objects/`** - Contains all page object classes @@ -46,24 +50,28 @@ The codebase follows the Page Object Model (POM) pattern with a clear separation - `components/` - Reusable UI components shared across pages ### Test Organization + - **`tests/fixtures/base.ts`** - Custom Playwright fixtures with enhanced logging, timing, and exception handling - **`tests/global-setup.ts`** - Pre-authenticates users and stores session state - **`tests/clinician/`** - Tests for clinician user flows - **`tests/patient/`** - Tests for patient user flows ### Environment Management + - **`utilities/env.ts`** - Centralized environment configuration using Zod validation - Supports environments: qa1, qa2, qa3, qa4, qa5, prd, int - Environment variables validated at startup - **`utilities/test-runner.js`** - Dynamic test execution with environment and tag filtering ### Key Configuration Files + - **`playwright.config.ts`** - Playwright configuration with dual project setup (local + BrowserStack), includes JSON and Xray reporters - **`tsconfig.json`** - TypeScript configuration with path mapping for imports - **`eslint.config.mjs`** - ESLint configuration using Airbnb Extended rules, includes test automation exceptions - **`.circleci/config.yml`** - CI/CD pipeline with dynamic environment and tag support ### Test Result Reporting + - **JSON Reporter**: Generates `test-results/last-run.json` with rich test data - **Xray Integration**: `utilities/xray-json-reporter.ts` uploads test results with step-by-step evidence - **HTML Reports**: Interactive reports in `playwright-report/` @@ -72,31 +80,39 @@ The codebase follows the Page Object Model (POM) pattern with a clear separation ## Project-Specific Patterns ### Authentication Strategy + - Global setup pre-authenticates both patient and clinician users - Session state stored in `tests/.auth/` directory - Separate projects for patient vs clinician test isolation ### Path Aliases + Use these import aliases defined in tsconfig.json: + - `@pom/*` - Page objects (e.g., `@pom/LoginPage`) - `@components/*` - UI components - `@fixtures/*` - Test fixtures ### Custom Test Fixtures + The project includes enhanced fixtures in `tests/fixtures/base.ts`: + - `timeLogger` - Logs test start/end times - `stepTimer` - Times individual test steps - `exceptionLogger` - Captures and reports frontend exceptions ### BrowserStack Integration + Tests automatically detect BrowserStack environment variables and switch between local Chrome and cloud testing. BrowserStack projects are conditionally added based on credential availability. ### Test Data Management + - Patient/clinician credentials managed via environment variables - Dynamic test data generation (e.g., timestamps) to avoid test conflicts - Environment-specific URL mapping ### Test Tagging System + - **`tests/fixtures/test-tags.ts`** - Comprehensive tag system with validation - **Required Categories**: User Types (@patient, @clinician), Test Types (@api, @ui, @smoke), Priorities (@critical, @high, @medium, @low) - **Tag Filtering**: Supports AND logic (space-separated) and OR logic (comma-separated) @@ -105,6 +121,7 @@ Tests automatically detect BrowserStack environment variables and switch between ## Development Notes ### Adding New Tests + 1. Create test files in appropriate directory (`tests/clinician/`, `tests/patient/`, `tests/claimed/`, `tests/personal/`) 2. Import custom fixtures: `import { expect, test } from '@fixtures/base'` 3. Use page objects with path aliases: `import LoginPage from '@pom/LoginPage'` @@ -113,13 +130,16 @@ Tests automatically detect BrowserStack environment variables and switch between 6. Use project-specific imports for specialized fixtures (e.g., `network-helpers`, `patient-helpers`) ### Creating Page Objects + 1. Extend the pattern established in existing page objects 2. Use semantic locators (`getByRole`, `getByText`) over CSS selectors 3. Include JSDoc comments for public methods 4. Add `name` property for step decorator context ### Environment Setup + Required environment variables: + - `PATIENT_USERNAME` / `PATIENT_PASSWORD` - `CLINICIAN_USERNAME` / `CLINICIAN_PASSWORD` - `TARGET_ENV` (qa1, qa2, qa3, qa4, qa5, prd, int) @@ -128,8 +148,10 @@ Required environment variables: - Optional: `TEST_TAGS` (for filtering tests by tags) ### Project Structure Understanding + The test suite is organized by user authentication state: + - **`tests/personal/`** - Tests for personal (individual) patient accounts -- **`tests/claimed/`** - Tests for claimed patient accounts (connected to clinicians) +- **`tests/claimed/`** - Tests for claimed patient accounts (connected to clinicians) - **`tests/clinician/`** - Tests for clinician user flows -Each directory has separate authentication setup and isolated test execution. \ No newline at end of file + Each directory has separate authentication setup and isolated test execution. diff --git a/Prompts/CLINICIAN_NAVIGATION_SUMMARY.md b/Prompts/CLINICIAN_NAVIGATION_SUMMARY.md index 2389ba5..0e5f619 100644 --- a/Prompts/CLINICIAN_NAVIGATION_SUMMARY.md +++ b/Prompts/CLINICIAN_NAVIGATION_SUMMARY.md @@ -1,18 +1,20 @@ # Clinician Navigation Framework - Proper Page Object Implementation ## Overview + Successfully implemented a proper clinician navigation framework that correctly follows the PatientNavigation format with all test logic separated into fixtures. ## āœ… Proper Page Object Structure ### ClinicianNavigation.ts - Page Object Only + ```typescript // Location: /page-objects/clinician/ClinicianNavigation.ts export default class ClinicianNav { readonly page: Page; readonly workspaces: Record; readonly pages: Record; - + constructor(page: Page) { // Only locator definitions - NO test logic this.workspaces = { ... }; @@ -22,6 +24,7 @@ export default class ClinicianNav { ``` ### Clinic-Helpers.ts - Test Logic & Methods + ```typescript // Location: /tests/fixtures/clinic-helpers.ts export const test = base.extend({ @@ -38,11 +41,13 @@ export const test = base.extend({ ## šŸ—ļø Architecture ### Page Objects Define ONLY: + - āœ… Locators (`link`, `verifyElement`) - āœ… Configuration (`name`, `verifyURL`) - āœ… Type definitions (`WorkspaceKey`, `PageKey`) ### Fixtures Handle ONLY: + - āœ… Test logic (`click`, `expect`, `console.log`) - āœ… Navigation methods (`navigateToWorkspace`) - āœ… Multi-workspace execution (`executeAcrossWorkspaces`) @@ -50,8 +55,9 @@ export const test = base.extend({ ## šŸŽÆ Available Hardcoded Workspaces ### Workspace Keys (Type-Safe): + ```typescript -type WorkspaceKey = +type WorkspaceKey = | 'AdminClinicBase' | 'AdminClinicEnterprise' | 'MemberClinicBase' @@ -63,6 +69,7 @@ type WorkspaceKey = ``` ### Workspace Configuration: + ```typescript AdminClinicBase: { name: 'Admin Clinic (Base)', @@ -75,6 +82,7 @@ AdminClinicBase: { ## āœ… Working Test Examples ### Single Workspace Navigation: + ```typescript test('should navigate to specific workspace', async ({ clinic }) => { await clinic.navigateToWorkspace('AdminClinicBase'); @@ -83,14 +91,15 @@ test('should navigate to specific workspace', async ({ clinic }) => { ``` ### Multi-Workspace Testing: + ```typescript test('should test across multiple workspaces', async ({ clinic }) => { const workspaces = [ { workspaceKey: 'AdminClinicBase' as const }, - { workspaceKey: 'MemberClinicEnterprise' as const } + { workspaceKey: 'MemberClinicEnterprise' as const }, ]; - await clinic.executeAcrossWorkspaces(workspaces, async (config) => { + await clinic.executeAcrossWorkspaces(workspaces, async config => { console.log(`Testing workspace: ${config.workspaceKey}`); // Your test logic here }); @@ -100,19 +109,20 @@ test('should test across multiple workspaces', async ({ clinic }) => { ## šŸŽÆ Ready for Profile API Implementation ### Template Structure: + ```typescript test('should validate clinician profile API across workspaces', async ({ clinic }) => { const targetWorkspaces = [ { workspaceKey: 'AdminClinicBase' as const }, - { workspaceKey: 'AdminClinicEnterprise' as const } + { workspaceKey: 'AdminClinicEnterprise' as const }, ]; - await clinic.executeAcrossWorkspaces(targetWorkspaces, async (config) => { + await clinic.executeAcrossWorkspaces(targetWorkspaces, async config => { // 1. Navigate to profile page within workspace await clinic.navigateToPage('Profile'); - + // 2. Capture GET request for profile data - // 3. Edit profile fields (not email) + // 3. Edit profile fields (not email) // 4. Submit profile changes // 5. Capture PUT request for profile updates // 6. Validate API responses @@ -143,21 +153,25 @@ tests/ ## šŸš€ Benefits Achieved ### 1. **Proper Separation of Concerns** + - Page objects = Pure locator definitions - Fixtures = Test logic and execution - Matches existing PatientNavigation pattern ### 2. **Easy Maintenance** + - Update locators in one place (ClinicianNavigation.ts) - Update test logic in one place (clinic-helpers.ts) - Type-safe workspace keys prevent errors ### 3. **Consistent Testing** + - Hardcoded workspace configurations ensure repeatability - executeAcrossWorkspaces() enables systematic multi-workspace testing - URL verification provides reliable workspace confirmation ## āœ… All Tests Passing + - āœ… workspace-navigation-simple.spec.ts (3/3 tests) - āœ… Multi-workspace navigation working - āœ… URL verification with correct `clinic-workspace` pattern diff --git a/Prompts/ENDPOINT_SCALABILITY_DEMO.md b/Prompts/ENDPOINT_SCALABILITY_DEMO.md index 1a1b0bc..5ade5e8 100644 --- a/Prompts/ENDPOINT_SCALABILITY_DEMO.md +++ b/Prompts/ENDPOINT_SCALABILITY_DEMO.md @@ -1,11 +1,13 @@ # Scalable Network Helpers - Endpoint-Driven API Validation ## Overview + The network helpers now use a scalable endpoint-driven approach instead of hardcoded functions. This allows validation of any API endpoint defined in the endpoint-schema folder. ## Before vs After ### Before (Hardcoded Functions) + ```typescript // Hardcoded profile-specific functions await api.validateProfileGetResponse(saveToPath); @@ -13,6 +15,7 @@ await api.validateProfilePutResponse(saveToPath); ``` ### After (Endpoint-Driven Approach) + ```typescript // Generic function that works with any endpoint in the registry await api.validateEndpointResponse('profile-metadata-get', saveToPath); @@ -22,7 +25,9 @@ await api.validateEndpointResponse('profile-metadata-put', saveToPath); ## Architecture ### 1. Endpoint Schema Pattern + Each API endpoint is defined with a schema in `/endpoint-schema/`: + ```typescript // profile-endpoints.ts export const getProfileMetadataSchema: EndpointSchema = { @@ -33,7 +38,9 @@ export const getProfileMetadataSchema: EndpointSchema = { ``` ### 2. Centralized Registry + All endpoints are registered in `/endpoint-schema/endpoint-registry.ts`: + ```typescript export const ENDPOINT_REGISTRY = { 'profile-metadata-get': getProfileMetadataSchema, @@ -43,16 +50,18 @@ export const ENDPOINT_REGISTRY = { ``` ### 3. Generic Validation Function + The network helpers use the registry to validate any endpoint: + ```typescript async validateEndpointResponse(endpointName: EndpointName, saveToPath?: string): Promise { const schema = getEndpointSchema(endpointName); const request = this.getLatestCaptureMatching(schema.method, schema.url as RegExp); - + if (request?.responseBody && saveToPath) { await this.saveApiResponse(request.responseBody, request.url, schema.method, saveToPath); } - + return request; } ``` @@ -60,23 +69,28 @@ async validateEndpointResponse(endpointName: EndpointName, saveToPath?: string): ## Benefits ### 1. Scalability + - Add new endpoints by simply defining them in endpoint-schema folder - No need to create new hardcoded functions for each endpoint - Consistent validation pattern across all API endpoints ### 2. Type Safety + ```typescript // TypeScript ensures only valid endpoint names can be used type EndpointName = keyof typeof ENDPOINT_REGISTRY; ``` ### 3. Maintainability + - Single place to define endpoint specifications - DRY principle - no duplicated validation logic - Easy to update endpoint definitions without touching test code ### 4. Future Extensibility + Easy to add new endpoints by following the pattern: + 1. Define schema in appropriate endpoint file 2. Add to endpoint registry 3. Use in tests with `api.validateEndpointResponse('new-endpoint-name')` @@ -87,7 +101,7 @@ Easy to add new endpoints by following the pattern: // Validate any GET endpoint await api.validateEndpointResponse('profile-metadata-get', './responses/profile-get.json'); -// Validate any PUT endpoint +// Validate any PUT endpoint await api.validateEndpointResponse('profile-metadata-put', './responses/profile-put.json'); // Future: Easy to add more endpoints @@ -96,7 +110,9 @@ await api.validateEndpointResponse('auth-token-post', './responses/auth-token.js ``` ## Migration + The old hardcoded functions are still available but marked as deprecated: + ```typescript /** * @deprecated Use validateEndpointResponse('profile-metadata-get', saveToPath) instead diff --git a/Prompts/test-scribe.md b/Prompts/test-scribe.md index f7194e1..42a381f 100644 --- a/Prompts/test-scribe.md +++ b/Prompts/test-scribe.md @@ -6,5 +6,5 @@ Only after all steps are completed, emit a Playwright TypeScript test that uses Save generated test file in the tests directory Execute the test file and iterate until the test passes Make sure to store direct page object information like hard coded locators and urls are stored within the appropriate 'page' or 'navigation' script in the page-objects folder -contain all logic for checks within appropriately named helper scripts in the tests/fixtures folder. -Tests shoudld be made in the patient or clinician folders depending on the current login being used \ No newline at end of file +contain all logic for checks within appropriately named helper scripts in the tests/fixtures folder. +Tests shoudld be made in the patient or clinician folders depending on the current login being used diff --git a/docs/XRAY_INTEGRATION.md b/docs/XRAY_INTEGRATION.md index 706224c..92033c5 100644 --- a/docs/XRAY_INTEGRATION.md +++ b/docs/XRAY_INTEGRATION.md @@ -1,11 +1,13 @@ # Xray Integration Documentation ## Overview + This project uses a unified JSON-based Xray integration that captures rich test data from Playwright and uploads it to Xray with step-by-step evidence including screenshots, videos, and test annotations. ## Architecture ### 1. **Playwright Configuration** (`playwright.config.ts`) + - **JSON Reporter**: Generates `test-results/last-run.json` with complete test data - **Xray JSON Reporter**: Custom reporter that automatically uploads to Xray - **Legacy XML Reporter**: Still available for backward compatibility @@ -20,7 +22,9 @@ reporter: [ ``` ### 2. **Xray JSON Reporter** (`utilities/xray-json-reporter.ts`) + **Features:** + - Maps Playwright test steps to Xray test steps with individual evidence - Attaches screenshots per step (e.g., `step-01-given-clinician-has-been-logged-in.png`) - Includes test tags, annotations, and custom properties @@ -28,18 +32,22 @@ reporter: [ - Supports test execution key parameter for linking to existing test executions **Data Mapping:** + - **Test Steps**: Extracts from `Step Duration:` annotations - **Evidence**: Screenshots, videos, JSON responses per step - **Status**: Pass/Fail/Pending with detailed failure messages - **Metadata**: Environment, build info, test tags ### 3. **CircleCI Integration** (`.circleci/config.yml`) + **Simplified Workflow:** + 1. Run tests → Generate `test-results/last-run.json` 2. Build TypeScript utilities 3. Upload to Xray using `node utilities/upload-to-xray.js` **Environment Variables:** + - `TEST_EXECUTION_KEY`: Links results to existing Xray test execution - `XRAY_CLIENT_ID`: Xray API authentication - `XRAY_CLIENT_SECRET`: Xray API authentication @@ -48,6 +56,7 @@ reporter: [ ## Usage ### Local Development + ```bash # Run tests and auto-upload to Xray (if credentials available) npm test @@ -60,13 +69,17 @@ npm run build ``` ### CI/CD Pipeline + Tests automatically upload to Xray when: + - `XRAY_CLIENT_ID` and `XRAY_CLIENT_SECRET` are available - `TEST_EXECUTION_KEY` parameter is provided - JSON results file exists ### Test Tagging + Use test tags to organize and filter results in Xray: + ```typescript { tag: createValidatedTags([ @@ -81,6 +94,7 @@ Use test tags to organize and filter results in Xray: ## Xray JSON Format ### Test Execution Structure + ```json { "info": { @@ -95,6 +109,7 @@ Use test tags to organize and filter results in Xray: ``` ### Individual Test Structure + ```json { "testInfo": { @@ -130,24 +145,27 @@ Use test tags to organize and filter results in Xray: ## Benefits Over Legacy XML -| Feature | XML (Legacy) | JSON (New) | -|---------|-------------|------------| -| Test Steps | āŒ Basic only | āœ… Full step breakdown | -| Screenshots | āŒ Separate API calls | āœ… Embedded per step | -| Videos | āŒ Not supported | āœ… Embedded evidence | -| Custom Properties | āŒ Limited | āœ… Rich metadata | -| Test Tags | āŒ Basic | āœ… Full tag system | -| Debugging Info | āŒ Minimal | āœ… Comprehensive | +| Feature | XML (Legacy) | JSON (New) | +| ----------------- | --------------------- | ---------------------- | +| Test Steps | āŒ Basic only | āœ… Full step breakdown | +| Screenshots | āŒ Separate API calls | āœ… Embedded per step | +| Videos | āŒ Not supported | āœ… Embedded evidence | +| Custom Properties | āŒ Limited | āœ… Rich metadata | +| Test Tags | āŒ Basic | āœ… Full tag system | +| Debugging Info | āŒ Minimal | āœ… Comprehensive | ## Migration Notes ### Current State + - **JSON**: Primary integration with rich evidence - **XML**: Available for backward compatibility - **Duplicate Steps**: Removed from CircleCI ### Future Cleanup + Once fully validated, remove: + - `xrayOptions` configuration in `playwright.config.ts` - `['junit', xrayOptions]` reporter - Legacy `utilities/xray-reporter.ts` file @@ -155,12 +173,14 @@ Once fully validated, remove: ## Troubleshooting ### Common Issues + 1. **Missing JSON file**: Ensure `json` reporter is enabled in Playwright config 2. **Upload failures**: Check Xray credentials and network connectivity 3. **Step evidence missing**: Verify step naming conventions in test annotations 4. **TypeScript compilation**: Run `npm run build` before upload ### Debug Information + - Generated JSON saved to `test-results/xray-execution.json` - Full logs available in CircleCI build output -- Test step timing and evidence captured in annotations \ No newline at end of file +- Test step timing and evidence captured in annotations diff --git a/endpoint-schema/README.md b/endpoint-schema/README.md index b030ed5..0c6c824 100644 --- a/endpoint-schema/README.md +++ b/endpoint-schema/README.md @@ -22,6 +22,7 @@ tests/ ### 1. Network Helper (`tests/fixtures/network-helpers.ts`) The `NetworkHelper` class provides: + - **Request/Response Capture**: Automatically intercepts and captures all network traffic - **Schema Validation**: Validates API responses against predefined schemas - **Filtering**: Filter captures by URL patterns, HTTP methods, etc. @@ -31,6 +32,7 @@ The `NetworkHelper` class provides: ### 2. Endpoint Schemas (`endpoint-schema/`) Schema files define the expected structure of API endpoints: + - **URL Patterns**: Regular expressions to match API endpoints - **HTTP Methods**: Expected HTTP methods (GET, POST, PUT, DELETE) - **Status Codes**: Expected response status codes @@ -39,6 +41,7 @@ Schema files define the expected structure of API endpoints: ### 3. Test Implementation Tests can: + - Capture all network traffic during user interactions - Validate specific API calls against schemas - Assert on response data and structure @@ -53,19 +56,19 @@ import { getUserProfileSchema } from '../../../endpoint-schema/profile-endpoints test('should validate profile API', async ({ page }) => { const networkHelper = createNetworkHelper(page); - + // Register schemas networkHelper.registerSchema('getUserProfile', getUserProfileSchema); - + // Start capturing await networkHelper.startCapture(); - + // Perform user actions await test.patient.navigateTo('Profile', page); - + // Validate API calls await networkHelper.validateCapture('profileRequest', 'getUserProfile'); - + // Stop capturing await networkHelper.stopCapture(); }); @@ -82,6 +85,7 @@ test('should validate profile API', async ({ page }) => { ## Schema Definition Examples ### Profile GET Endpoint + ```typescript export const getUserProfileSchema: EndpointSchema = { url: /\/v1\/users\/[^\/]+$/, @@ -92,13 +96,14 @@ export const getUserProfileSchema: EndpointSchema = { username: 'string', profile: { fullName: 'string', - patient: 'object' - } - } + patient: 'object', + }, + }, }; ``` ### Profile Update Endpoint + ```typescript export const updateUserProfileSchema: EndpointSchema = { url: /\/v1\/users\/[^\/]+$/, @@ -107,13 +112,13 @@ export const updateUserProfileSchema: EndpointSchema = { requestSchema: { profile: { fullName: 'string', - patient: 'object' - } + patient: 'object', + }, }, responseSchema: { userid: 'string', - profile: 'object' - } + profile: 'object', + }, }; ``` @@ -129,6 +134,7 @@ export const updateUserProfileSchema: EndpointSchema = { - `clearCaptures()`: Clear all captured data This structure makes it easy to: + - Add new endpoint schemas as the API evolves - Create comprehensive API validation tests - Debug network-related issues diff --git a/tests/fixtures/base.ts b/tests/fixtures/base.ts index e0dde50..e7a5885 100644 --- a/tests/fixtures/base.ts +++ b/tests/fixtures/base.ts @@ -143,11 +143,12 @@ export const test: TestType< let currentStepName = ''; // Make step counter accessible globally for network helper - // eslint-disable-next-line no-underscore-dangle - (globalThis as any).__stepCounter = { + (globalThis as any).stepCounter = { get: () => stepCounter, - // eslint-disable-next-line no-plusplus - increment: () => ++stepCounter, + increment: () => { + stepCounter += 1; + return stepCounter; + }, getDirectory: () => screenshotDir, getCurrentStepName: () => currentStepName, setCurrentStepName: (name: string) => { @@ -171,8 +172,7 @@ export const test: TestType< ) { return originalStep.call(this, name, async (stepInfo: TestStepInfo) => { // Set current step name for network helpers (clean name without [no-screenshot]) - // eslint-disable-next-line no-underscore-dangle - const stepCounterObj = (globalThis as any).__stepCounter; + const stepCounterObj = (globalThis as any).stepCounter; if (stepCounterObj) { const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); stepCounterObj.setCurrentStepName(cleanName); @@ -213,7 +213,7 @@ export const test: TestType< } } } catch (error) { - // Error ignored - screenshot capture is optional + // Screenshot capture failed, continue without screenshot } return result; @@ -236,8 +236,7 @@ export const test: TestType< ) { return originalStep.call(this, name, async (stepInfo: TestStepInfo) => { // Set current step name for network helpers (clean name) - // eslint-disable-next-line no-underscore-dangle - const stepCounterObj = (globalThis as any).__stepCounter; + const stepCounterObj = (globalThis as any).stepCounter; if (stepCounterObj) { stepCounterObj.setCurrentStepName(name); } diff --git a/tests/fixtures/network-helpers.ts b/tests/fixtures/network-helpers.ts index 1ad2772..4617f22 100644 --- a/tests/fixtures/network-helpers.ts +++ b/tests/fixtures/network-helpers.ts @@ -284,6 +284,7 @@ export class NetworkHelper { if (request?.responseBody) { // Access the shared step counter from the stepScreenshoter fixture const stepCounterObj = (globalThis as any).stepCounter; + const stepCounterObj = (globalThis as any).stepCounter; if (stepCounterObj) { const stepNumber = stepCounterObj.increment(); const currentStepName = stepCounterObj.getCurrentStepName(); @@ -544,8 +545,7 @@ export class NetworkHelper { } // Generate comparison JSON file similar to validateEndpointResponse - // eslint-disable-next-line no-underscore-dangle - const stepCounterObj = (globalThis as any).__stepCounter; + const stepCounterObj = (globalThis as any).stepCounter; if (stepCounterObj) { // Increment for JSON file naming (this is correct behavior) const stepNumber = stepCounterObj.increment(); diff --git a/utilities/test-runner.js b/utilities/test-runner.js index 405acbb..2789dee 100644 --- a/utilities/test-runner.js +++ b/utilities/test-runner.js @@ -113,6 +113,7 @@ function main() { console.error( 'āŒ Error: playwright.config.ts not found. Please run this script from the project root.', ); + // eslint-disable-next-line n/no-process-exit process.exit(1); } @@ -126,13 +127,15 @@ function main() { // Handle process events playwrightProcess.on('error', error => { console.error(`āŒ Failed to start Playwright: ${error.message}`); - process.exit(1); + throw new Error(`Failed to start Playwright: ${error.message}`); }); playwrightProcess.on('close', code => { const emoji = code === 0 ? 'āœ…' : 'āŒ'; console.log(`${emoji} Playwright tests completed with exit code: ${code}`); - process.exit(code); + if (code !== 0) { + throw new Error(`Playwright tests failed with exit code: ${code}`); + } }); // Handle graceful shutdown diff --git a/utilities/upload-to-xray.js b/utilities/upload-to-xray.js index 61a923b..f635b1e 100644 --- a/utilities/upload-to-xray.js +++ b/utilities/upload-to-xray.js @@ -10,12 +10,14 @@ const path = require('node:path'); async function uploadResults() { try { // Import compiled CommonJS module + // eslint-disable-next-line n/global-require, import-x/extensions const XrayJsonReporter = require('../build/utilities/xray-json-reporter.js').default; const jsonPath = process.argv[2] || 'test-results/last-run.json'; if (!fs.existsSync(jsonPath)) { console.error(`āŒ JSON results file not found: ${jsonPath}`); + // eslint-disable-next-line n/no-process-exit process.exit(1); } @@ -27,6 +29,7 @@ async function uploadResults() { console.log('āœ… Xray upload completed successfully'); } catch (error) { console.error('āŒ Failed to upload to Xray:', error); + // eslint-disable-next-line n/no-process-exit process.exit(1); } } From be5592c39a6f4f8c038b4c8ef8ab3b90ef4a0315 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 25 Aug 2025 16:02:15 -0400 Subject: [PATCH 19/60] let's actually test on ci --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 926015c..1e5d8bb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -291,3 +291,4 @@ workflows: commit-workflow: jobs: - code-quality-check + - test From 65c07783fa12de8f411d6177ec71563789e52735 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 25 Aug 2025 16:14:02 -0400 Subject: [PATCH 20/60] Remove test evidence steps from CircleCI config Eliminated steps for gathering and sending test evidence in the CircleCI pipeline. This streamlines the job by removing calls to browserstackEvidenceDownload.js and sendTestEvidenceToJira.js. --- .circleci/config.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1e5d8bb..fed1c8f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -266,12 +266,8 @@ jobs: - unless: condition: and: - - equal: ['testParallel', << pipeline.parameters.testEnvironment >>] + - equal: [<< pipeline.parameters.testEnvironment >>] steps: - - run: - name: Gather Test Evidence - command: node utilities/browserstackEvidenceDownload.js - when: always - run: name: Build TypeScript utilities command: npm run build @@ -282,10 +278,6 @@ jobs: when: always environment: TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> - - run: - name: Add Test Evidence to JIRA - command: node utilities/sendTestEvidenceToJira.js - when: always workflows: commit-workflow: From 8d95fcccf6f6083b78ac5f280a59851b2926e470 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 25 Aug 2025 16:28:35 -0400 Subject: [PATCH 21/60] Send Slack notifications only from first parallel node Updated CircleCI config to ensure Slack notifications are sent only from the first parallel node to avoid duplicate messages. Also adjusted Playwright test shard indexing and removed the JIRA test evidence step. --- .circleci/config.yml | 210 ++++++++++++++++++++++--------------------- 1 file changed, 107 insertions(+), 103 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index fed1c8f..e213199 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -64,7 +64,7 @@ jobs: # Run tests with parallel execution - run: name: Run Playwright Tests - command: npm run test --shard=$CIRCLE_NODE_INDEX/$CIRCLE_NODE_TOTAL + command: npm run test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL # Store test results and artifacts - store_artifacts: @@ -73,69 +73,73 @@ jobs: path: test-results - store_test_results: path: test-output/test-results.xml - # Main and Develop branch notifications - always notify with branch name - - slack/notify: - event: fail - branch_pattern: main - mentions: '<@UG56AQFK2>' - custom: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":x: *Tidepool Web UI Tests Failed on Main Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + # Only send notifications from the first node to avoid duplicates + - when: + condition: + equal: ["0", "${CIRCLE_NODE_INDEX}"] + steps: + - slack/notify: + event: fail + branch_pattern: main + mentions: '<@UG56AQFK2>' + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":x: *Tidepool Web UI Tests Failed on Main Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + } + } + ] } - } - ] - } - - slack/notify: - event: pass - branch_pattern: main - custom: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":white_check_mark: *Tidepool Web UI Tests Passed on Main Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + - slack/notify: + event: pass + branch_pattern: main + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":white_check_mark: *Tidepool Web UI Tests Passed on Main Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + } + } + ] } - } - ] - } - - slack/notify: - event: fail - branch_pattern: develop - mentions: '<@UG56AQFK2>' - custom: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":x: *Tidepool Web UI Tests Failed on Develop Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + - slack/notify: + event: fail + branch_pattern: develop + mentions: '<@UG56AQFK2>' + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":x: *Tidepool Web UI Tests Failed on Develop Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + } + } + ] } - } - ] - } - - slack/notify: - event: pass - branch_pattern: develop - custom: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":white_check_mark: *Tidepool Web UI Tests Passed on Develop Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + - slack/notify: + event: pass + branch_pattern: develop + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":white_check_mark: *Tidepool Web UI Tests Passed on Develop Branch* \n\n:link: <${CIRCLE_BUILD_URL}|View Build Details>" + } + } + ] } - } - ] - } - unless: condition: and: @@ -155,10 +159,6 @@ jobs: when: always environment: TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> - - run: - name: Add Test Evidence to JIRA - command: node utilities/sendTestEvidenceToJira.js - when: always scheduled-test: working_directory: ~/tidepool-org/webuitests @@ -191,7 +191,7 @@ jobs: # Run tests with parallel execution - run: name: Run Playwright Tests - command: npm run test --shard=$CIRCLE_NODE_INDEX/$CIRCLE_NODE_TOTAL + command: npm run test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL # Store test results and artifacts - store_artifacts: @@ -222,46 +222,50 @@ jobs: echo "export TIME=$TIME" >> $BASH_ENV when: always - # Detailed Slack notifications for scheduled runs - - slack/notify: - event: fail - mentions: '<@UG56AQFK2>' - custom: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*:x: Scheduled Tidepool Web UI Tests Failed* :sad_tapani: \n\n :pencil:*Summary* \n\n *$PASSED_TESTS/$TOTAL_TESTS* tests passed \n *$FAILURES* tests failed \n Total time: *$TIME* seconds" - } - }, - { - "type": "divider" - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":link: *<${CIRCLE_BUILD_URL}|View CircleCI Build Details>*" + # Only send notifications from the first node to avoid duplicates + - when: + condition: + equal: ["0", "${CIRCLE_NODE_INDEX}"] + steps: + - slack/notify: + event: fail + mentions: '<@UG56AQFK2>' + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*:x: Scheduled Tidepool Web UI Tests Failed* :sad_tapani: \n\n :pencil:*Summary* \n\n *$PASSED_TESTS/$TOTAL_TESTS* tests passed \n *$FAILURES* tests failed \n Total time: *$TIME* seconds" + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":link: *<${CIRCLE_BUILD_URL}|View CircleCI Build Details>*" + } + } + ] } - } - ] - } - - slack/notify: - event: pass - custom: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*:white_check_mark: Scheduled Tidepool Web UI Tests Passed!* :catjam: \n\n :pencil:*Summary:* \n *$PASSED_TESTS/$TOTAL_TESTS* tests passed \n Total time: *$TIME* seconds \n\n :link: *<${CIRCLE_BUILD_URL}|View CircleCI Build Details>*" + - slack/notify: + event: pass + custom: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*:white_check_mark: Scheduled Tidepool Web UI Tests Passed!* :catjam: \n\n :pencil:*Summary:* \n *$PASSED_TESTS/$TOTAL_TESTS* tests passed \n Total time: *$TIME* seconds \n\n :link: *<${CIRCLE_BUILD_URL}|View CircleCI Build Details>*" + } + } + ] } - } - ] - } - unless: condition: From ad14a9e6ac5072091b8f752b547c6a09c0a6b5e1 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Tue, 10 Feb 2026 19:29:40 -0500 Subject: [PATCH 22/60] Improve Xray integration and CI pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add configurable Xray project key and modernize test-to-Xray upload flow. Key changes: - CircleCI: add xrayProjectKey pipeline parameter and propagate XRAY_PROJECT_KEY; simplify test run command and remove duplicated CI upload steps to rely on reporter. - Playwright config: remove legacy JUnit XML reporter and rely on JSON + custom Xray JSON reporter. - Xray reporter: major enhancements in utilities/xray-json-reporter.js — evidence classification (inline vs deferred vs skip), file-size threshold, video handling (only for failed tests), deferred-evidence plumbing (GraphQL upload stub), improved authentication/error messages, project key usage, and logging/stats. Reporter only attempts upload when credentials/execution key are configured. - Environment schema: default XRAY_PROJECT_KEY and XRAY_EVIDENCE_SIZE_THRESHOLD_KB added; optional JIRA fields added. - Utilities: add Xray types and JSON schema files; remove legacy xray-reporter and upload-to-xray scripts; include GraphQL evidence client hook. - Tests & fixtures: adopt validated tag usage in tests, fix step counter API (globalThis.stepCounter), rename/getNestedValue param, small bug fixes (loop increment, continue lint suppression), and other stability improvements in clinic/patient/network helpers. - Docs: update XRAY_INTEGRATION.md and CLAUDE.md to document new behavior, env vars, and usage examples. Also includes various build/test wiring updates and minor fixes to make the new reporter and CI flow work end-to-end. --- .circleci/config.yml | 74 +- CLAUDE.md | 48 +- build/page-objects/patient/ProfilePage.js | 2 +- build/playwright.config.js | 8 - build/tests/fixtures/base.js | 15 +- build/tests/fixtures/clinic-helpers.js | 72 +- build/tests/fixtures/network-helpers.js | 8 +- build/tests/fixtures/patient-helpers.js | 1 + build/tests/global-setup.js | 2 +- build/tests/personal/login.spec.js | 37 +- build/utilities/env.js | 4 + build/utilities/xray-json-reporter.js | 286 ++- docs/XRAY_INTEGRATION.md | 237 ++- package-lock.json | 1917 ++++++++++----------- package.json | 27 +- playwright.config.ts | 9 - tests/global-setup.ts | 74 +- tests/personal/login.spec.ts | 43 +- utilities/env.ts | 3 + utilities/test-runner.js | 13 +- utilities/upload-to-xray.js | 37 - utilities/xray-json-reporter.ts | 436 +++-- utilities/xray-json-schema.json | 392 +++++ utilities/xray-reporter.ts | 161 -- utilities/xray-types.ts | 100 ++ 25 files changed, 2322 insertions(+), 1684 deletions(-) delete mode 100644 utilities/upload-to-xray.js create mode 100644 utilities/xray-json-schema.json delete mode 100644 utilities/xray-reporter.ts create mode 100644 utilities/xray-types.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index e213199..6784f5f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,6 +13,9 @@ parameters: testTags: type: string default: '' + xrayProjectKey: + type: string + default: 'SAND' jobs: code-quality-check: working_directory: ~/tidepool-org/webuitests @@ -42,6 +45,7 @@ jobs: TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> TARGET_ENV: << pipeline.parameters.testEnvironment >> TEST_TAGS: << pipeline.parameters.testTags >> + XRAY_PROJECT_KEY: << pipeline.parameters.xrayProjectKey >> steps: - checkout - node/install @@ -62,18 +66,18 @@ jobs: command: npx playwright install --with-deps # Run tests with parallel execution + # TARGET_ENV and TEST_TAGS are already set as environment variables above - run: name: Run Playwright Tests - command: npm run test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL + command: npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL # Store test results and artifacts - store_artifacts: path: playwright-report - store_artifacts: path: test-results - - store_test_results: - path: test-output/test-results.xml - # Only send notifications from the first node to avoid duplicates + + # Only send notifications from the first node to avoid duplicates - when: condition: equal: ["0", "${CIRCLE_NODE_INDEX}"] @@ -140,25 +144,6 @@ jobs: } ] } - - unless: - condition: - and: - - equal: ['testParallel', << pipeline.parameters.testEnvironment >>] - steps: - - run: - name: Gather Test Evidence - command: node utilities/browserstackEvidenceDownload.js - when: always - - run: - name: Build TypeScript utilities - command: npm run build - when: always - - run: - name: Upload Results to Xray (JSON) - command: node utilities/upload-to-xray.js test-results/last-run.json - when: always - environment: - TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> scheduled-test: working_directory: ~/tidepool-org/webuitests @@ -169,6 +154,7 @@ jobs: TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> TARGET_ENV: << pipeline.parameters.testEnvironment >> TEST_TAGS: << pipeline.parameters.testTags >> + XRAY_PROJECT_KEY: << pipeline.parameters.xrayProjectKey >> steps: - checkout - node/install @@ -189,38 +175,16 @@ jobs: command: npx playwright install --with-deps # Run tests with parallel execution + # TARGET_ENV and TEST_TAGS are already set as environment variables above - run: name: Run Playwright Tests - command: npm run test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL + command: npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL # Store test results and artifacts - store_artifacts: path: playwright-report - store_artifacts: path: test-results - - store_test_results: - path: test-output/test-results.xml - - - run: - name: Install xmllint - command: apt-get update && apt-get install -y libxml2-utils - when: always - - # Parse test results for detailed Slack notification - - run: - name: Parse Test Results - command: | - TOTAL_TESTS=$(xmllint --xpath "string(/testsuites/@tests)" test-output/test-results.xml) - FAILURES=$(xmllint --xpath "string(/testsuites/@failures)" test-output/test-results.xml) - ERRORS=$(xmllint --xpath "string(/testsuites/@errors)" test-output/test-results.xml) - PASSED_TESTS=$((TOTAL_TESTS - FAILURES - ERRORS)) - TIME=$(xmllint --xpath "string(/testsuites/@time)" test-output/test-results.xml) - echo "export TOTAL_TESTS=$TOTAL_TESTS" >> $BASH_ENV - echo "export PASSED_TESTS=$PASSED_TESTS" >> $BASH_ENV - echo "export FAILURES=$FAILURES" >> $BASH_ENV - echo "export ERRORS=$ERRORS" >> $BASH_ENV - echo "export TIME=$TIME" >> $BASH_ENV - when: always # Only send notifications from the first node to avoid duplicates - when: @@ -267,22 +231,6 @@ jobs: ] } - - unless: - condition: - and: - - equal: [<< pipeline.parameters.testEnvironment >>] - steps: - - run: - name: Build TypeScript utilities - command: npm run build - when: always - - run: - name: Upload Results to Xray (JSON) - command: node utilities/upload-to-xray.js test-results/last-run.json - when: always - environment: - TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> - workflows: commit-workflow: jobs: diff --git a/CLAUDE.md b/CLAUDE.md index bea9571..a280f6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,18 +10,21 @@ This is a Playwright-based UI testing suite for Tidepool's web application, supp ### Testing Commands -- `npm test` - Run all tests on qa1 environment -- `npm run test:qa2` - Run tests on qa2 environment -- `npm run test:prd` - Run tests on production +- `npm test` - Run all tests (uses TARGET_ENV from .env file) - `npm run test:smoke` - Run only smoke tests - `npm run test:critical` - Run only critical tests - `npm run test:api` - Run only API tests - `npm run test:ui` - Run only UI tests - `npm run test:patient` - Run only patient tests - `npm run test:clinician` - Run only clinician tests +- `npm run test:regression` - Run only regression tests - `npm run debug` - Debug tests with Playwright's debug mode -- `playwright test tests/specific-test.spec.ts` - Run a single test file -- `TARGET_ENV=qa2 TEST_TAGS='@smoke @critical' npm test` - Run tests with environment and tags +- `npx playwright test tests/specific-test.spec.ts` - Run a single test file + +**Advanced Tag Filtering:** +- Combine tags with AND logic: `npx playwright test --grep "(?=.*@smoke)(?=.*@ui)"` +- Combine tags with OR logic: `npx playwright test --grep "@smoke|@critical"` +- Change environment: Set `TARGET_ENV` in your .env file or export it before running tests ### Code Quality Commands @@ -59,9 +62,10 @@ The codebase follows the Page Object Model (POM) pattern with a clear separation ### Environment Management - **`utilities/env.ts`** - Centralized environment configuration using Zod validation +- **`.env` file** - Local environment configuration (set TARGET_ENV and credentials) - Supports environments: qa1, qa2, qa3, qa4, qa5, prd, int -- Environment variables validated at startup -- **`utilities/test-runner.js`** - Dynamic test execution with environment and tag filtering +- Environment variables validated at startup via Zod schema +- CircleCI uses pipeline parameters to set environment variables ### Key Configuration Files @@ -73,9 +77,13 @@ The codebase follows the Page Object Model (POM) pattern with a clear separation ### Test Result Reporting - **JSON Reporter**: Generates `test-results/last-run.json` with rich test data -- **Xray Integration**: `utilities/xray-json-reporter.ts` uploads test results with step-by-step evidence +- **Xray Integration**: `utilities/xray-json-reporter.ts` uploads test results with intelligent evidence handling + - Videos only for failed tests (saves storage) + - Screenshots and JSON responses for all tests + - Configurable project key via `XRAY_PROJECT_KEY` (default: SAND) + - Step-level evidence properly mapped to test steps - **HTML Reports**: Interactive reports in `playwright-report/` -- **CircleCI Integration**: Automated test result submission to Xray using testExecKey parameter +- **CircleCI Integration**: Automated test result submission to Xray with configurable project key ## Project-Specific Patterns @@ -115,8 +123,11 @@ Tests automatically detect BrowserStack environment variables and switch between - **`tests/fixtures/test-tags.ts`** - Comprehensive tag system with validation - **Required Categories**: User Types (@patient, @clinician), Test Types (@api, @ui, @smoke), Priorities (@critical, @high, @medium, @low) -- **Tag Filtering**: Supports AND logic (space-separated) and OR logic (comma-separated) +- **Tag Filtering**: + - Space-separated tags = AND logic (test must have ALL tags): `TEST_TAGS='@smoke @ui'` + - Comma-separated tags = OR logic (test must have ANY tag): `TEST_TAGS='@smoke,@critical'` - **Dynamic Execution**: Use `TEST_TAGS` environment variable for selective test runs +- **Implementation**: Uses Playwright's `--grep` flag with regex patterns to filter tests by tag metadata ## Development Notes @@ -140,12 +151,19 @@ Tests automatically detect BrowserStack environment variables and switch between Required environment variables: -- `PATIENT_USERNAME` / `PATIENT_PASSWORD` -- `CLINICIAN_USERNAME` / `CLINICIAN_PASSWORD` +- `PERSONAL_USERNAME` / `PERSONAL_PASSWORD` - Personal patient account +- `CLAIMED_USERNAME` / `CLAIMED_PASSWORD` - Claimed patient account +- `SHARED_USERNAME` / `SHARED_PASSWORD` - Shared patient account +- `CLINICIAN_USERNAME` / `CLINICIAN_PASSWORD` - Clinician account - `TARGET_ENV` (qa1, qa2, qa3, qa4, qa5, prd, int) -- Optional: `BROWSERSTACK_USERNAME` / `BROWSERSTACK_ACCESS_KEY` -- Optional: `XRAY_CLIENT_ID` / `XRAY_CLIENT_SECRET` (for Xray integration) -- Optional: `TEST_TAGS` (for filtering tests by tags) +- Optional: `BROWSERSTACK_USERNAME` / `BROWSERSTACK_ACCESS_KEY` (for BrowserStack cloud testing) + +**Xray Integration (Optional):** +- `XRAY_CLIENT_ID` / `XRAY_CLIENT_SECRET` - Required for automatic Xray upload after test runs +- `XRAY_PROJECT_KEY` - Jira project key (default: SAND) +- `TEST_EXECUTION_KEY` - Link to existing Xray execution, or 'none' to auto-create + +**Note:** If `XRAY_CLIENT_ID` and `XRAY_CLIENT_SECRET` are not provided, the Xray reporter will silently skip upload and only generate local JSON reports. ### Project Structure Understanding diff --git a/build/page-objects/patient/ProfilePage.js b/build/page-objects/patient/ProfilePage.js index ef565d8..003f029 100644 --- a/build/page-objects/patient/ProfilePage.js +++ b/build/page-objects/patient/ProfilePage.js @@ -39,7 +39,7 @@ class ProfilePage { const currentValue = await diagnosisCombo.inputValue(); const options = await diagnosisCombo.locator('option').all(); // Find current index by checking option values - for (let i = 0; i < options.length; i++) { + for (let i = 0; i < options.length; i += 1) { const optionValue = await options[i].getAttribute('value'); if (optionValue === currentValue) { return i; diff --git a/build/playwright.config.js b/build/playwright.config.js index 57baf55..d6b290c 100644 --- a/build/playwright.config.js +++ b/build/playwright.config.js @@ -6,13 +6,6 @@ Object.defineProperty(exports, "__esModule", { value: true }); const test_1 = require("@playwright/test"); const node_path_1 = __importDefault(require("node:path")); const env_1 = __importDefault(require("./utilities/env")); -// Legacy XML options - can be removed when fully migrated to JSON -const xrayOptions = { - embedAnnotationsAsProperties: true, - textContentAnnotations: ['test_description', 'testrun_comment'], - embedAttachmentsAsProperty: 'testrun_evidence', - outputFile: 'test-output/test-results.xml', -}; // Helper to detect BrowserStack run const isBrowserStack = Boolean(process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESS_KEY); function buildBrowserStackEndpoint(testName) { @@ -43,7 +36,6 @@ exports.default = (0, test_1.defineConfig)({ reporter: [ ['html', { open: 'never', outputFolder: 'playwright-report' }], ['json', { outputFile: 'test-results/last-run.json' }], - ['junit', xrayOptions], ['./utilities/xray-json-reporter.ts'], ], use: { diff --git a/build/tests/fixtures/base.js b/build/tests/fixtures/base.js index b21e7bc..2c7e91d 100644 --- a/build/tests/fixtures/base.js +++ b/build/tests/fixtures/base.js @@ -130,9 +130,12 @@ exports.test = test_1.test.extend({ // Store current step name for network helpers let currentStepName = ''; // Make step counter accessible globally for network helper - globalThis.__stepCounter = { + globalThis.stepCounter = { get: () => stepCounter, - increment: () => ++stepCounter, + increment: () => { + stepCounter += 1; + return stepCounter; + }, getDirectory: () => screenshotDir, getCurrentStepName: () => currentStepName, setCurrentStepName: (name) => { @@ -151,7 +154,7 @@ exports.test = test_1.test.extend({ const newStep = function newStepScreenshot(name, fn) { return originalStep.call(this, name, async (stepInfo) => { // Set current step name for network helpers (clean name without [no-screenshot]) - const stepCounterObj = globalThis.__stepCounter; + const stepCounterObj = globalThis.stepCounter; if (stepCounterObj) { const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); stepCounterObj.setCurrentStepName(cleanName); @@ -186,7 +189,9 @@ exports.test = test_1.test.extend({ } } } - catch (error) { } + catch (error) { + // Screenshot capture failed, continue without screenshot + } return result; }); }; @@ -198,7 +203,7 @@ exports.test = test_1.test.extend({ const stepNoScreenshot = function stepNoScreenshot(name, fn) { return originalStep.call(this, name, async (stepInfo) => { // Set current step name for network helpers (clean name) - const stepCounterObj = globalThis.__stepCounter; + const stepCounterObj = globalThis.stepCounter; if (stepCounterObj) { stepCounterObj.setCurrentStepName(name); } diff --git a/build/tests/fixtures/clinic-helpers.js b/build/tests/fixtures/clinic-helpers.js index 17b2e56..b328d86 100644 --- a/build/tests/fixtures/clinic-helpers.js +++ b/build/tests/fixtures/clinic-helpers.js @@ -128,6 +128,42 @@ async function executeAcrossWorkspaces(workspaceConfigs, action, page) { } } } +/** + * Find and access any available patient (fastest option) + * @param page - The Playwright page object + * @returns The full name of the first patient that was accessed + */ +async function findAndAccessAnyPatient(page) { + const dashboard = new ClinicianDashboardPage_1.default(page); + try { + // Clear search to show all patients + await dashboard.searchInput.click(); + await dashboard.searchInput.fill(' '); + await page.waitForTimeout(500); + await dashboard.searchInput.fill(''); + await page.waitForTimeout(1500); + let allCells = await dashboard.patientListTable.getByRole('cell').all(); + // If no cells, try pressing Enter on empty search + if (allCells.length === 0) { + await dashboard.searchInput.press('Enter'); + await page.waitForTimeout(1500); + allCells = await dashboard.patientListTable.getByRole('cell').all(); + } + // Find the first cell that looks like a patient name + for (const cell of allCells) { + const cellText = await cell.textContent(); + if (cellText && cellText.trim().length > 3 && cellText.includes(' ')) { + await cell.click(); + await page.waitForTimeout(800); + return cellText.trim(); + } + } + throw new Error('No patient names found in table'); + } + catch (error) { + throw new Error(`Failed to find any patient: ${error}`); + } +} /** * Find and access any patient whose name contains the search term (optimized version) * @param searchTerm - Partial name to search for (e.g., "Custodial") @@ -191,42 +227,6 @@ async function findAndAccessPatientByPartialName(searchTerm, page) { throw new Error(`No patient found containing "${searchTerm}" and no fallback patients available`); } } -/** - * Find and access any available patient (fastest option) - * @param page - The Playwright page object - * @returns The full name of the first patient that was accessed - */ -async function findAndAccessAnyPatient(page) { - const dashboard = new ClinicianDashboardPage_1.default(page); - try { - // Clear search to show all patients - await dashboard.searchInput.click(); - await dashboard.searchInput.fill(' '); - await page.waitForTimeout(500); - await dashboard.searchInput.fill(''); - await page.waitForTimeout(1500); - let allCells = await dashboard.patientListTable.getByRole('cell').all(); - // If no cells, try pressing Enter on empty search - if (allCells.length === 0) { - await dashboard.searchInput.press('Enter'); - await page.waitForTimeout(1500); - allCells = await dashboard.patientListTable.getByRole('cell').all(); - } - // Find the first cell that looks like a patient name - for (const cell of allCells) { - const cellText = await cell.textContent(); - if (cellText && cellText.trim().length > 3 && cellText.includes(' ')) { - await cell.click(); - await page.waitForTimeout(800); - return cellText.trim(); - } - } - throw new Error('No patient names found in table'); - } - catch (error) { - throw new Error(`Failed to find any patient: ${error}`); - } -} /** * Access a specific patient by name and navigate to their summary page * @param patientName - The name of the patient to access diff --git a/build/tests/fixtures/network-helpers.js b/build/tests/fixtures/network-helpers.js index d5a0ebb..ea7dd18 100644 --- a/build/tests/fixtures/network-helpers.js +++ b/build/tests/fixtures/network-helpers.js @@ -252,7 +252,7 @@ class NetworkHelper { const request = this.getLatestCaptureMatching(schema.method, schema.url); if (request?.responseBody) { // Access the shared step counter from the stepScreenshoter fixture - const stepCounterObj = globalThis.__stepCounter; + const stepCounterObj = globalThis.stepCounter; if (stepCounterObj) { const stepNumber = stepCounterObj.increment(); const currentStepName = stepCounterObj.getCurrentStepName(); @@ -371,8 +371,8 @@ class NetworkHelper { * @param path - The dot-notation path (e.g., 'patient.birthday') * @returns The value at the path or undefined */ - getNestedValue(obj, path) { - return path.split('.').reduce((current, key) => current?.[key], obj); + getNestedValue(obj, propertyPath) { + return propertyPath.split('.').reduce((current, key) => current?.[key], obj); } /** * Validate producer-consumer data consistency for profile endpoints @@ -435,7 +435,7 @@ class NetworkHelper { throw new Error('No base endpoint found'); } // Generate comparison JSON file similar to validateEndpointResponse - const stepCounterObj = globalThis.__stepCounter; + const stepCounterObj = globalThis.stepCounter; if (stepCounterObj) { // Increment for JSON file naming (this is correct behavior) const stepNumber = stepCounterObj.increment(); diff --git a/build/tests/fixtures/patient-helpers.js b/build/tests/fixtures/patient-helpers.js index 0b68151..b47b24c 100644 --- a/build/tests/fixtures/patient-helpers.js +++ b/build/tests/fixtures/patient-helpers.js @@ -368,6 +368,7 @@ async function executeNavigationStrategy(state) { // Check condition if present if (step.condition && !(await step.condition(state))) { console.log(`Skipping step ${step.name} - condition not met`); + // eslint-disable-next-line no-continue continue; } console.log(`Executing step: ${step.name}`); diff --git a/build/tests/global-setup.js b/build/tests/global-setup.js index 2550db3..03e5990 100644 --- a/build/tests/global-setup.js +++ b/build/tests/global-setup.js @@ -12,7 +12,7 @@ const env_1 = __importDefault(require("../utilities/env")); async function loginUserType(role) { const browser = await test_1.chromium.launch(); const context = await browser.newContext({ - baseURL: process.env.BASE_URL, + baseURL: env_1.default.BASE_URL, }); const page = await context.newPage(); await page.goto(env_1.default.BASE_URL); diff --git a/build/tests/personal/login.spec.js b/build/tests/personal/login.spec.js index 9855597..8c78393 100644 --- a/build/tests/personal/login.spec.js +++ b/build/tests/personal/login.spec.js @@ -8,11 +8,19 @@ const base_1 = require("@fixtures/base"); const LoginPage_1 = __importDefault(require("page-objects/LoginPage")); const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); const env_1 = __importDefault(require("../../utilities/env")); +const test_tags_1 = require("../fixtures/test-tags"); // make sure we don't have any cookies or origins base_1.test.use({ storageState: { cookies: [], origins: [] } }); // Possible testcases: https://tidepool.atlassian.net/jira/software/c/projects/WEB/issues/?jql=project%20%3D%20%22WEB%22%20AND%20type%20%3D%20Test%20AND%20textfields%20~%20%22login%22%20ORDER%20BY%20created%20DESC base_1.test.describe('Login into application', () => { - (0, base_1.test)('should work with valid credentials for clinician with multiple clinics', async ({ page, }) => { + (0, base_1.test)('should work with valid credentials for clinician with multiple clinics', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.CLINICIAN, + test_tags_1.TEST_TAGS.UI, + test_tags_1.TEST_TAGS.SMOKE, + test_tags_1.TEST_TAGS.CRITICAL, + ]), + }, async ({ page }) => { const loginPage = new LoginPage_1.default(page); await base_1.test.step('When user is logged into application', async () => { await loginPage.goto(); @@ -24,7 +32,14 @@ base_1.test.describe('Login into application', () => { await (0, base_1.expect)(workspacesPage.header).toBeVisible(); }); }); - (0, base_1.test)('should show error message with invalid credentials', async ({ page }) => { + (0, base_1.test)('should show error message with invalid credentials', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.CLINICIAN, + test_tags_1.TEST_TAGS.UI, + test_tags_1.TEST_TAGS.SMOKE, + test_tags_1.TEST_TAGS.HIGH, + ]), + }, async ({ page }) => { const loginPage = new LoginPage_1.default(page); await base_1.test.step('When user attempts to login with invalid credentials', async () => { await loginPage.goto(); @@ -38,7 +53,14 @@ base_1.test.describe('Login into application', () => { await (0, base_1.expect)(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); }); }); - (0, base_1.test)('should validate email format', async ({ page }) => { + (0, base_1.test)('should validate email format', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.CLINICIAN, + test_tags_1.TEST_TAGS.UI, + test_tags_1.TEST_TAGS.REGRESSION, + test_tags_1.TEST_TAGS.MEDIUM, + ]), + }, async ({ page }) => { const loginPage = new LoginPage_1.default(page); await base_1.test.step('When user attempts to login with invalid email format', async () => { await loginPage.goto(); @@ -52,7 +74,14 @@ base_1.test.describe('Login into application', () => { await (0, base_1.expect)(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); }); }); - (0, base_1.test)('should show error message with invalid credentials 1', async ({ page }) => { + (0, base_1.test)('should show error message with invalid credentials 1', { + tag: (0, test_tags_1.createValidatedTags)([ + test_tags_1.TEST_TAGS.CLINICIAN, + test_tags_1.TEST_TAGS.UI, + test_tags_1.TEST_TAGS.SMOKE, + test_tags_1.TEST_TAGS.HIGH, + ]), + }, async ({ page }) => { const loginPage = new LoginPage_1.default(page); await base_1.test.step('When user is logged into application', async () => { await loginPage.goto(); diff --git a/build/utilities/env.js b/build/utilities/env.js index 1c8b960..123e678 100644 --- a/build/utilities/env.js +++ b/build/utilities/env.js @@ -20,6 +20,10 @@ const envSchema = zod_1.default.object({ TARGET_ENV: zod_1.default.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production', 'prd', 'int']), XRAY_CLIENT_ID: zod_1.default.string().optional(), XRAY_CLIENT_SECRET: zod_1.default.string().optional(), + XRAY_PROJECT_KEY: zod_1.default.string().default('SAND'), + XRAY_EVIDENCE_SIZE_THRESHOLD_KB: zod_1.default.coerce.number().default(100), + JIRA_EMAIL: zod_1.default.string().optional(), + JIRA_API_KEY: zod_1.default.string().optional(), }); const env = envSchema.safeParse(process.env); if (!env.success) { diff --git a/build/utilities/xray-json-reporter.js b/build/utilities/xray-json-reporter.js index 01ea7fe..56046f6 100644 --- a/build/utilities/xray-json-reporter.js +++ b/build/utilities/xray-json-reporter.js @@ -6,9 +6,10 @@ Object.defineProperty(exports, "__esModule", { value: true }); const node_fs_1 = __importDefault(require("node:fs")); const node_path_1 = __importDefault(require("node:path")); const env_1 = __importDefault(require("./env")); +const xray_graphql_evidence_1 = require("./xray-graphql-evidence"); /** * Unified Xray JSON Reporter for Playwright - * Maps rich Playwright test data to Xray's JSON format with step-by-step evidence + * Maps rich Playwright test data to Xray's JSON format with intelligent evidence handling */ class XrayJsonReporter { constructor() { @@ -16,20 +17,26 @@ class XrayJsonReporter { success: 'āœ…', error: 'āŒ', info: 'ā„¹ļø', - warning: 'ā›”ļø', + warning: 'āš ļø', upload: 'šŸš€', test: '🧪', + evidence: 'šŸ“Ž', separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', }; this.startTime = ''; this.endTime = ''; + this.deferredEvidenceUploads = []; } /** * Authenticates with Xray API using client credentials */ async authenticateWithXray() { + const startAuth = Date.now(); try { - console.log(`${this.styles.info} Authenticating with Xray...`); + console.log(`${this.styles.info} Authenticating with Xray Cloud API...`); + if (!env_1.default.XRAY_CLIENT_ID || !env_1.default.XRAY_CLIENT_SECRET) { + throw new Error('XRAY_CLIENT_ID and XRAY_CLIENT_SECRET are required for authentication'); + } const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { method: 'POST', headers: { @@ -42,11 +49,16 @@ class XrayJsonReporter { }); if (!response.ok) { const errorText = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + throw new Error(`Authentication failed (HTTP ${response.status}): ${errorText || 'No error details'}`); } const token = await response.text(); - console.log(`${this.styles.success} Successfully authenticated with Xray`); - return token.replace(/"/g, ''); // Remove quotes from token + const cleanToken = token.replace(/"/g, ''); // Remove quotes from token + if (!cleanToken || cleanToken.length < 10) { + throw new Error(`Invalid token received: ${cleanToken.substring(0, 20)}...`); + } + const authDuration = Date.now() - startAuth; + console.log(`${this.styles.success} Successfully authenticated with Xray (${authDuration}ms)`); + return cleanToken; } catch (error) { console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); @@ -77,36 +89,112 @@ class XrayJsonReporter { } } /** - * Extracts step information from test annotations + * Get file size in bytes + */ + getFileSize(filePath) { + try { + const stats = node_fs_1.default.statSync(filePath); + return stats.size; + } + catch (error) { + return 0; + } + } + /** + * Classifies evidence based on type, size, and test result + */ + classifyEvidence(attachment, testStatus, contentType) { + const filePath = attachment.path; + if (!filePath || !node_fs_1.default.existsSync(filePath)) { + return 'skip'; + } + const sizeBytes = this.getFileSize(filePath); + const sizeKB = sizeBytes / 1024; + const thresholdKB = env_1.default.XRAY_EVIDENCE_SIZE_THRESHOLD_KB || 100; + // Videos: Only for failed tests + if (contentType.includes('video')) { + if (testStatus !== 'passed') { + return 'deferred'; // Always defer videos (large files) + } + return 'skip'; + } + // Screenshots (PNG/JPEG): Always include + if (contentType.includes('image')) { + if (sizeKB < thresholdKB) { + return 'inline'; + } + return 'deferred'; + } + // JSON responses: Always inline (small) + if (contentType.includes('json')) { + return 'inline'; + } + // Other attachments: Check size + if (sizeKB < thresholdKB) { + return 'inline'; + } + return 'deferred'; + } + /** + * Extracts step information from test annotations and maps evidence */ - async extractSteps(annotations, attachments) { + async extractSteps(annotations, attachments, testStatus) { const steps = []; + const classifiedEvidence = []; const stepAnnotations = annotations.filter(ann => ann.type.startsWith('Step Duration:')); - for (const stepAnn of stepAnnotations) { + for (let i = 0; i < stepAnnotations.length; i += 1) { + const stepAnn = stepAnnotations[i]; const stepName = stepAnn.type.replace('Step Duration: ', ''); const duration = stepAnn.description; - // Find associated step attachments - const stepAttachments = attachments.filter(att => att.name.toLowerCase().includes(stepName.toLowerCase().substring(0, 20))); + const stepNumber = i + 1; + // Find associated step attachments using step number pattern + const stepPattern = `step-${stepNumber.toString().padStart(2, '0')}`; + const stepAttachments = attachments.filter(att => att.name.toLowerCase().includes(stepPattern)); const step = { action: stepName, data: `Duration: ${duration}`, result: stepName.includes('Then') ? stepName : undefined, - status: 'PASS', // Will be updated based on test result + status: 'PASS', // Will be updated if test failed evidences: [], }; - // Add evidence for this step + // Classify and process step evidence for (const attachment of stepAttachments) { if (attachment.path && node_fs_1.default.existsSync(attachment.path)) { - step.evidences?.push({ - data: await this.fileToBase64(attachment.path), - filename: node_path_1.default.basename(attachment.path), - contentType: attachment.contentType || 'application/octet-stream', - }); + const contentType = attachment.contentType || 'application/octet-stream'; + const classification = this.classifyEvidence(attachment, testStatus, contentType); + if (classification !== 'skip') { + const sizeBytes = this.getFileSize(attachment.path); + if (classification === 'inline') { + // Embed in Xray JSON + const base64Data = await this.fileToBase64(attachment.path); + if (base64Data) { + step.evidences?.push({ + data: base64Data, + filename: node_path_1.default.basename(attachment.path), + contentType, + }); + } + } + else { + // Mark for deferred upload + classifiedEvidence.push({ + evidence: { + data: '', // Will be loaded during GraphQL upload + filename: node_path_1.default.basename(attachment.path), + contentType, + }, + classification: 'deferred', + stepIndex: i, + filePath: attachment.path, + fileSize: sizeBytes, + }); + } + } } } steps.push(step); } - return steps; + return { steps, classified: classifiedEvidence }; } /** * Maps Playwright test result to Xray test format @@ -115,24 +203,48 @@ class XrayJsonReporter { const tags = testCase.tags || []; const annotations = testResult.annotations || []; const attachments = testResult.attachments || []; + const testStatus = testResult.status; // Extract steps from annotations - const steps = await this.extractSteps(annotations, attachments); + const { steps, classified: stepDeferred } = await this.extractSteps(annotations, attachments, testStatus); // Mark failed steps if test failed - if (testResult.status !== 'passed' && steps.length > 0) { + if (testStatus !== 'passed' && steps.length > 0) { steps[steps.length - 1].status = 'FAIL'; steps[steps.length - 1].actualResult = testResult.error?.message || 'Test failed'; } // Collect test-level evidence (screenshots, videos) const testEvidences = []; + const testLevelDeferred = []; for (const attachment of attachments) { - if (attachment.path && node_fs_1.default.existsSync(attachment.path)) { - // Add main test evidence (final screenshots, videos, etc.) - if (attachment.name.includes('screenshot') || attachment.name.includes('video')) { - testEvidences.push({ - data: await this.fileToBase64(attachment.path), - filename: attachment.name, - contentType: attachment.contentType || 'application/octet-stream', - }); + // Only process test-level evidence (not step-level) + if (attachment.path && + node_fs_1.default.existsSync(attachment.path) && + !attachment.name.toLowerCase().includes('step-')) { + const contentType = attachment.contentType || 'application/octet-stream'; + const classification = this.classifyEvidence(attachment, testStatus, contentType); + if (classification !== 'skip') { + const sizeBytes = this.getFileSize(attachment.path); + if (classification === 'inline') { + const base64Data = await this.fileToBase64(attachment.path); + if (base64Data) { + testEvidences.push({ + data: base64Data, + filename: attachment.name, + contentType, + }); + } + } + else { + testLevelDeferred.push({ + evidence: { + data: '', + filename: attachment.name, + contentType, + }, + classification: 'deferred', + filePath: attachment.path, + fileSize: sizeBytes, + }); + } } } } @@ -140,15 +252,18 @@ class XrayJsonReporter { testInfo: { summary: testCase.title, type: 'Generic', - projectKey: 'XT', // Could be made configurable + projectKey: env_1.default.XRAY_PROJECT_KEY || 'SAND', labels: tags, }, - status: this.getTestStatus(testResult.status), + status: this.getTestStatus(testStatus), comment: testResult.error?.message, - evidences: testEvidences, + evidences: testEvidences.length > 0 ? testEvidences : undefined, steps: steps.length > 0 ? steps : undefined, }; - return xrayTest; + return { + test: xrayTest, + deferred: [...stepDeferred, ...testLevelDeferred], + }; } /** * Converts Playwright JSON results to Xray format @@ -157,18 +272,25 @@ class XrayJsonReporter { const jsonContent = node_fs_1.default.readFileSync(playwrightJsonPath, 'utf8'); const playwrightResult = JSON.parse(jsonContent); const tests = []; + this.deferredEvidenceUploads = []; // Reset deferred uploads // Process all test suites for (const suite of playwrightResult.suites || []) { await this.processSuite(suite, tests); } const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; const targetEnv = process.env.TARGET_ENV || 'qa1'; + // Calculate statistics + const passedCount = tests.filter(t => t.status === 'PASS').length; + const failedCount = tests.filter(t => t.status === 'FAIL').length; + const pendingCount = tests.filter(t => t.status === 'PENDING').length; const xrayResult = { info: { summary: `Playwright Test Execution - ${new Date().toISOString()}`, - description: `Automated test execution for ${targetEnv} environment`, + description: `Automated test execution for ${targetEnv} environment\n\nResults: ${passedCount} passed, ${failedCount} failed, ${pendingCount} pending`, version: '1.0', - testExecutionKey: testExecKey !== 'none' ? testExecKey : undefined, + testExecutionKey: testExecKey && testExecKey !== 'none' && testExecKey.trim() !== '' + ? testExecKey + : undefined, startDate: playwrightResult.stats?.startTime || new Date().toISOString(), finishDate: new Date(new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + (playwrightResult.stats?.duration || 0)).toISOString(), @@ -176,6 +298,11 @@ class XrayJsonReporter { }, tests, }; + // Log deferred evidence summary + if (this.deferredEvidenceUploads.length > 0) { + const totalSizeKB = this.deferredEvidenceUploads.reduce((sum, d) => sum + this.getFileSize(d.filePath) / 1024, 0); + console.log(`${this.styles.evidence} ${this.deferredEvidenceUploads.length} evidence files marked for deferred upload (${totalSizeKB.toFixed(1)} KB)`); + } return xrayResult; } /** @@ -186,8 +313,22 @@ class XrayJsonReporter { for (const spec of suite.specs || []) { for (const test of spec.tests || []) { for (const result of test.results || []) { - const xrayTest = await this.mapPlaywrightTestToXray(spec, result); + const { test: xrayTest, deferred } = await this.mapPlaywrightTestToXray(spec, result); tests.push(xrayTest); + // Store deferred evidence for later upload + // Note: We'll need test run IDs from import response to upload these + for (const evidence of deferred) { + this.deferredEvidenceUploads.push({ + testRunId: '', // Will be populated after import + testRunStepId: evidence.stepIndex !== undefined ? '' : undefined, + filePath: evidence.filePath, + filename: evidence.evidence.filename, + contentType: evidence.evidence.contentType, + stepAction: evidence.stepIndex !== undefined + ? xrayTest.steps?.[evidence.stepIndex]?.action + : undefined, + }); + } } } } @@ -201,7 +342,11 @@ class XrayJsonReporter { */ async uploadToXray(xrayResult) { try { + const uploadStart = Date.now(); + const payloadSize = JSON.stringify(xrayResult).length; + const payloadSizeKB = (payloadSize / 1024).toFixed(1); console.log(`${this.styles.info} Uploading test execution to Xray...`); + console.log(`${this.styles.info} Payload: ${xrayResult.tests.length} tests, ${payloadSizeKB} KB`); const token = await this.authenticateWithXray(); const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution', { method: 'POST', @@ -213,16 +358,46 @@ class XrayJsonReporter { }); if (!response.ok) { const errorText = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + throw new Error(`Upload failed (HTTP ${response.status}): ${errorText}`); } const result = await response.json(); - console.log(`${this.styles.success} Successfully uploaded to Xray. Execution Key: ${result.key}`); + const uploadDuration = Date.now() - uploadStart; + console.log(`${this.styles.success} Successfully uploaded to Xray (${uploadDuration}ms)`); + console.log(`${this.styles.success} Test Execution Key: ${result.testExecIssue?.key || 'N/A'}`); + return result; } catch (error) { console.error(`${this.styles.error} Failed to upload to Xray:`, error); throw error; } } + /** + * Upload deferred evidence via GraphQL + */ + async uploadDeferredEvidenceViaGraphQL(importResponse) { + try { + console.log(`${this.styles.evidence} Uploading ${this.deferredEvidenceUploads.length} deferred evidence files via GraphQL...`); + // Get fresh token for GraphQL + const token = await this.authenticateWithXray(); + // Create GraphQL client + const graphqlClient = new xray_graphql_evidence_1.XrayGraphQLClient(); + graphqlClient.setAuthToken(token); + // Note: The import response doesn't directly provide test run IDs + // For now, we'll skip GraphQL upload and log a warning + // This requires additional API calls to fetch test run details + console.log(`${this.styles.warning} GraphQL evidence upload requires test run ID mapping`); + console.log(`${this.styles.info} Test Execution: ${importResponse.testExecIssue?.key}`); + console.log(`${this.styles.info} Deferred uploads will be enhanced in a future update to fetch test run IDs`); + // TODO: Implement test run ID fetching via GraphQL query + // Query: { getTestExecution(issueId: "...") { testRuns { id, test { summary } } } } + // Then map playwright test titles to test run IDs + // Then call graphqlClient.uploadBatch(uploadsWithIds) + } + catch (error) { + console.error(`${this.styles.error} Failed to upload deferred evidence:`, error); + // Don't throw - evidence upload is non-critical + } + } /** * Main method to process and upload results */ @@ -232,12 +407,30 @@ class XrayJsonReporter { return; } try { - console.log(`${this.styles.info} Processing Playwright results...`); + const processStart = Date.now(); + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.info} Processing Playwright results for Xray...`); + console.log(`${this.styles.info} Project Key: ${env_1.default.XRAY_PROJECT_KEY || 'SAND'}`); + console.log(`${this.styles.info} Environment: ${process.env.TARGET_ENV || 'qa1'}`); + const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; + if (testExecKey && testExecKey !== 'none' && testExecKey.trim() !== '') { + console.log(`${this.styles.info} Linking to Test Execution: ${testExecKey}`); + } + else { + console.log(`${this.styles.info} Creating new Test Execution`); + } const xrayResult = await this.convertPlaywrightJsonToXray(playwrightJsonPath); // Save converted result for debugging node_fs_1.default.writeFileSync('test-results/xray-execution.json', JSON.stringify(xrayResult, null, 2)); - await this.uploadToXray(xrayResult); - console.log(`${this.styles.upload} Xray upload completed successfully`); + console.log(`${this.styles.info} Saved Xray JSON to: test-results/xray-execution.json`); + const importResponse = await this.uploadToXray(xrayResult); + // Phase 3 - Upload deferred evidence via GraphQL + if (this.deferredEvidenceUploads.length > 0 && importResponse) { + await this.uploadDeferredEvidenceViaGraphQL(importResponse); + } + const totalDuration = Date.now() - processStart; + console.log(`${this.styles.upload} Xray upload completed successfully (${totalDuration}ms)`); + console.log(`${this.styles.separator}\n`); } catch (error) { console.error(`${this.styles.error} Failed to process and upload:`, error); @@ -267,10 +460,13 @@ class XrayJsonReporter { console.log(`Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`); console.log(`Duration: ${result.duration}ms`); console.log(`${this.styles.separator}\n`); - // Auto-upload if JSON results are available - const jsonPath = 'test-results/last-run.json'; - if (node_fs_1.default.existsSync(jsonPath)) { - await this.processAndUpload(jsonPath); + // Only attempt upload if Xray credentials and execution key are configured + const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; + if (env_1.default.XRAY_CLIENT_ID && env_1.default.XRAY_CLIENT_SECRET && testExecKey && testExecKey !== 'none') { + const jsonPath = 'test-results/last-run.json'; + if (node_fs_1.default.existsSync(jsonPath)) { + await this.processAndUpload(jsonPath); + } } } } diff --git a/docs/XRAY_INTEGRATION.md b/docs/XRAY_INTEGRATION.md index 92033c5..d72e063 100644 --- a/docs/XRAY_INTEGRATION.md +++ b/docs/XRAY_INTEGRATION.md @@ -2,105 +2,108 @@ ## Overview -This project uses a unified JSON-based Xray integration that captures rich test data from Playwright and uploads it to Xray with step-by-step evidence including screenshots, videos, and test annotations. +This project uses a JSON-based Xray integration that captures Playwright test data and uploads it to JIRA Xray Cloud with evidence handling including screenshots and videos (failed tests only). ## Architecture -### 1. **Playwright Configuration** (`playwright.config.ts`) +### 1. **Playwright Configuration** ([playwright.config.ts](../playwright.config.ts)) - **JSON Reporter**: Generates `test-results/last-run.json` with complete test data -- **Xray JSON Reporter**: Custom reporter that automatically uploads to Xray -- **Legacy XML Reporter**: Still available for backward compatibility +- **Xray JSON Reporter**: Custom reporter that automatically uploads to Xray Cloud ```typescript reporter: [ ['html', { open: 'never', outputFolder: 'playwright-report' }], - ['json', { outputFile: 'test-results/last-run.json' }], // New JSON format - ['junit', xrayOptions], // Legacy XML format - ['./utilities/xray-json-reporter.ts'], // Auto-upload to Xray + ['json', { outputFile: 'test-results/last-run.json' }], + ['./utilities/xray-json-reporter.ts'], // Auto-upload to Xray ], ``` -### 2. **Xray JSON Reporter** (`utilities/xray-json-reporter.ts`) +### 2. **Xray JSON Reporter** ([utilities/xray-json-reporter.ts](../utilities/xray-json-reporter.ts)) -**Features:** +**Core Features:** -- Maps Playwright test steps to Xray test steps with individual evidence +- Maps Playwright test steps to Xray test steps with evidence - Attaches screenshots per step (e.g., `step-01-given-clinician-has-been-logged-in.png`) -- Includes test tags, annotations, and custom properties -- Embeds video evidence for failed tests +- Embeds video evidence for failed tests only +- Supports configurable project keys - Supports test execution key parameter for linking to existing test executions +**Evidence Handling:** + +- **Videos**: Only uploaded for failed tests (saves storage) +- **Screenshots**: Always included as base64-encoded inline evidence +- **JSON responses**: Always included inline +- Passing test videos are skipped entirely + **Data Mapping:** - **Test Steps**: Extracts from `Step Duration:` annotations - **Evidence**: Screenshots, videos, JSON responses per step -- **Status**: Pass/Fail/Pending with detailed failure messages -- **Metadata**: Environment, build info, test tags +- **Status**: PASSED/FAILED/TODO with detailed failure messages -### 3. **CircleCI Integration** (`.circleci/config.yml`) +### 3. **CircleCI Integration** ([.circleci/config.yml](../.circleci/config.yml)) -**Simplified Workflow:** +The Xray reporter uploads automatically during `onEnd` — no separate CI step needed. -1. Run tests → Generate `test-results/last-run.json` -2. Build TypeScript utilities -3. Upload to Xray using `node utilities/upload-to-xray.js` +**Pipeline Parameters:** -**Environment Variables:** - -- `TEST_EXECUTION_KEY`: Links results to existing Xray test execution -- `XRAY_CLIENT_ID`: Xray API authentication -- `XRAY_CLIENT_SECRET`: Xray API authentication -- `TARGET_ENV`: Test environment (qa1, qa2, etc.) +- `testEnvironment` - Target environment (qa1, qa2, qa3, qa4, qa5, prd, int) +- `testExecKey` - Test Execution Key to link results to (or 'none' for auto-create) +- `testTags` - Filter tests by tags +- `xrayProjectKey` - Xray project key (default: 'SAND') ## Usage ### Local Development ```bash -# Run tests and auto-upload to Xray (if credentials available) +# Set required environment variables in .env +XRAY_CLIENT_ID=your_client_id +XRAY_CLIENT_SECRET=your_client_secret +XRAY_PROJECT_KEY=SAND # Optional, defaults to SAND +TARGET_ENV=qa1 +TEST_EXECUTION_KEY=SAND-1245 # Or 'none' for auto-create + +# Run tests — reporter auto-uploads to Xray if credentials are set npm test - -# Manual upload of existing results -npm run upload-to-xray test-results/last-run.json - -# Build TypeScript utilities -npm run build ``` ### CI/CD Pipeline Tests automatically upload to Xray when: -- `XRAY_CLIENT_ID` and `XRAY_CLIENT_SECRET` are available -- `TEST_EXECUTION_KEY` parameter is provided -- JSON results file exists - -### Test Tagging +- `XRAY_CLIENT_ID` and `XRAY_CLIENT_SECRET` are available in environment +- `TEST_EXECUTION_KEY` is set (and not 'none') +- JSON results file exists (`test-results/last-run.json`) -Use test tags to organize and filter results in Xray: +**CircleCI Pipeline Triggers:** -```typescript -{ - tag: createValidatedTags([ - TEST_TAGS.PATIENT, - TEST_TAGS.API, - TEST_TAGS.HIGH, - TEST_TAGS.API_USER, - ]), -} +```bash +# Run tests on qa2 and link to existing test execution +curl -X POST \ + --url https://circleci.com/api/v2/project/github/your-org/your-repo/pipeline \ + -H "Circle-Token: $CIRCLE_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "parameters": { + "testEnvironment": "qa2", + "testExecKey": "SAND-123", + "xrayProjectKey": "SAND" + } + }' ``` ## Xray JSON Format -### Test Execution Structure +### Execution Structure ```json { + "testExecutionKey": "SAND-1245", "info": { "summary": "Playwright Test Execution - 2025-08-22T19:50:15.680Z", - "testExecutionKey": "XT-123", - "testEnvironments": ["qa1"], + "description": "Automated test execution for qa1 environment\n\nResults: 45 passed, 2 failed, 1 skipped", "startDate": "2025-08-22T19:50:15.680Z", "finishDate": "2025-08-22T19:50:56.408Z" }, @@ -108,18 +111,26 @@ Use test tags to organize and filter results in Xray: } ``` +**Note:** `testExecutionKey` is at the root level. When linking to an existing execution, `testEnvironments` and `version` are omitted to avoid validation errors. + ### Individual Test Structure ```json { "testInfo": { "summary": "should allow navigation to account settings", - "type": "Generic", - "projectKey": "XT", - "labels": ["patient", "api", "high"] + "type": "Manual", + "projectKey": "SAND", + "steps": [ + { + "action": "When user navigates to settings", + "data": "Duration: 5193ms", + "result": "Then the settings page is displayed" + } + ] }, - "status": "PASS", - "evidences": [ + "status": "PASSED", + "evidence": [ { "data": "base64-encoded-screenshot", "filename": "final-screenshot.png", @@ -128,13 +139,11 @@ Use test tags to organize and filter results in Xray: ], "steps": [ { - "action": "Given clinician has been logged in", - "data": "Duration: 5193ms", - "status": "PASS", - "evidences": [ + "status": "PASSED", + "evidence": [ { "data": "base64-encoded-step-screenshot", - "filename": "step-01-given-clinician-has-been-logged-in.png", + "filename": "step-01-screenshot.png", "contentType": "image/png" } ] @@ -143,44 +152,100 @@ Use test tags to organize and filter results in Xray: } ``` -## Benefits Over Legacy XML +**Key details:** -| Feature | XML (Legacy) | JSON (New) | -| ----------------- | --------------------- | ---------------------- | -| Test Steps | āŒ Basic only | āœ… Full step breakdown | -| Screenshots | āŒ Separate API calls | āœ… Embedded per step | -| Videos | āŒ Not supported | āœ… Embedded evidence | -| Custom Properties | āŒ Limited | āœ… Rich metadata | -| Test Tags | āŒ Basic | āœ… Full tag system | -| Debugging Info | āŒ Minimal | āœ… Comprehensive | +- `testInfo.steps` contains step **definitions** (action, data, result) +- `test.steps` contains step **execution results** (status, evidence, actualResult) +- Status values are `PASSED`, `FAILED`, `TODO`, `EXECUTING` (Xray Cloud format) +- Evidence field is singular `evidence` (not `evidences`) -## Migration Notes +### Step Mapping Logic -### Current State +Given/When/Then steps are mapped as follows: -- **JSON**: Primary integration with rich evidence -- **XML**: Available for backward compatibility -- **Duplicate Steps**: Removed from CircleCI +- **Given** → Standalone step (action only) +- **When** → Step action; consecutive Then/And steps become its `result` +- **Then/And** → Combined as the result of the preceding When step -### Future Cleanup +**Example:** +``` +When user logs in → action: "When user logs in" +Then user sees dashboard result: "Then user sees dashboard\nAnd user sees welcome" +And user sees welcome +``` -Once fully validated, remove: +## Configuration Reference -- `xrayOptions` configuration in `playwright.config.ts` -- `['junit', xrayOptions]` reporter -- Legacy `utilities/xray-reporter.ts` file +### Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `XRAY_CLIENT_ID` | Yes | - | Xray Cloud API client ID | +| `XRAY_CLIENT_SECRET` | Yes | - | Xray Cloud API client secret | +| `XRAY_PROJECT_KEY` | No | `SAND` | Jira project key for Xray tests | +| `TARGET_ENV` | Yes | `qa1` | Test environment | +| `TEST_EXECUTION_KEY` | No | `none` | Link to existing test execution (or 'none' to auto-create) | +| `TEST_TAGS` | No | - | Filter tests by tags | + +### File Locations + +| File | Purpose | +|------|---------| +| `test-results/last-run.json` | Playwright JSON results (source data) | +| `test-results/xray-execution.json` | Converted Xray JSON format (debug) | +| `playwright-report/` | HTML test report | ## Troubleshooting ### Common Issues -1. **Missing JSON file**: Ensure `json` reporter is enabled in Playwright config -2. **Upload failures**: Check Xray credentials and network connectivity -3. **Step evidence missing**: Verify step naming conventions in test annotations -4. **TypeScript compilation**: Run `npm run build` before upload +1. **No upload happening** + - Check `XRAY_CLIENT_ID` and `XRAY_CLIENT_SECRET` are set + - Verify `TEST_EXECUTION_KEY` is set and not 'none' + - Check console output for authentication errors + +2. **Tests not appearing in correct project** + - Verify `XRAY_PROJECT_KEY` is set to correct project + - Ensure project key is uppercase (e.g., 'SAND', not 'sand') + +3. **"Result is not valid Xray Format" error** + - Check `test-results/xray-execution.json` for the actual payload + - Verify `testExecutionKey` is at root level (not inside `info`) + - Ensure status values are `PASSED`/`FAILED` (not `PASS`/`FAIL`) + +4. **"environments dont exist" or "Version name not valid" errors** + - These occur when `testEnvironments` or `version` don't match Jira project config + - When linking to existing executions, these fields are automatically omitted + +5. **Steps showing as TODO instead of PASSED** + - Verify status values use Xray Cloud format: `PASSED`, `FAILED`, `TODO` + - Xray Server uses `PASS`/`FAIL` but Cloud uses `PASSED`/`FAILED` ### Debug Information -- Generated JSON saved to `test-results/xray-execution.json` -- Full logs available in CircleCI build output -- Test step timing and evidence captured in annotations +- Check console output during test run for upload status +- Review `test-results/xray-execution.json` for the converted payload +- Check CircleCI build logs for upload details + +## API Reference + +### Xray Cloud Endpoints Used + +1. **Authentication** + - Endpoint: `POST https://xray.cloud.getxray.app/api/v1/authenticate` + - Input: `{ client_id, client_secret }` + - Output: Token string + +2. **Import Execution Results** + - Endpoint: `POST https://xray.cloud.getxray.app/api/v2/import/execution` + - Auth: Bearer token + - Input: Xray JSON format + - Output: Test execution details + +## Support + +For issues or questions: +- Check this documentation first +- Review CircleCI build logs +- Inspect `test-results/xray-execution.json` for payload details +- Verify environment variables are set correctly diff --git a/package-lock.json b/package-lock.json index 277afef..22f5a65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,32 +45,32 @@ } }, "node_modules/@dabh/diagnostics": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", - "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", "license": "MIT", "dependencies": { - "colorspace": "1.1.x", + "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "node_modules/@emnapi/core": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", - "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.0.4", + "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", - "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "dev": true, "license": "MIT", "optional": true, @@ -79,9 +79,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz", - "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "dev": true, "license": "MIT", "optional": true, @@ -90,9 +90,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -122,9 +122,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -132,11 +132,14 @@ } }, "node_modules/@eslint/compat": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.3.1.tgz", - "integrity": "sha512-k8MHony59I5EPic6EQTCNOuPoVBnoYXkP+20xvwFjN7t0qI3ImyvyBgg+hIVPwC8JaxVjjUZld+cLfBLFDLucg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.1.tgz", + "integrity": "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -150,13 +153,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -189,19 +192,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", - "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -212,9 +218,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -224,7 +230,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -296,23 +302,10 @@ "node": "*" } }, - "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@eslint/js": { - "version": "9.32.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz", - "integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -323,9 +316,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -333,13 +326,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", - "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.1", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -383,18 +376,36 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", - "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", "dependencies": { - "@grpc/proto-loader": "^0.7.13", + "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" }, "engines": { "node": ">=12.10.0" } }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@grpc/proto-loader": { "version": "0.7.15", "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", @@ -426,6 +437,19 @@ "@grpc/grpc-js": "^1.8.21" } }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -437,33 +461,19 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -492,29 +502,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", @@ -550,36 +537,54 @@ } }, "node_modules/@kubernetes/client-node": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-1.3.0.tgz", - "integrity": "sha512-IE0yrIpOT97YS5fg2QpzmPzm8Wmcdf4ueWMn+FiJSI3jgTTQT1u+LUhoYpdfhdHAVxdrNsaBg2C0UXSnOgMoCQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-1.4.0.tgz", + "integrity": "sha512-Zge3YvF7DJi264dU1b3wb/GmzR99JhUpqTvp+VGHfwZT+g7EOOYNScDJNZwXy9cszyIGPIs0VHr+kk8e95qqrA==", "license": "Apache-2.0", "dependencies": { "@types/js-yaml": "^4.0.1", - "@types/node": "^22.0.0", - "@types/node-fetch": "^2.6.9", + "@types/node": "^24.0.0", + "@types/node-fetch": "^2.6.13", "@types/stream-buffers": "^3.0.3", "form-data": "^4.0.0", "hpagent": "^1.2.0", "isomorphic-ws": "^5.0.0", "js-yaml": "^4.1.0", "jsonpath-plus": "^10.3.0", - "node-fetch": "^2.6.9", + "node-fetch": "^2.7.0", "openid-client": "^6.1.3", "rfc4648": "^1.3.0", "socks-proxy-agent": "^8.0.4", "stream-buffers": "^3.0.2", - "tar-fs": "^3.0.8", + "tar-fs": "^3.0.9", "ws": "^8.18.2" } }, + "node_modules/@kubernetes/client-node/node_modules/@types/node": { + "version": "24.10.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.8.tgz", + "integrity": "sha512-r0bBaXu5Swb05doFYO2kTWHMovJnNVbCsII0fhesM8bNRlLhXIuckley4a2DaD+vOdmm5G+zGkQZAPZsF80+YQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@kubernetes/client-node/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.1.tgz", - "integrity": "sha512-CPle1OQehbWqd25La9Ack5B07StKIxh4+Bf19qnpZKJC1oI22Y0czZHbifjw1UoczIfKBwBDAp/dFxvHG13B5A==", + "version": "1.25.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", + "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", "dev": true, + "license": "MIT", "dependencies": { - "ajv": "^6.12.6", + "@hono/node-server": "^1.19.7", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -587,37 +592,29 @@ "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" }, "engines": { "node": ">=18" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -631,44 +628,6 @@ "@tybys/wasm-util": "^0.10.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@open-draft/until": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz", @@ -689,18 +648,21 @@ } }, "node_modules/@percy/sdk-utils": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/@percy/sdk-utils/-/sdk-utils-1.31.1.tgz", - "integrity": "sha512-OU+n/TGEPt7ZikJOwau9S0X0bCfKNTxHIry9dX57amL82PysCrzEfcKUJIAf1BTaVqDH4In8GPssjLVhut95Ag==", + "version": "1.31.8", + "resolved": "https://registry.npmjs.org/@percy/sdk-utils/-/sdk-utils-1.31.8.tgz", + "integrity": "sha512-S+qxi4TIOvToAD5j89nkdDj0Xj5CH8YJxpI6ZRVJE/UQE+amHIP34KiTdrWKw5aPlYEwNPeNn9UlXz5HUr5Z9g==", "license": "MIT", + "dependencies": { + "pac-proxy-agent": "^7.0.2" + }, "engines": { "node": ">=14" } }, "node_modules/@percy/selenium-webdriver": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@percy/selenium-webdriver/-/selenium-webdriver-2.2.3.tgz", - "integrity": "sha512-dVUsgKkDUYvv7+jN4S4HuwSoYxb7Up0U7dM3DRj3/XzLp3boZiyTWAdFdOGS8R5eSsiY5UskTcGQKmGqHRle1Q==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@percy/selenium-webdriver/-/selenium-webdriver-2.2.5.tgz", + "integrity": "sha512-Bb8PtXwkE7Fu2oQAKBUMxejsC5+BOyo08vVM13NgdjJooNr7JeqbfZ6wbpzkG34HRjqu2C+ihXj8naYJE1OKlA==", "license": "MIT", "dependencies": { "@percy/sdk-utils": "^1.30.9", @@ -728,6 +690,7 @@ "resolved": "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.32.tgz", "integrity": "sha512-1pEULH5zF+NuUCBGRDEei7+Qv1pbkscaR0z3fKjAp7CsrS1DAgu62DHwCSbkTulXkd5nY1TdCCr4oK+shCB7Eg==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.16.0", "commander": "^13.1.0", @@ -747,10 +710,11 @@ } }, "node_modules/@playwright/mcp/node_modules/dotenv": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", - "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -758,46 +722,46 @@ "url": "https://dotenvx.com" } }, - "node_modules/@playwright/mcp/node_modules/playwright": { - "version": "1.55.0-alpha-1752701791000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-1752701791000.tgz", - "integrity": "sha512-PA3TvDz7uQ+Pde0uaii5/WpU5vntRJsYFsaSPoBzywIqzYFO1ugk1ZZ0q6z4/xHq0ha1UClvsv3P77B+u1fi+w==", - "dev": true, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.55.0-alpha-1752701791000" + "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" }, "engines": { "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" } }, - "node_modules/@playwright/mcp/node_modules/playwright-core": { - "version": "1.55.0-alpha-1752701791000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-1752701791000.tgz", - "integrity": "sha512-mQhzhjJMiqnGNnYZv7M4yk1OcNTt1E72jrTLO7EqZuoeat4+qpcU0/mbK+RcTEass5a9YheoVFh6OIhruFMGVg==", - "dev": true, + "node_modules/@playwright/test/node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, "bin": { - "playwright-core": "cli.js" + "playwright": "cli.js" }, "engines": { "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" } }, - "node_modules/@playwright/test": { - "version": "1.54.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz", - "integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==", + "node_modules/@playwright/test/node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "license": "Apache-2.0", - "dependencies": { - "playwright": "1.54.1" - }, "bin": { - "playwright": "cli.js" + "playwright-core": "cli.js" }, "engines": { "node": ">=18" @@ -920,6 +884,16 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, "node_modules/@stylistic/eslint-plugin": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-3.1.0.tgz", @@ -940,19 +914,6 @@ "eslint": ">=8.40.0" } }, - "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -981,9 +942,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", - "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", "optional": true, @@ -1051,9 +1012,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.17.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz", - "integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==", + "version": "22.19.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz", + "integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1098,6 +1059,27 @@ "node": ">= 0.12" } }, + "node_modules/@types/request/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@types/request/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -1108,9 +1090,9 @@ } }, "node_modules/@types/stream-buffers": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/stream-buffers/-/stream-buffers-3.0.7.tgz", - "integrity": "sha512-azOCy05sXVXrO+qklf0c/B07H/oHaIuDDAiHPVwlk3A9Ek+ksHyTeMajLZl3r76FxpPpxem//4Te61G1iW3Giw==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/stream-buffers/-/stream-buffers-3.0.8.tgz", + "integrity": "sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==", "license": "MIT", "dependencies": { "@types/node": "*" @@ -1129,21 +1111,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", - "integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", + "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/type-utils": "8.38.0", - "@typescript-eslint/utils": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/type-utils": "8.53.0", + "@typescript-eslint/utils": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1153,9 +1134,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.38.0", + "@typescript-eslint/parser": "^8.53.0", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -1169,17 +1150,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz", - "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz", + "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1190,19 +1171,19 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", - "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz", + "integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.38.0", - "@typescript-eslint/types": "^8.38.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.53.0", + "@typescript-eslint/types": "^8.53.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1212,18 +1193,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz", - "integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz", + "integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0" + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1234,9 +1215,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", - "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz", + "integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==", "dev": true, "license": "MIT", "engines": { @@ -1247,21 +1228,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz", - "integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.0.tgz", + "integrity": "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/utils": "8.38.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/utils": "8.53.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1272,13 +1253,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", - "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz", + "integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==", "dev": true, "license": "MIT", "engines": { @@ -1290,22 +1271,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", - "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz", + "integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.38.0", - "@typescript-eslint/tsconfig-utils": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.53.0", + "@typescript-eslint/tsconfig-utils": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1315,36 +1295,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz", - "integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz", + "integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1355,17 +1319,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz", - "integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz", + "integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/types": "8.53.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -1662,6 +1626,7 @@ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "dev": true, + "license": "MIT", "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" @@ -1670,27 +1635,6 @@ "node": ">= 0.6" } }, - "node_modules/accepts/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1742,6 +1686,24 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-align": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", @@ -1865,9 +1827,9 @@ } }, "node_modules/aws-sdk": { - "version": "2.1692.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1692.0.tgz", - "integrity": "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw==", + "version": "2.1693.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1693.0.tgz", + "integrity": "sha512-cJmb8xEnVLT+R6fBS5sn/EFJiX7tUnDaPtOPZ1vFbOJtd0fnZn/Ky2XGgsvvoeliWeH7mL3TWSX5zXXGSQV6gQ==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1896,10 +1858,18 @@ } }, "node_modules/b4a": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", - "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", - "license": "Apache-2.0" + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } }, "node_modules/balanced-match": { "version": "1.0.2", @@ -1908,22 +1878,31 @@ "license": "MIT" }, "node_modules/bare-events": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.0.tgz", - "integrity": "sha512-EKZ5BTXYExaNqi3I3f9RtEsaI/xBSGjE0XZCZilPzFAV/goswFHuPd9jEZlPIZ/iNZJwDSao9qRiScySz7MbQg==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", - "optional": true + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } }, "node_modules/bare-fs": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.6.tgz", - "integrity": "sha512-25RsLF33BqooOEFNdMcEhMpJy8EoR88zSMrnOQOaM3USnOK2VmaJ1uaQEwPA6AQjrv1lXChScosN6CzbwbO9OQ==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", + "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", "license": "Apache-2.0", "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", - "bare-stream": "^2.6.4" + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" }, "engines": { "bare": ">=1.16.0" @@ -1938,9 +1917,9 @@ } }, "node_modules/bare-os": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", - "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", "license": "Apache-2.0", "optional": true, "engines": { @@ -1958,9 +1937,9 @@ } }, "node_modules/bare-stream": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", - "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1979,6 +1958,16 @@ } } }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2000,9 +1989,9 @@ "license": "MIT" }, "node_modules/basic-ftp": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", - "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", + "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -2024,23 +2013,28 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "dev": true, + "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/boolbase": { @@ -2079,9 +2073,9 @@ } }, "node_modules/boxen/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -2091,9 +2085,9 @@ } }, "node_modules/boxen/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -2126,9 +2120,9 @@ } }, "node_modules/boxen/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -2161,19 +2155,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserstack-local": { "version": "1.5.8", "resolved": "https://registry.npmjs.org/browserstack-local/-/browserstack-local-1.5.8.tgz", @@ -2188,9 +2169,9 @@ } }, "node_modules/browserstack-node-sdk": { - "version": "1.40.3", - "resolved": "https://registry.npmjs.org/browserstack-node-sdk/-/browserstack-node-sdk-1.40.3.tgz", - "integrity": "sha512-KrY6LLRsr2kOgm/QSromgIMbG9+ICAy6sandv/xdlqi4GjLCi6gksIomCQdkrAGVfHFXSFOY8PPpupgwi4uSGA==", + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/browserstack-node-sdk/-/browserstack-node-sdk-1.49.1.tgz", + "integrity": "sha512-nb5O2rO8Zww339KagvsLATJ4KaUokItpptZn9C0BT3NFQcmAWm0Rj+MkHqV0FXw2pRRc0CE6+im7FZnqPjDjCQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@google-cloud/compute": "^4.0.1", @@ -2220,6 +2201,7 @@ "google-protobuf": "^3.20.1", "googleapis": "^126.0.1", "got": "^11.8.6", + "https-proxy-agent": "^5.0.1", "jest-worker": "^28.1.0", "js-yaml": "^4.1.0", "js-yaml-cloudformation-schema": "^1.0.0", @@ -2236,7 +2218,7 @@ "update-notifier": "6.0.2", "uuid": "^8.3.2", "windows-release": "^5.1.0", - "winston": "^3.8.2", + "winston": "^3.18.3", "winston-transport": "^4.5.0", "ws": "^8.17.1", "yargs": "^17.5.1", @@ -2279,6 +2261,7 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -2505,13 +2488,16 @@ } }, "node_modules/color": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", - "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", "license": "MIT", "dependencies": { - "color-convert": "^1.9.3", - "color-string": "^1.6.0" + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" } }, "node_modules/color-convert": { @@ -2533,38 +2519,45 @@ "license": "MIT" }, "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", "license": "MIT", "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" } }, "node_modules/color/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", "license": "MIT", "dependencies": { - "color-name": "1.1.3" + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" } }, "node_modules/color/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/colorspace": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", - "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", "license": "MIT", - "dependencies": { - "color": "^3.1.3", - "text-hex": "1.0.x" + "engines": { + "node": ">=12.20" } }, "node_modules/combined-stream": { @@ -2584,6 +2577,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" } @@ -2656,15 +2650,17 @@ "license": "MIT" }, "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "dev": true, - "dependencies": { - "safe-buffer": "5.2.1" + "license": "MIT", + "engines": { + "node": ">=18" }, - "engines": { - "node": ">= 0.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -2672,6 +2668,7 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -2681,6 +2678,7 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -2690,6 +2688,7 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.6.0" } @@ -2705,6 +2704,7 @@ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", "dev": true, + "license": "MIT", "dependencies": { "object-assign": "^4", "vary": "^1" @@ -2817,15 +2817,15 @@ } }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "license": "MIT" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2953,6 +2953,7 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -3096,7 +3097,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/emittery": { "version": "0.11.0", @@ -3127,6 +3129,7 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -3141,9 +3144,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", - "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3242,7 +3245,8 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/escape-string-regexp": { "version": "4.0.0", @@ -3278,25 +3282,24 @@ } }, "node_modules/eslint": { - "version": "9.32.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", - "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.0", - "@eslint/core": "^0.15.0", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.32.0", - "@eslint/plugin-kit": "^0.3.4", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", @@ -3355,28 +3358,31 @@ } }, "node_modules/eslint-config-airbnb-extended": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/eslint-config-airbnb-extended/-/eslint-config-airbnb-extended-2.1.2.tgz", - "integrity": "sha512-hcph8OvzwNfLifdw1nPBYFTsJwWF1ESf7JgYwpe0u4ZWflsfUE9EonDNAk9dGEJqkS3PDBGaVhf1u60uF7coTg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/eslint-config-airbnb-extended/-/eslint-config-airbnb-extended-2.3.3.tgz", + "integrity": "sha512-1p/dQedg2lzPx/PlX5EpVlIY1UvZ5eflrO18rGs9DbhmVKCRv4/47irOekmPrrEGAZhbqrcmpzJIA+DE6yzHTQ==", "dev": true, "license": "MIT", "dependencies": { "confusing-browser-globals": "^1.0.11", - "globals": "^16.3.0" + "globals": "^16.5.0" + }, + "engines": { + "node": ">=16.0.0" }, "peerDependencies": { - "@next/eslint-plugin-next": "15.x", - "@stylistic/eslint-plugin": "3.x", - "@types/eslint-plugin-jsx-a11y": "6.x", - "eslint": "9.x", - "eslint-import-resolver-typescript": "4.x", - "eslint-plugin-import": "2.x", - "eslint-plugin-import-x": "4.x", - "eslint-plugin-jsx-a11y": "6.x", - "eslint-plugin-n": "17.x", - "eslint-plugin-react": "7.x", - "eslint-plugin-react-hooks": "5.x", - "typescript-eslint": "8.x" + "@next/eslint-plugin-next": "^15.0.0 || ^16.0.0", + "@stylistic/eslint-plugin": "^3.0.0", + "@types/eslint-plugin-jsx-a11y": "^6.0.0", + "eslint": "^9.0.0", + "eslint-import-resolver-typescript": "^4.0.0", + "eslint-plugin-import": "^2.0.0", + "eslint-plugin-import-x": "^4.0.0", + "eslint-plugin-jsx-a11y": "^6.0.0", + "eslint-plugin-n": "^17.0.0", + "eslint-plugin-react": "^7.0.0", + "eslint-plugin-react-hooks": "^5.0.0 || ^6.0.0 || ^7.0.0", + "typescript-eslint": "^8.0.0" }, "peerDependenciesMeta": { "@next/eslint-plugin-next": { @@ -3552,26 +3558,10 @@ } } }, - "node_modules/eslint-plugin-import-x/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/eslint-plugin-n": { - "version": "17.21.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.21.3.tgz", - "integrity": "sha512-MtxYjDZhMQgsWRm/4xYLL0i2EhusWT7itDxlJ80l1NND2AL2Vi5Mvneqv/ikG9+zpran0VsVRXTEHrpLmUZRNw==", + "version": "17.23.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.23.2.tgz", + "integrity": "sha512-RhWBeb7YVPmNa2eggvJooiuehdL76/bbfj/OJewyoGT80qn5PXdz8zMOTO6YHOsI7byPt7+Ighh/i/4a5/v7hw==", "dev": true, "license": "MIT", "dependencies": { @@ -3624,14 +3614,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.3.tgz", - "integrity": "sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==", + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", "dev": true, "license": "MIT", "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.11.7" + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -3764,9 +3754,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3812,6 +3802,7 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -3849,11 +3840,21 @@ "node": ">=0.4.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "dev": true, + "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" }, @@ -3862,12 +3863,13 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.3.tgz", - "integrity": "sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", "dev": true, + "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=18.0.0" } }, "node_modules/execa": { @@ -3906,18 +3908,20 @@ } }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, + "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -3952,6 +3956,7 @@ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 16" }, @@ -3962,27 +3967,6 @@ "express": ">= 4.11" } }, - "node_modules/express/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -4008,36 +3992,6 @@ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4053,9 +4007,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "funding": [ { "type": "github", @@ -4068,16 +4022,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -4087,6 +4031,24 @@ "pend": "~1.2.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -4106,24 +4068,12 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", @@ -4133,7 +4083,11 @@ "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/find-up": { @@ -4181,9 +4135,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -4216,9 +4170,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -4240,11 +4194,33 @@ "node": ">= 14.17" } }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -4254,6 +4230,7 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -4358,6 +4335,15 @@ "node": ">=14" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -4420,9 +4406,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", - "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4512,6 +4498,18 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/global-agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", @@ -4742,13 +4740,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/gtoken": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", @@ -4840,6 +4831,17 @@ "integrity": "sha512-DAzV5P/pk3wTU/8TLZN+zFTDv4Xa1QDTU8pRvovPetcOMbmqq8CwsAvZBLPZHH6usxyy31zMp7I4aCYb6XIf6w==", "license": "MIT" }, + "node_modules/hono": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hpagent": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", @@ -4875,28 +4877,24 @@ "license": "BSD-2-Clause" }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "dev": true, + "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "engines": { - "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-proxy-agent": { @@ -4957,15 +4955,20 @@ } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "dev": true, + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ieee754": { @@ -5043,14 +5046,10 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, "engines": { "node": ">= 12" } @@ -5060,6 +5059,7 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.10" } @@ -5080,12 +5080,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "license": "MIT" - }, "node_modules/is-bun-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", @@ -5140,13 +5134,14 @@ } }, "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, @@ -5187,9 +5182,9 @@ } }, "node_modules/is-npm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.0.0.tgz", - "integrity": "sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", + "integrity": "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==", "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -5198,16 +5193,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-obj": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", @@ -5230,7 +5215,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/is-regex": { "version": "1.2.1", @@ -5349,9 +5335,9 @@ } }, "node_modules/jiti": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", - "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", "bin": { @@ -5368,18 +5354,18 @@ } }, "node_modules/jose": { - "version": "6.0.12", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.12.tgz", - "integrity": "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5407,9 +5393,9 @@ } }, "node_modules/js-yaml-cloudformation-schema/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -5437,12 +5423,6 @@ "js-yaml": "4.x" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "license": "MIT" - }, "node_modules/jsep": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", @@ -5473,6 +5453,13 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -5516,12 +5503,12 @@ } }, "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -5715,6 +5702,7 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -5724,6 +5712,7 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -5737,38 +5726,15 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz", - "integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", "dev": true, "funding": [ "https://github.com/sponsors/broofa" ], + "license": "MIT", "bin": { "mime": "bin/cli.js" }, @@ -5777,24 +5743,30 @@ } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mimic-fn": { @@ -5816,15 +5788,19 @@ } }, "node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -5849,9 +5825,9 @@ "license": "MIT" }, "node_modules/napi-postinstall": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.2.tgz", - "integrity": "sha512-tWVJxJHmBWLy69PvO96TZMZDrzmw5KeiZBz3RHmiM2XZ9grBJ2WgMAFVVg25nqp3ZjTFUs2Ftw1JhscL3Teliw==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", "dev": true, "license": "MIT", "bin": { @@ -5876,6 +5852,7 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -5967,9 +5944,9 @@ } }, "node_modules/oauth4webapi": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.6.0.tgz", - "integrity": "sha512-OwXPTXjKPOldTpAa19oksrX9TYHA0rt+VcUFTkJ7QKwgmevPpNm9Cn5vFZUtIo96FiU6AfPuUUGzoXqgOzibWg==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.3.tgz", + "integrity": "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -5980,6 +5957,7 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -6019,6 +5997,7 @@ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "dev": true, + "license": "MIT", "dependencies": { "ee-first": "1.1.1" }, @@ -6060,13 +6039,13 @@ } }, "node_modules/openid-client": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.6.2.tgz", - "integrity": "sha512-Xya5TNMnnZuTM6DbHdB4q0S3ig2NTAELnii/ASie1xDEr8iiB8zZbO871OWBdrw++sd3hW6bqWjgcmSy1RTWHA==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.1.tgz", + "integrity": "sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==", "license": "MIT", "dependencies": { - "jose": "^6.0.11", - "oauth4webapi": "^3.5.4" + "jose": "^6.1.0", + "oauth4webapi": "^3.8.2" }, "funding": { "url": "https://github.com/sponsors/panva" @@ -6328,9 +6307,9 @@ } }, "node_modules/package-json/node_modules/normalize-url": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.2.tgz", - "integrity": "sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", + "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", "license": "MIT", "engines": { "node": ">=14.16" @@ -6418,6 +6397,7 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -6451,12 +6431,14 @@ } }, "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "dev": true, - "engines": { - "node": ">=16" + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/pause-stream": { @@ -6478,34 +6460,36 @@ "license": "MIT" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=16.20.0" } }, "node_modules/playwright": { - "version": "1.54.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz", - "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==", + "version": "1.55.0-alpha-1752701791000", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-1752701791000.tgz", + "integrity": "sha512-PA3TvDz7uQ+Pde0uaii5/WpU5vntRJsYFsaSPoBzywIqzYFO1ugk1ZZ0q6z4/xHq0ha1UClvsv3P77B+u1fi+w==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.54.1" + "playwright-core": "1.55.0-alpha-1752701791000" }, "bin": { "playwright": "cli.js" @@ -6518,9 +6502,10 @@ } }, "node_modules/playwright-core": { - "version": "1.54.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz", - "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==", + "version": "1.55.0-alpha-1752701791000", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-1752701791000.tgz", + "integrity": "sha512-mQhzhjJMiqnGNnYZv7M4yk1OcNTt1E72jrTLO7EqZuoeat4+qpcU0/mbK+RcTEass5a9YheoVFh6OIhruFMGVg==", + "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -6549,9 +6534,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", "bin": { @@ -6565,9 +6550,9 @@ } }, "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", "dev": true, "license": "MIT", "dependencies": { @@ -6613,9 +6598,9 @@ } }, "node_modules/protobufjs": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", - "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -6654,6 +6639,7 @@ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "dev": true, + "license": "MIT", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -6694,9 +6680,9 @@ "license": "MIT" }, "node_modules/pupa": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz", - "integrity": "sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", + "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", "license": "MIT", "dependencies": { "escape-goat": "^4.0.0" @@ -6709,9 +6695,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -6732,27 +6718,6 @@ "node": ">=0.4.x" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -6770,23 +6735,25 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "dev": true, + "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/rc": { @@ -6804,6 +6771,15 @@ "rc": "cli.js" } }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -6827,6 +6803,18 @@ "minimatch": "^5.1.0" } }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/reconnecting-websocket": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz", @@ -6939,17 +6927,6 @@ "node": ">=14" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rfc4648": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", @@ -7034,6 +7011,7 @@ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", @@ -7045,30 +7023,6 @@ "node": ">= 18" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -7119,7 +7073,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/sax": { "version": "1.2.1", @@ -7128,9 +7083,9 @@ "license": "ISC" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7161,46 +7116,30 @@ } }, "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "dev": true, + "license": "MIT", "dependencies": { - "debug": "^4.3.5", + "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", - "statuses": "^2.0.1" + "statuses": "^2.0.2" }, "engines": { "node": ">= 18" - } - }, - "node_modules/send/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "dependencies": { - "mime-db": "^1.54.0" }, - "engines": { - "node": ">= 0.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/serialize-error": { @@ -7219,10 +7158,11 @@ } }, "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "dev": true, + "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", @@ -7231,6 +7171,10 @@ }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/set-function-length": { @@ -7254,7 +7198,8 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -7355,15 +7300,6 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/slice-ansi": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", @@ -7392,12 +7328,12 @@ } }, "node_modules/socks": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.6.tgz", - "integrity": "sha512-pe4Y2yzru68lXCb38aAqRf5gvN8YdjP1lok5o0J7BOHljkyCGKVz7H3vpVIXKD27rj2giOJ7DwVyk/GWrPHDWA==", + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "license": "MIT", "dependencies": { - "ip-address": "^9.0.5", + "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -7480,6 +7416,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -7518,16 +7455,14 @@ "license": "MIT" }, "node_modules/streamx": { - "version": "2.22.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", - "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", "license": "MIT", "dependencies": { + "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" } }, "node_modules/strict-event-emitter": { @@ -7581,12 +7516,16 @@ } }, "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/stubs": { @@ -7608,9 +7547,9 @@ } }, "node_modules/synckit": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", - "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7640,19 +7579,23 @@ } }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar-fs": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz", - "integrity": "sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -7751,14 +7694,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -7767,61 +7710,21 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "license": "MIT", "engines": { "node": ">=14.14" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.6" } @@ -7842,9 +7745,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -7877,19 +7780,6 @@ "typescript": ">=4.0.0" } }, - "node_modules/ts-declaration-location/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -7926,6 +7816,7 @@ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "dev": true, + "license": "MIT", "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", @@ -7935,27 +7826,6 @@ "node": ">= 0.6" } }, - "node_modules/type-is/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/type-is/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -7966,9 +7836,9 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -7981,16 +7851,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.38.0.tgz", - "integrity": "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.53.0.tgz", + "integrity": "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.38.0", - "@typescript-eslint/parser": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/utils": "8.38.0" + "@typescript-eslint/eslint-plugin": "8.53.0", + "@typescript-eslint/parser": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/utils": "8.53.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8001,7 +7871,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/undici-types": { @@ -8030,6 +7900,7 @@ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -8098,9 +7969,9 @@ } }, "node_modules/update-notifier/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -8178,6 +8049,7 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -8250,9 +8122,9 @@ } }, "node_modules/widest-line/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -8285,9 +8157,9 @@ } }, "node_modules/widest-line/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -8315,13 +8187,13 @@ } }, "node_modules/winston": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", - "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", "dependencies": { "@colors/colors": "^1.6.0", - "@dabh/diagnostics": "^2.0.2", + "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", @@ -8378,9 +8250,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -8390,9 +8262,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -8425,9 +8297,9 @@ } }, "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -8458,9 +8330,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -8594,12 +8466,13 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.24.6", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", - "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "dev": true, + "license": "ISC", "peerDependencies": { - "zod": "^3.24.1" + "zod": "^3.25 || ^4" } } } diff --git a/package.json b/package.json index db80757..564de8c 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,22 @@ { "name": "webuitests", "version": "1.0.0", - "description": "Tidepool UI Testing with playwright and browserstack", + "description": "Tidepool UI Testing with playwright", "main": "index.js", "scripts": { - "merge-reports": "jrm tests_output/testresults.xml \"tests_output/e2e/*.xml\" \"tests_output/ui/*.xml\"", "lint": "eslint --ext .ts . --max-warnings 999999", "lint:fix": "eslint --ext .ts . --fix", "typecheck": "tsc --noEmit", "check": "npm run lint && npm run typecheck", - "debug": "node utilities/test-runner.js --debug", - "test": "node utilities/test-runner.js", - "test:qa1": "TARGET_ENV=qa1 node utilities/test-runner.js", - "test:qa2": "TARGET_ENV=qa2 node utilities/test-runner.js", - "test:qa3": "TARGET_ENV=qa3 node utilities/test-runner.js", - "test:qa4": "TARGET_ENV=qa4 node utilities/test-runner.js", - "test:prd": "TARGET_ENV=prd node utilities/test-runner.js", - "test:int": "TARGET_ENV=int node utilities/test-runner.js", - "test:smoke": "TEST_TAGS='@smoke' node utilities/test-runner.js", - "test:critical": "TEST_TAGS='@critical' node utilities/test-runner.js", - "test:api": "TEST_TAGS='@api' node utilities/test-runner.js", - "test:ui": "TEST_TAGS='@ui' node utilities/test-runner.js", - "test:patient": "TEST_TAGS='@patient' node utilities/test-runner.js", - "test:clinician": "TEST_TAGS='@clinician' node utilities/test-runner.js", - "upload-to-xray": "node utilities/upload-to-xray.js", + "debug": "npx playwright test --debug", + "test": "npx playwright test", + "test:smoke": "npx playwright test --grep @smoke", + "test:critical": "npx playwright test --grep @critical", + "test:api": "npx playwright test --grep @api", + "test:ui": "npx playwright test --grep @ui", + "test:patient": "npx playwright test --grep @patient", + "test:clinician": "npx playwright test --grep @clinician", + "test:regression": "npx playwright test --grep @regression", "build": "tsc", "format": "prettier --write ." }, diff --git a/playwright.config.ts b/playwright.config.ts index b43418e..6bc3197 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,14 +2,6 @@ import { defineConfig, devices } from '@playwright/test'; import path from 'node:path'; import env from './utilities/env'; -// Legacy XML options - can be removed when fully migrated to JSON -const xrayOptions = { - embedAnnotationsAsProperties: true, - textContentAnnotations: ['test_description', 'testrun_comment'], - embedAttachmentsAsProperty: 'testrun_evidence', - outputFile: 'test-output/test-results.xml', -}; - // Helper to detect BrowserStack run const isBrowserStack = Boolean( process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESS_KEY, @@ -46,7 +38,6 @@ export default defineConfig({ reporter: [ ['html', { open: 'never', outputFolder: 'playwright-report' }], ['json', { outputFile: 'test-results/last-run.json' }], - ['junit', xrayOptions], ['./utilities/xray-json-reporter.ts'], ], diff --git a/tests/global-setup.ts b/tests/global-setup.ts index 17b9b53..2675952 100644 --- a/tests/global-setup.ts +++ b/tests/global-setup.ts @@ -7,29 +7,63 @@ import env from '../utilities/env'; async function loginUserType(role: 'personal' | 'claimed' | 'shared' | 'clinician') { const browser = await chromium.launch(); const context = await browser.newContext({ - baseURL: process.env.BASE_URL, + baseURL: env.BASE_URL, }); const page = await context.newPage(); - await page.goto(env.BASE_URL); - const loginPage = new LoginPage(page); - if (role === 'personal') { - await loginPage.login(env.PERSONAL_USERNAME, env.PERSONAL_PASSWORD); - await page.waitForURL('**/data'); - } else if (role === 'claimed') { - await loginPage.login(env.CLAIMED_USERNAME, env.CLAIMED_PASSWORD); - await page.waitForURL('**/data'); - } else if (role === 'shared') { - await loginPage.login(env.SHARED_USERNAME, env.SHARED_PASSWORD); - await page.waitForURL('**/data'); - } else { - await loginPage.login(env.CLINICIAN_USERNAME, env.CLINICIAN_PASSWORD); - await page.waitForURL('**/workspaces'); - } - const authDir = path.resolve(process.cwd(), 'tests', '.auth'); - await fs.promises.mkdir(authDir, { recursive: true }); - const filePath = path.join(authDir, `${role}.json`); - await context.storageState({ path: filePath }); + try { + console.log(`\nšŸ” Authenticating ${role} user on ${env.BASE_URL}...`); + await page.goto(env.BASE_URL); + const loginPage = new LoginPage(page); + + let username: string; + let password: string; + let expectedURL: string; + + if (role === 'personal') { + username = env.PERSONAL_USERNAME; + password = env.PERSONAL_PASSWORD; + expectedURL = '**/data'; + } else if (role === 'claimed') { + username = env.CLAIMED_USERNAME; + password = env.CLAIMED_PASSWORD; + expectedURL = '**/data'; + } else if (role === 'shared') { + username = env.SHARED_USERNAME; + password = env.SHARED_PASSWORD; + expectedURL = '**/data'; + } else { + username = env.CLINICIAN_USERNAME; + password = env.CLINICIAN_PASSWORD; + expectedURL = '**/workspaces'; + } + + await loginPage.login(username, password); + await page.waitForURL(expectedURL, { timeout: 15000 }); + + const authDir = path.resolve(process.cwd(), 'tests', '.auth'); + await fs.promises.mkdir(authDir, { recursive: true }); + const filePath = path.join(authDir, `${role}.json`); + await context.storageState({ path: filePath }); + + console.log(`āœ… ${role} authentication successful`); + } catch (error) { + console.error(`\nāŒ GLOBAL SETUP FAILED: Unable to authenticate ${role} user`); + console.error(`\nPossible causes:`); + console.error(` 1. Invalid credentials for ${role} user`); + console.error(` 2. Wrong environment (currently: ${env.TARGET_ENV} -> ${env.BASE_URL})`); + console.error(` 3. User account doesn't exist on this environment`); + console.error(` 4. Network issues or environment is down`); + console.error(`\nPlease verify:`); + console.error(` - TARGET_ENV in .env file is set to the correct environment`); + console.error(` - Credentials in .env file match the environment`); + console.error(` - The ${env.BASE_URL} environment is accessible\n`); + + await browser.close(); + throw new Error( + `Global setup failed: Could not authenticate ${role} user. Check credentials and environment configuration.`, + ); + } await browser.close(); } diff --git a/tests/personal/login.spec.ts b/tests/personal/login.spec.ts index ccd52ba..81589b0 100644 --- a/tests/personal/login.spec.ts +++ b/tests/personal/login.spec.ts @@ -4,6 +4,7 @@ import LoginPage from 'page-objects/LoginPage'; import WorkspacesPage from '@pom/clinician/WorkspacesPage'; import { TEST_TAGS, createValidatedTags } from '@fixtures/test-tags'; import env from '../../utilities/env'; +import { TEST_TAGS, createValidatedTags } from '../fixtures/test-tags'; // make sure we don't have any cookies or origins test.use({ storageState: { cookies: [], origins: [] } }); @@ -18,11 +19,18 @@ test.describe('Login into application', () => { async ({ page }) => { const loginPage = new LoginPage(page); + await test.step('When user is logged into application', async () => { + await loginPage.goto(); + await loginPage.login(env.CLINICIAN_USERNAME, env.CLINICIAN_PASSWORD); + }); await test.step('When user is logged into application', async () => { await loginPage.goto(); await loginPage.login(env.CLINICIAN_USERNAME, env.CLINICIAN_PASSWORD); }); + await test.step('Then the user is redirected to workspaces page', async () => { + const workspacesPage = new WorkspacesPage(page); + await page.waitForURL(workspacesPage.url); await test.step('Then the user is redirected to workspaces page', async () => { const workspacesPage = new WorkspacesPage(page); await page.waitForURL(workspacesPage.url); @@ -31,6 +39,10 @@ test.describe('Login into application', () => { }); }, ); + await expect(workspacesPage.header).toBeVisible(); + }); + }, + ); test( 'should show error message with invalid credentials', @@ -40,13 +52,17 @@ test.describe('Login into application', () => { async ({ page }) => { const loginPage = new LoginPage(page); - await test.step('When user attempts to login with invalid credentials', async () => { + await test.step('When user attempts to login with invalid username', async () => { await loginPage.goto(); // Enter email await page.fill('#username', 'invalid@email.com'); await page.click('#kc-login'); }); + // Enter email + await page.fill('#username', 'invalid@email.com'); + await page.click('#kc-login'); + }); await test.step('Then error message should be displayed', async () => { // Wait for the error message to appear @@ -66,10 +82,10 @@ test.describe('Login into application', () => { async ({ page }) => { const loginPage = new LoginPage(page); - await test.step('When user attempts to login with invalid email format', async () => { + await test.step('When user attempts to login with unrecognized email', async () => { await loginPage.goto(); - // Enter invalid email format + // Enter unrecognized email await page.fill('#username', 'invalidemail'); await page.click('#kc-login'); }); @@ -83,15 +99,28 @@ test.describe('Login into application', () => { }); }, ); + await test.step('Then email validation error should be displayed', async () => { + // Check for email validation error message + await expect(page.locator('#input-error-username')).toBeVisible(); + await expect(page.locator('#input-error-username')).toContainText( + "This email doesn't belong to an account yet.", + ); + }); + }, + ); test( - 'should show error message with invalid credentials 1', + 'should show error message with invalid password', { tag: createValidatedTags([TEST_TAGS.PATIENT, TEST_TAGS.UI, TEST_TAGS.PRIORITY_MEDIUM]), }, async ({ page }) => { const loginPage = new LoginPage(page); + await test.step('When user is logged into application', async () => { + await loginPage.goto(); + await loginPage.login(env.CLINICIAN_USERNAME, `${env.CLINICIAN_PASSWORD}1`); + }); await test.step('When user is logged into application', async () => { await loginPage.goto(); await loginPage.login(env.CLINICIAN_USERNAME, `${env.CLINICIAN_PASSWORD}1`); @@ -103,4 +132,10 @@ test.describe('Login into application', () => { }); }, ); + await test.step('Then error message should be displayed', async () => { + await expect(page.locator('#input-error')).toBeVisible(); + await expect(page.locator('#input-error')).toContainText('Invalid password.'); + }); + }, + ); }); diff --git a/utilities/env.ts b/utilities/env.ts index 9323afe..d246243 100644 --- a/utilities/env.ts +++ b/utilities/env.ts @@ -17,6 +17,9 @@ const envSchema = z.object({ TARGET_ENV: z.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production', 'prd', 'int']), XRAY_CLIENT_ID: z.string().optional(), XRAY_CLIENT_SECRET: z.string().optional(), + XRAY_PROJECT_KEY: z.string().default('SAND'), + JIRA_EMAIL: z.string().optional(), + JIRA_API_KEY: z.string().optional(), }); const env = envSchema.safeParse(process.env); diff --git a/utilities/test-runner.js b/utilities/test-runner.js index 2789dee..9b8673e 100644 --- a/utilities/test-runner.js +++ b/utilities/test-runner.js @@ -6,11 +6,17 @@ * - TEST_TAGS environment variable (space or comma separated) * - Command line arguments for additional Playwright flags * + * Tag Filtering Logic: + * - Uses Playwright's --grep-tag flag to filter tests by tag metadata + * - Space-separated tags = AND logic (test must have ALL tags) + * - Comma-separated tags = OR logic (test must have ANY tag) + * * Usage: * node utilities/test-runner.js # Run all tests on qa1 * TARGET_ENV=qa2 node utilities/test-runner.js # Run all tests on qa2 - * TEST_TAGS="@smoke @critical" node utilities/test-runner.js # Run smoke AND critical tests - * TEST_TAGS="@api,@ui" node utilities/test-runner.js # Run api OR ui tests (comma-separated = OR) + * TEST_TAGS="@smoke" node utilities/test-runner.js # Run smoke tests + * TEST_TAGS="@smoke @critical" node utilities/test-runner.js # Run tests with BOTH smoke AND critical tags + * TEST_TAGS="@api,@ui" node utilities/test-runner.js # Run tests with EITHER api OR ui tags * node utilities/test-runner.js --debug # Pass additional flags to Playwright */ @@ -49,7 +55,7 @@ function buildGrepArgs(tags) { } if (tagList.length === 1) { - // Single tag: simple grep + // Single tag: simple grep with @ prefix return ['--grep', `@${tagList[0]}`]; } @@ -62,6 +68,7 @@ function buildGrepArgs(tags) { return ['--grep', orPattern]; } // Space-separated = AND logic: (?=.*@tag1)(?=.*@tag2)(?=.*@tag3) + // Uses positive lookahead regex for AND logic const andPattern = tagList.map(tag => `(?=.*@${tag})`).join(''); return ['--grep', andPattern]; } diff --git a/utilities/upload-to-xray.js b/utilities/upload-to-xray.js deleted file mode 100644 index f635b1e..0000000 --- a/utilities/upload-to-xray.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Standalone utility to upload Playwright JSON results to Xray - * Usage: node utilities/upload-to-xray.js [path-to-json-results] - */ - -const fs = require('node:fs'); -const path = require('node:path'); - -// Import the compiled TypeScript reporter -async function uploadResults() { - try { - // Import compiled CommonJS module - // eslint-disable-next-line n/global-require, import-x/extensions - const XrayJsonReporter = require('../build/utilities/xray-json-reporter.js').default; - - const jsonPath = process.argv[2] || 'test-results/last-run.json'; - - if (!fs.existsSync(jsonPath)) { - console.error(`āŒ JSON results file not found: ${jsonPath}`); - // eslint-disable-next-line n/no-process-exit - process.exit(1); - } - - console.log(`šŸš€ Processing Playwright results from: ${jsonPath}`); - - const reporter = new XrayJsonReporter(); - await reporter.processAndUpload(jsonPath); - - console.log('āœ… Xray upload completed successfully'); - } catch (error) { - console.error('āŒ Failed to upload to Xray:', error); - // eslint-disable-next-line n/no-process-exit - process.exit(1); - } -} - -uploadResults(); diff --git a/utilities/xray-json-reporter.ts b/utilities/xray-json-reporter.ts index 9e37d3d..1a5d60e 100644 --- a/utilities/xray-json-reporter.ts +++ b/utilities/xray-json-reporter.ts @@ -2,78 +2,44 @@ import fs from 'node:fs'; import path from 'node:path'; import { FullConfig, FullResult, Suite, TestCase, TestResult } from '@playwright/test/reporter'; import env from './env'; - -interface XrayTestStep { - action: string; - data?: string; - result?: string; - status: 'PASS' | 'FAIL' | 'PENDING'; - actualResult?: string; - evidences?: { - data: string; - filename: string; - contentType: string; - }[]; -} - -interface XrayTest { - testKey?: string; - testInfo: { - summary: string; - type: 'Manual' | 'Cucumber' | 'Generic'; - projectKey: string; - labels?: string[]; - }; - status: 'PASS' | 'FAIL' | 'PENDING' | 'EXECUTING'; - comment?: string; - evidences?: { - data: string; - filename: string; - contentType: string; - }[]; - steps?: XrayTestStep[]; - examples?: string[]; -} - -interface XrayExecutionResult { - info: { - summary: string; - description: string; - version?: string; - testPlanKey?: string; - testExecutionKey?: string; - startDate: string; - finishDate: string; - testEnvironments?: string[]; - }; - tests: XrayTest[]; -} +import { + XrayTestStepDefinition, + XrayTestStepResult, + XrayTest, + XrayExecutionResult, + XrayEvidence, + XrayImportResponse, +} from './xray-types'; /** - * Unified Xray JSON Reporter for Playwright - * Maps rich Playwright test data to Xray's JSON format with step-by-step evidence + * Xray JSON Reporter for Playwright + * Maps Playwright test data to Xray Cloud JSON format and uploads results */ class XrayJsonReporter { private styles = { - success: 'āœ…', - error: 'āŒ', - info: 'ā„¹ļø', - warning: 'ā›”ļø', - upload: 'šŸš€', - test: '🧪', - separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + success: '\u2705', + error: '\u274C', + info: '\u2139\uFE0F', + warning: '\u26A0\uFE0F', + upload: '\uD83D\uDE80', + test: '\uD83E\uDDEA', + evidence: '\uD83D\uDCCE', + separator: + '\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501', }; - private startTime = ''; - - private endTime = ''; - /** * Authenticates with Xray API using client credentials */ async authenticateWithXray(): Promise { + const startAuth = Date.now(); try { - console.log(`${this.styles.info} Authenticating with Xray...`); + console.log(`${this.styles.info} Authenticating with Xray Cloud API...`); + + if (!env.XRAY_CLIENT_ID || !env.XRAY_CLIENT_SECRET) { + throw new Error('XRAY_CLIENT_ID and XRAY_CLIENT_SECRET are required for authentication'); + } + const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { method: 'POST', headers: { @@ -87,12 +53,23 @@ class XrayJsonReporter { if (!response.ok) { const errorText = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + throw new Error( + `Authentication failed (HTTP ${response.status}): ${errorText || 'No error details'}`, + ); } const token = await response.text(); - console.log(`${this.styles.success} Successfully authenticated with Xray`); - return token.replace(/"/g, ''); // Remove quotes from token + const cleanToken = token.replace(/"/g, ''); + + if (!cleanToken || cleanToken.length < 10) { + throw new Error(`Invalid token received: ${cleanToken.substring(0, 20)}...`); + } + + const authDuration = Date.now() - startAuth; + console.log( + `${this.styles.success} Successfully authenticated with Xray (${authDuration}ms)`, + ); + return cleanToken; } catch (error) { console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); throw error; @@ -100,12 +77,13 @@ class XrayJsonReporter { } /** - * Maps Playwright test status to Xray status + * Maps Playwright test status to Xray Cloud status + * Note: Xray Cloud uses PASSED/FAILED, Xray Server uses PASS/FAIL */ - private getTestStatus(status: string): 'PASS' | 'FAIL' | 'PENDING' { - if (status === 'passed') return 'PASS'; - if (status === 'skipped') return 'PENDING'; - return 'FAIL'; + private getTestStatus(status: string): 'PASSED' | 'FAILED' | 'TODO' | 'EXECUTING' { + if (status === 'passed') return 'PASSED'; + if (status === 'skipped') return 'TODO'; + return 'FAILED'; } /** @@ -122,44 +100,175 @@ class XrayJsonReporter { } /** - * Extracts step information from test annotations + * Determines if an attachment should be included as evidence + * Videos are only included for failed tests; other files check size threshold */ - private async extractSteps(annotations: any[], attachments: any[]): Promise { - const steps: XrayTestStep[] = []; - const stepAnnotations = annotations.filter(ann => ann.type.startsWith('Step Duration:')); + private shouldIncludeEvidence(attachment: any, testStatus: string, contentType: string): boolean { + const filePath = attachment.path; + if (!filePath || !fs.existsSync(filePath)) { + return false; + } - for (const stepAnn of stepAnnotations) { - const stepName = stepAnn.type.replace('Step Duration: ', ''); - const duration = stepAnn.description; + // Videos: Only for failed tests + if (contentType.includes('video')) { + return testStatus !== 'passed'; + } - // Find associated step attachments + return true; + } + + private isGivenStep(stepName: string): boolean { + return stepName.toLowerCase().startsWith('given '); + } + + private isWhenStep(stepName: string): boolean { + return stepName.toLowerCase().startsWith('when '); + } + + private isThenStep(stepName: string): boolean { + const lower = stepName.toLowerCase(); + return lower.startsWith('then ') || lower.startsWith('and '); + } + + private parseDuration(duration: string): number { + const match = duration.match(/(\d+)/); + return match ? parseInt(match[1], 10) : 0; + } + + /** + * Collects inline evidence for given step indices + */ + private async collectStepEvidence( + indices: number[], + attachments: any[], + testStatus: string, + ): Promise { + const evidence: XrayEvidence[] = []; + + for (const stepIndex of indices) { + const stepNumber = stepIndex + 1; + const stepPattern = `step-${stepNumber.toString().padStart(2, '0')}`; const stepAttachments = attachments.filter(att => - att.name.toLowerCase().includes(stepName.toLowerCase().substring(0, 20)), + att.name.toLowerCase().includes(stepPattern), ); - const step: XrayTestStep = { - action: stepName, - data: `Duration: ${duration}`, - result: stepName.includes('Then') ? stepName : undefined, - status: 'PASS', // Will be updated based on test result - evidences: [], - }; - - // Add evidence for this step for (const attachment of stepAttachments) { if (attachment.path && fs.existsSync(attachment.path)) { - step.evidences?.push({ - data: await this.fileToBase64(attachment.path), - filename: path.basename(attachment.path), - contentType: attachment.contentType || 'application/octet-stream', - }); + const contentType = attachment.contentType || 'application/octet-stream'; + + if (this.shouldIncludeEvidence(attachment, testStatus, contentType)) { + const base64Data = await this.fileToBase64(attachment.path); + if (base64Data) { + evidence.push({ + data: base64Data, + filename: path.basename(attachment.path), + contentType, + }); + } + } } } + } + + return evidence; + } - steps.push(step); + /** + * Extracts step information from test annotations with Given/When/Then logic: + * - Given: standalone step (action only) + * - When: step with action, result = all consecutive Then steps that follow + * - Then/And: combined as result of the preceding When step + * + * Returns both step definitions (for testInfo.steps) and step results (for test.steps) + */ + private async extractSteps( + annotations: any[], + attachments: any[], + testStatus: string, + ): Promise<{ + stepDefinitions: XrayTestStepDefinition[]; + stepResults: XrayTestStepResult[]; + }> { + const stepDefinitions: XrayTestStepDefinition[] = []; + const stepResults: XrayTestStepResult[] = []; + const stepAnnotations = annotations.filter(ann => ann.type.startsWith('Step Duration:')); + + if (stepAnnotations.length === 0) { + return { stepDefinitions, stepResults }; } - return steps; + let pendingWhen: { name: string; duration: number; index: number } | null = null; + let pendingThens: { name: string; duration: number; index: number }[] = []; + + const flushPendingWhen = async () => { + if (!pendingWhen) return; + + const stepDef: XrayTestStepDefinition = { + action: pendingWhen.name, + data: `Duration: ${pendingWhen.duration + pendingThens.reduce((sum, t) => sum + t.duration, 0)}ms`, + }; + + if (pendingThens.length > 0) { + stepDef.result = pendingThens.map(t => t.name).join('\n'); + } + + stepDefinitions.push(stepDef); + + const stepResult: XrayTestStepResult = { + status: 'PASSED', + }; + + const allIndices = [pendingWhen.index, ...pendingThens.map(t => t.index)]; + const evidence = await this.collectStepEvidence(allIndices, attachments, testStatus); + if (evidence.length > 0) { + stepResult.evidence = evidence; + } + + stepResults.push(stepResult); + pendingWhen = null; + pendingThens = []; + }; + + const addStandaloneStep = async (stepName: string, duration: number, index: number) => { + stepDefinitions.push({ + action: stepName, + data: `Duration: ${duration}ms`, + }); + + const stepResult: XrayTestStepResult = { + status: 'PASSED', + }; + + const evidence = await this.collectStepEvidence([index], attachments, testStatus); + if (evidence.length > 0) { + stepResult.evidence = evidence; + } + + stepResults.push(stepResult); + }; + + for (let i = 0; i < stepAnnotations.length; i += 1) { + const stepAnn = stepAnnotations[i]; + const stepName = stepAnn.type.replace('Step Duration: ', ''); + const duration = this.parseDuration(stepAnn.description); + + if (this.isGivenStep(stepName)) { + await flushPendingWhen(); + await addStandaloneStep(stepName, duration, i); + } else if (this.isWhenStep(stepName)) { + await flushPendingWhen(); + pendingWhen = { name: stepName, duration, index: i }; + } else if (this.isThenStep(stepName)) { + pendingThens.push({ name: stepName, duration, index: i }); + } else { + await flushPendingWhen(); + await addStandaloneStep(stepName, duration, i); + } + } + + await flushPendingWhen(); + + return { stepDefinitions, stepResults }; } /** @@ -169,48 +278,58 @@ class XrayJsonReporter { testCase: TestCase, testResult: TestResult, ): Promise { - const tags = (testCase as any).tags || []; const annotations = testResult.annotations || []; const attachments = testResult.attachments || []; + const testStatus = testResult.status; - // Extract steps from annotations - const steps = await this.extractSteps(annotations, attachments); + const { stepDefinitions, stepResults } = await this.extractSteps( + annotations, + attachments, + testStatus, + ); - // Mark failed steps if test failed - if (testResult.status !== 'passed' && steps.length > 0) { - steps[steps.length - 1].status = 'FAIL'; - steps[steps.length - 1].actualResult = testResult.error?.message || 'Test failed'; + // Mark last step as failed if test failed + if (testStatus !== 'passed' && stepResults.length > 0) { + stepResults[stepResults.length - 1].status = 'FAILED'; + stepResults[stepResults.length - 1].actualResult = testResult.error?.message || 'Test failed'; } - // Collect test-level evidence (screenshots, videos) - const testEvidences: { data: string; filename: string; contentType: string }[] = []; + // Collect test-level evidence (not step-level) + const testEvidence: XrayEvidence[] = []; + for (const attachment of attachments) { - if (attachment.path && fs.existsSync(attachment.path)) { - // Add main test evidence (final screenshots, videos, etc.) - if (attachment.name.includes('screenshot') || attachment.name.includes('video')) { - testEvidences.push({ - data: await this.fileToBase64(attachment.path), - filename: attachment.name, - contentType: attachment.contentType || 'application/octet-stream', - }); + if ( + attachment.path && + fs.existsSync(attachment.path) && + !attachment.name.toLowerCase().includes('step-') + ) { + const contentType = attachment.contentType || 'application/octet-stream'; + + if (this.shouldIncludeEvidence(attachment, testStatus, contentType)) { + const base64Data = await this.fileToBase64(attachment.path); + if (base64Data) { + testEvidence.push({ + data: base64Data, + filename: attachment.name, + contentType, + }); + } } } } - const xrayTest: XrayTest = { + return { testInfo: { summary: testCase.title, - type: 'Generic', - projectKey: 'XT', // Could be made configurable - labels: tags, + type: 'Manual', + projectKey: env.XRAY_PROJECT_KEY || 'SAND', + steps: stepDefinitions.length > 0 ? stepDefinitions : undefined, }, - status: this.getTestStatus(testResult.status), + status: this.getTestStatus(testStatus), comment: testResult.error?.message, - evidences: testEvidences, - steps: steps.length > 0 ? steps : undefined, + evidence: testEvidence.length > 0 ? testEvidence : undefined, + steps: stepResults.length > 0 ? stepResults : undefined, }; - - return xrayTest; } /** @@ -222,7 +341,6 @@ class XrayJsonReporter { const tests: XrayTest[] = []; - // Process all test suites for (const suite of playwrightResult.suites || []) { await this.processSuite(suite, tests); } @@ -230,30 +348,31 @@ class XrayJsonReporter { const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; const targetEnv = process.env.TARGET_ENV || 'qa1'; - const xrayResult: XrayExecutionResult = { + const passedCount = tests.filter(t => t.status === 'PASSED').length; + const failedCount = tests.filter(t => t.status === 'FAILED').length; + const todoCount = tests.filter(t => t.status === 'TODO').length; + + const hasExistingExecution = testExecKey && testExecKey !== 'none' && testExecKey.trim() !== ''; + + return { + testExecutionKey: hasExistingExecution ? testExecKey : undefined, info: { summary: `Playwright Test Execution - ${new Date().toISOString()}`, - description: `Automated test execution for ${targetEnv} environment`, - version: '1.0', - testExecutionKey: testExecKey !== 'none' ? testExecKey : undefined, + description: `Automated test execution for ${targetEnv} environment\n\nResults: ${passedCount} passed, ${failedCount} failed, ${todoCount} skipped`, startDate: playwrightResult.stats?.startTime || new Date().toISOString(), finishDate: new Date( new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + (playwrightResult.stats?.duration || 0), ).toISOString(), - testEnvironments: [targetEnv], }, tests, }; - - return xrayResult; } /** * Recursively processes test suites */ private async processSuite(suite: any, tests: XrayTest[]): Promise { - // Process specs in this suite for (const spec of suite.specs || []) { for (const test of spec.tests || []) { for (const result of test.results || []) { @@ -263,18 +382,23 @@ class XrayJsonReporter { } } - // Process nested suites for (const nestedSuite of suite.suites || []) { await this.processSuite(nestedSuite, tests); } } /** - * Uploads Xray execution result to Xray + * Uploads Xray execution result to Xray Cloud */ - async uploadToXray(xrayResult: XrayExecutionResult): Promise { + async uploadToXray(xrayResult: XrayExecutionResult): Promise { try { + const uploadStart = Date.now(); + const payloadSizeKB = (JSON.stringify(xrayResult).length / 1024).toFixed(1); + console.log(`${this.styles.info} Uploading test execution to Xray...`); + console.log( + `${this.styles.info} Payload: ${xrayResult.tests.length} tests, ${payloadSizeKB} KB`, + ); const token = await this.authenticateWithXray(); @@ -289,13 +413,18 @@ class XrayJsonReporter { if (!response.ok) { const errorText = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); + throw new Error(`Upload failed (HTTP ${response.status}): ${errorText}`); } - const result = await response.json(); + const result: XrayImportResponse = await response.json(); + const uploadDuration = Date.now() - uploadStart; + + console.log(`${this.styles.success} Successfully uploaded to Xray (${uploadDuration}ms)`); console.log( - `${this.styles.success} Successfully uploaded to Xray. Execution Key: ${result.key}`, + `${this.styles.success} Test Execution Key: ${result.testExecIssue?.key || 'N/A'}`, ); + + return result; } catch (error) { console.error(`${this.styles.error} Failed to upload to Xray:`, error); throw error; @@ -312,14 +441,35 @@ class XrayJsonReporter { } try { - console.log(`${this.styles.info} Processing Playwright results...`); + const processStart = Date.now(); + console.log(`\n${this.styles.separator}`); + console.log(`${this.styles.info} Processing Playwright results for Xray...`); + console.log(`${this.styles.info} Project Key: ${env.XRAY_PROJECT_KEY || 'SAND'}`); + console.log(`${this.styles.info} Environment: ${process.env.TARGET_ENV || 'qa1'}`); + + const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; + if (testExecKey && testExecKey !== 'none' && testExecKey.trim() !== '') { + console.log(`${this.styles.info} Linking to Test Execution: ${testExecKey}`); + } else { + console.log(`${this.styles.info} Creating new Test Execution`); + } + const xrayResult = await this.convertPlaywrightJsonToXray(playwrightJsonPath); // Save converted result for debugging fs.writeFileSync('test-results/xray-execution.json', JSON.stringify(xrayResult, null, 2)); + console.log(`${this.styles.info} Saved Xray JSON to: test-results/xray-execution.json`); + + if (xrayResult.tests.length === 0) { + console.log(`${this.styles.warning} No tests to upload, skipping Xray upload`); + return; + } await this.uploadToXray(xrayResult); - console.log(`${this.styles.upload} Xray upload completed successfully`); + + const totalDuration = Date.now() - processStart; + console.log(`${this.styles.upload} Xray upload completed successfully (${totalDuration}ms)`); + console.log(`${this.styles.separator}\n`); } catch (error) { console.error(`${this.styles.error} Failed to process and upload:`, error); throw error; @@ -327,10 +477,9 @@ class XrayJsonReporter { } /** - * Reporter lifecycle methods for direct Playwright integration + * Reporter lifecycle methods for Playwright integration */ onBegin(_config: FullConfig, suite: Suite): void { - this.startTime = new Date().toISOString(); console.log(`\n${this.styles.separator}`); console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); console.log(`${this.styles.separator}\n`); @@ -346,7 +495,6 @@ class XrayJsonReporter { } async onEnd(result: FullResult): Promise { - this.endTime = new Date().toISOString(); console.log(`\n${this.styles.separator}`); console.log(`${this.styles.info} Test Run Summary:`); console.log( @@ -355,10 +503,12 @@ class XrayJsonReporter { console.log(`Duration: ${result.duration}ms`); console.log(`${this.styles.separator}\n`); - // Auto-upload if JSON results are available - const jsonPath = 'test-results/last-run.json'; - if (fs.existsSync(jsonPath)) { - await this.processAndUpload(jsonPath); + const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; + if (env.XRAY_CLIENT_ID && env.XRAY_CLIENT_SECRET && testExecKey && testExecKey !== 'none') { + const jsonPath = 'test-results/last-run.json'; + if (fs.existsSync(jsonPath)) { + await this.processAndUpload(jsonPath); + } } } } diff --git a/utilities/xray-json-schema.json b/utilities/xray-json-schema.json new file mode 100644 index 0000000..70d770c --- /dev/null +++ b/utilities/xray-json-schema.json @@ -0,0 +1,392 @@ +{ + "$id": "XraySchema", + "type": "object", + "properties": { + "testExecutionKey": { + "type": "string" + }, + "info": { + "type": "object", + "properties": { + "project": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "version": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "user": { + "type": "string" + }, + "startDate": { + "type": "string", + "format": "date-time" + }, + "finishDate": { + "type": "string", + "format": "date-time" + }, + "testPlanKey": { + "type": "string" + }, + "testEnvironments": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "tests": { + "type": "array", + "items": { + "$ref": "#/definitions/Test" + }, + "minItems": 1 + } + }, + "additionalProperties": false, + + "definitions": { + + "Test": { + "type": "object", + "properties": { + "testKey": { + "type": "string" + }, + "testInfo": { + "$ref": "#/definitions/TestInfo" + }, + "start": { + "type": "string", + "format": "date-time" + }, + "finish": { + "type": "string", + "format": "date-time" + }, + "comment": { + "type": "string" + }, + "executedBy": { + "type": "string" + }, + "assignee": { + "type": "string" + }, + "status": { + "type": "string" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/ManualTestStepResult" + } + }, + "examples": { + "type": "array", + "items": { + "type": "string", + "enum": ["TODO", "FAILED", "PASSED", "EXECUTING"] + } + }, + "iterations": { + "type": "array", + "items": { + "$ref": "#/definitions/IterationResult" + } + }, + "defects": { + "type": "array", + "items": { + "type": "string" + } + }, + "evidence": { + "type": "array", + "items": { + "$ref": "#/definitions/EvidenceItem" + } + }, + "customFields": { + "$ref": "#/definitions/CustomField" + } + }, + "required": ["status"], + "dependencies": { + "evidence": { + "not": { "required": ["evidences"] } + }, + "evidences": { + "not": { "required": ["evidence"] } + }, + "steps": { + "allOf": [ + { + "not": { "required": ["examples"] } + }, + { + "not": { "required": ["results"] } + }, + { + "not": { "required": ["iterations"] } + } + ] + }, + "examples": { + "allOf": [ + { + "not": { "required": ["steps"] } + }, + { + "not": { "required": ["results"] } + }, + { + "not": { "required": ["iterations"] } + } + ] + }, + "results": { + "allOf": [ + { + "not": { "required": ["steps"] } + }, + { + "not": { "required": ["examples"] } + }, + { + "not": { "required": ["iterations"] } + } + ] + }, + "iterations": { + "allOf": [ + { + "not": { "required": ["steps"] } + }, + { + "not": { "required": ["examples"] } + }, + { + "not": { "required": ["results"] } + } + ] + } + }, + "additionalProperties": false + }, + + "IterationResult": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "parameters": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "log": { + "type": "string" + }, + "duration": { + "type": "string" + }, + "status": { + "type": "string" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/definitions/ManualTestStepResult" + } + } + }, + "required": ["status"], + "additionalProperties": false + }, + + "ManualTestStepResult": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "comment": { + "type": "string" + }, + "evidence": { + "type": "array", + "items": { + "$ref": "#/definitions/EvidenceItem" + } + }, + "defects": { + "type": "array", + "items": { + "type": "string" + } + }, + "actualResult": { + "type": "string" + } + }, + "required": ["status"], + "additionalProperties": false + }, + + "TestInfo": { + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "projectKey": { + "type": "string" + }, + "requirementKeys": { + "type": "array", + "items": { + "type": "string" + } + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string" + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "data": { + "type": "string" + }, + "result": { + "type": "string" + } + }, + "customFields": { + ".+": {} + }, + "required": ["action"], + "additionalProperties": false + } + }, + "scenario": { + "type": "string" + }, + "definition": { + "type": "string" + } + }, + "dependencies": { + "steps": { + "allOf": [ + { + "not": { "required": ["scenario"] } + }, + { + "not": { "required": ["definition"] } + } + ] + }, + "scenario": { + "allOf": [ + { + "not": { "required": ["steps"] } + }, + { + "not": { "required": ["definition"] } + } + ] + }, + "definition": { + "allOf": [ + { + "not": { "required": ["steps"] } + }, + { + "not": { "required": ["scenario"] } + } + ] + } + }, + "required": ["summary", "projectKey", "type"], + "additionalProperties": false + }, + + "EvidenceItem": { + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "contentType": { + "type": "string" + } + }, + "required": ["data", "filename"], + "additionalProperties": false + }, + + "CustomField": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": {} + }, + "anyOf": [ + { + "required": ["id", "value"] + }, + { + "required": ["name", "value"] + } + ], + "additionalProperties": false + } + } + } + +} \ No newline at end of file diff --git a/utilities/xray-reporter.ts b/utilities/xray-reporter.ts deleted file mode 100644 index 5038434..0000000 --- a/utilities/xray-reporter.ts +++ /dev/null @@ -1,161 +0,0 @@ -import fs from 'node:fs'; -import { FullConfig, FullResult, Suite, TestCase, TestResult } from '@playwright/test/reporter'; -import env from './env'; - -interface Styles { - success: string; - error: string; - info: string; - warning: string; - upload: string; - test: string; - separator: string; -} - -/** - * Reporter class for uploading test results to Xray - */ -class XRayReporter { - private styles: Styles; - - constructor() { - this.styles = { - success: 'āœ…', - error: 'āŒ', - info: 'ā„¹ļø', - warning: 'ā›”ļø', - upload: 'šŸš€', - test: '🧪', - separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - }; - } - - /** - * Authenticates with Xray API using client credentials - * @returns {Promise} The authentication token - * @throws {Error} If authentication fails - */ - async authenticateWithXray(): Promise { - try { - console.log(`${this.styles.info} Authenticating with Xray...`); - const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - client_id: env.XRAY_CLIENT_ID, - client_secret: env.XRAY_CLIENT_SECRET, - }), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}, ${response.body}`); - } - - const data = await response.json(); - console.log(`${this.styles.success} Successfully authenticated with Xray`); - return data.token; - } catch (error) { - console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); - throw error; - } - } - - /** - * Uploads test results to Xray - * @param {string} token - The authentication token - * @param {string} xmlContent - The JUnit XML content to upload - * @returns {Promise} - * @throws {Error} If upload fails - */ - async uploadTestResults(token: string, xmlContent: string): Promise { - try { - console.log(`${this.styles.info} Uploading test results to Xray...`); - const response = await fetch( - 'https://xray.cloud.getxray.app/api/v2/import/execution/junit?projectKey=XT&testPlanKey=XT-380', - { - method: 'POST', - headers: { - 'Content-Type': 'text/xml', - Authorization: `Bearer ${token}`, - }, - body: xmlContent, - }, - ); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); - } - - console.log(`${this.styles.success} Successfully uploaded test results to Xray`); - } catch (error) { - console.error(`${this.styles.error} Failed to upload test results to Xray:`, error); - throw error; - } - } - - /** - * Called when test run begins - * @param suite - Test suite object containing all tests - */ - onBegin(_config: FullConfig, suite: Suite): void { - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); - console.log(`${this.styles.separator}\n`); - } - - /** - * Called when a test begins - * @param test - Test case object - */ - onTestBegin(test: TestCase, _result: TestResult): void { - console.log(`${this.styles.test} Starting: ${test.title}`); - } - - /** - * Called when a test ends - * @param {Object} test - Test case object - * @param {Object} result - Test result object containing status and other details - */ - onTestEnd(test: TestCase, result: TestResult): void { - const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; - console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); - } - - /** - * Called when all tests have finished - * @param result - Full test run result object containing status and duration - */ - async onEnd(result: FullResult): Promise { - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.info} Test Run Summary:`); - console.log( - `Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`, - ); - console.log(`Duration: ${result.duration}ms`); - console.log(`${this.styles.separator}\n`); - - if (!(env.XRAY_CLIENT_ID || env.XRAY_CLIENT_SECRET)) { - console.log( - `${this.styles.warning} No Xray client ID or secret found, skipping upload to JIRA Xray`, - ); - return; - } - - try { - console.log(`${this.styles.info} Reading test results file...`); - const testResults = fs.readFileSync('./test-results/test-results.xml', 'utf8'); - - const token = await this.authenticateWithXray(); - await this.uploadTestResults(token, testResults); - console.log(`${this.styles.upload} Successfully uploaded test results to Xray`); - } catch (error) { - console.error(`${this.styles.error} Failed to process test results:`, error); - } - console.log(`${this.styles.separator}\n`); - } -} - -export default XRayReporter; diff --git a/utilities/xray-types.ts b/utilities/xray-types.ts new file mode 100644 index 0000000..cf0aea9 --- /dev/null +++ b/utilities/xray-types.ts @@ -0,0 +1,100 @@ +/** + * TypeScript type definitions for Xray Cloud API integration + */ + +/** + * Xray Evidence format (base64 encoded) + */ +export interface XrayEvidence { + data: string; // base64 encoded content + filename: string; + contentType?: string; +} + +/** + * Test step definition (used in testInfo.steps to define the test) + */ +export interface XrayTestStepDefinition { + action: string; + data?: string; + result?: string; +} + +/** + * Test step execution result (used in test.steps to record execution results) + */ +export interface XrayTestStepResult { + status: 'PASSED' | 'FAILED' | 'TODO' | 'EXECUTING'; + comment?: string; + actualResult?: string; + evidence?: XrayEvidence[]; + defects?: string[]; +} + +/** + * Xray Test Information (test definition/specification) + */ +export interface XrayTestInfo { + summary: string; + type: 'Manual' | 'Cucumber' | 'Generic'; + projectKey: string; + requirementKeys?: string[]; + labels?: string[]; + steps?: XrayTestStepDefinition[]; +} + +/** + * Xray Test format (test execution record) + */ +export interface XrayTest { + testKey?: string; + testInfo?: XrayTestInfo; + start?: string; + finish?: string; + status: 'PASSED' | 'FAILED' | 'TODO' | 'EXECUTING'; + comment?: string; + evidence?: XrayEvidence[]; + steps?: XrayTestStepResult[]; + defects?: string[]; +} + +/** + * Xray Test Execution Information (goes in "info" object) + */ +export interface XrayExecutionInfo { + project?: string; + summary: string; + description?: string; + version?: string; + revision?: string; + user?: string; + startDate?: string; + finishDate?: string; + testPlanKey?: string; + testEnvironments?: string[]; +} + +/** + * Xray Execution Result format (for JSON import) + */ +export interface XrayExecutionResult { + testExecutionKey?: string; + info?: XrayExecutionInfo; + tests: XrayTest[]; +} + +/** + * Xray API Import Response + */ +export interface XrayImportResponse { + testExecIssue: { + id: string; + key: string; + self: string; + }; + testIssues?: { + id: string; + key: string; + self: string; + }[]; +} From b6bd30bdd526e6c881551ec6f5e34df347b0ae25 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Tue, 10 Feb 2026 20:34:45 -0500 Subject: [PATCH 23/60] CircleCI: set env vars conditionally; change default env Change default testEnvironment from 'qa1' to 'qa2'. Remove job-level static environment mappings and add a run step that appends exports to $BASH_ENV so project environment variables are preserved unless pipeline parameters are explicitly provided. Applied the update to both Playwright test jobs to ensure pipeline parameters override project env vars only when set. --- .circleci/config.yml | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 6784f5f..97101e1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,7 +6,7 @@ orbs: parameters: testEnvironment: type: string - default: 'qa1' + default: 'qa2' testExecKey: type: string default: 'none' @@ -41,15 +41,18 @@ jobs: docker: - image: mcr.microsoft.com/playwright:v1.54.1-noble parallelism: 4 - environment: - TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> - TARGET_ENV: << pipeline.parameters.testEnvironment >> - TEST_TAGS: << pipeline.parameters.testTags >> - XRAY_PROJECT_KEY: << pipeline.parameters.xrayProjectKey >> steps: - checkout - node/install - run: node --version + # Pipeline parameters override project env vars only when explicitly provided + - run: + name: Set environment variables + command: | + echo "export TARGET_ENV=\"${TARGET_ENV:-<< pipeline.parameters.testEnvironment >>}\"" >> $BASH_ENV + echo "export TEST_EXECUTION_KEY=\"${TEST_EXECUTION_KEY:-<< pipeline.parameters.testExecKey >>}\"" >> $BASH_ENV + echo "export TEST_TAGS=\"${TEST_TAGS:-<< pipeline.parameters.testTags >>}\"" >> $BASH_ENV + echo "export XRAY_PROJECT_KEY=\"${XRAY_PROJECT_KEY:-<< pipeline.parameters.xrayProjectKey >>}\"" >> $BASH_ENV - restore_cache: keys: - dependency-cache-{{ checksum "package.json" }} @@ -66,7 +69,6 @@ jobs: command: npx playwright install --with-deps # Run tests with parallel execution - # TARGET_ENV and TEST_TAGS are already set as environment variables above - run: name: Run Playwright Tests command: npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL @@ -150,15 +152,18 @@ jobs: docker: - image: mcr.microsoft.com/playwright:v1.54.1-noble parallelism: 4 - environment: - TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> - TARGET_ENV: << pipeline.parameters.testEnvironment >> - TEST_TAGS: << pipeline.parameters.testTags >> - XRAY_PROJECT_KEY: << pipeline.parameters.xrayProjectKey >> steps: - checkout - node/install - run: node --version + # Pipeline parameters override project env vars only when explicitly provided + - run: + name: Set environment variables + command: | + echo "export TARGET_ENV=\"${TARGET_ENV:-<< pipeline.parameters.testEnvironment >>}\"" >> $BASH_ENV + echo "export TEST_EXECUTION_KEY=\"${TEST_EXECUTION_KEY:-<< pipeline.parameters.testExecKey >>}\"" >> $BASH_ENV + echo "export TEST_TAGS=\"${TEST_TAGS:-<< pipeline.parameters.testTags >>}\"" >> $BASH_ENV + echo "export XRAY_PROJECT_KEY=\"${XRAY_PROJECT_KEY:-<< pipeline.parameters.xrayProjectKey >>}\"" >> $BASH_ENV - restore_cache: keys: - dependency-cache-{{ checksum "package.json" }} @@ -175,7 +180,6 @@ jobs: command: npx playwright install --with-deps # Run tests with parallel execution - # TARGET_ENV and TEST_TAGS are already set as environment variables above - run: name: Run Playwright Tests command: npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL From 15ee97a8e39673200e27081e8870e42d0670e545 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Thu, 12 Feb 2026 14:48:59 -0500 Subject: [PATCH 24/60] Remove generated build and dist artifacts Delete the committed build/ and dist/ directories (compiled page-objects, endpoint schemas, tests, and utilities). These are generated artifacts that should be produced by the project's build step rather than stored in the repository to reduce repo size and merge noise; regenerate using the normal build command (e.g., `npm run build`). --- build/endpoint-schema/auth-endpoints.js | 53 -- build/endpoint-schema/endpoint-registry.js | 52 -- .../endpoint-schema/patient-data-endpoints.js | 56 -- build/endpoint-schema/profile-endpoints.js | 107 ---- build/page-objects/LoginPage.js | 44 -- .../page-objects/account/AccountNavigation.js | 62 --- .../account/AccountSettingsPage.js | 13 - .../clinician/ClinicCreationPage.js | 84 --- .../clinician/ClinicianDashboardPage.js | 79 --- .../clinician/ClinicianNavigation.js | 119 ----- .../clinician/WorkspaceSettingsPage.js | 29 -- .../page-objects/clinician/WorkspacesPage.js | 36 -- .../components/navigation-menu.section.js | 27 - .../components/navigation.section.js | 22 - build/page-objects/patient/BasicsPage.js | 143 ------ build/page-objects/patient/DailyPage.js | 17 - .../page-objects/patient/PatientNavigation.js | 100 ---- build/page-objects/patient/ProfilePage.js | 115 ----- .../patient/components/daily-chart.js | 14 - build/playwright.config.js | 106 ---- .../claimed-profile-edit-fullname.spec.js | 148 ------ .../comprehensive-profile-access-test.spec.js | 159 ------ .../API-User/claimed-email-edit.spec.js | 95 ---- .../edit-custodial-profile-API.spec.js | 91 ---- build/tests/clinician/add-patient.spec.js | 38 -- .../clinician/create-clinic-workspace.spec.js | 86 ---- .../clinician/edit-clinic-address.spec.js | 47 -- build/tests/clinician/filter-patient.spec.js | 70 --- build/tests/fixtures/account-helpers.js | 123 ----- build/tests/fixtures/base.js | 262 ---------- build/tests/fixtures/clinic-helpers.js | 280 ---------- build/tests/fixtures/network-helpers.js | 480 ----------------- build/tests/fixtures/patient-helpers.js | 484 ------------------ build/tests/fixtures/test-tags.js | 98 ---- build/tests/global-setup.js | 47 -- .../edit-personal-profile-API.spec.js | 75 --- .../personal/basic-functionality.spec.js | 240 --------- build/tests/personal/login.spec.js | 95 ---- build/utilities/annotations.js | 24 - build/utilities/env.js | 46 -- build/utilities/xray-json-reporter.js | 473 ----------------- build/utilities/xray-reporter.js | 134 ----- dist/endpoint-schema/auth-endpoints.d.ts | 13 - dist/endpoint-schema/auth-endpoints.js | 50 -- dist/endpoint-schema/endpoint-registry.d.ts | 34 -- dist/endpoint-schema/endpoint-registry.js | 48 -- .../patient-data-endpoints.d.ts | 13 - .../endpoint-schema/patient-data-endpoints.js | 53 -- dist/endpoint-schema/profile-endpoints.d.ts | 32 -- dist/endpoint-schema/profile-endpoints.js | 104 ---- dist/page-objects/LoginPage.d.ts | 32 -- dist/page-objects/LoginPage.js | 41 -- .../account/AccountNavigation.d.ts | 18 - .../page-objects/account/AccountNavigation.js | 59 --- .../account/AccountSettingsPage.d.ts | 9 - .../account/AccountSettingsPage.js | 9 - .../clinician/ClinicCreationPage.d.ts | 55 -- .../clinician/ClinicCreationPage.js | 81 --- .../clinician/ClinicianDashboardPage.d.ts | 46 -- .../clinician/ClinicianDashboardPage.js | 77 --- .../clinician/ClinicianNavigation.d.ts | 20 - .../clinician/ClinicianNavigation.js | 116 ----- .../clinician/WorkspaceSettingsPage.d.ts | 18 - .../clinician/WorkspaceSettingsPage.js | 26 - .../clinician/WorkspacesPage.d.ts | 16 - dist/page-objects/clinician/WorkspacesPage.js | 30 -- .../components/navigation-menu.section.d.ts | 16 - .../components/navigation-menu.section.js | 24 - .../components/navigation.section.d.ts | 14 - .../components/navigation.section.js | 16 - dist/page-objects/patient/BasicsPage.d.ts | 58 --- dist/page-objects/patient/BasicsPage.js | 138 ----- dist/page-objects/patient/DailyPage.d.ts | 11 - dist/page-objects/patient/DailyPage.js | 11 - .../patient/PatientNavigation.d.ts | 13 - .../page-objects/patient/PatientNavigation.js | 97 ---- dist/page-objects/patient/ProfilePage.d.ts | 22 - dist/page-objects/patient/ProfilePage.js | 111 ---- .../patient/components/daily-chart.d.ts | 11 - .../patient/components/daily-chart.js | 11 - dist/playwright.config.d.ts | 2 - dist/playwright.config.js | 108 ---- .../claimed-profile-edit-fullname.spec.d.ts | 1 - .../claimed-profile-edit-fullname.spec.js | 146 ------ ...omprehensive-profile-access-test.spec.d.ts | 1 - .../comprehensive-profile-access-test.spec.js | 124 ----- .../API-User/claimed-email-edit.spec.d.ts | 1 - .../API-User/claimed-email-edit.spec.js | 93 ---- .../edit-custodial-profile-API.spec.d.ts | 1 - .../edit-custodial-profile-API.spec.js | 89 ---- dist/tests/clinician/add-patient.spec.d.ts | 1 - dist/tests/clinician/add-patient.spec.js | 33 -- .../create-clinic-workspace.spec.d.ts | 1 - .../clinician/create-clinic-workspace.spec.js | 81 --- .../clinician/edit-clinic-address.spec.d.ts | 1 - .../clinician/edit-clinic-address.spec.js | 42 -- dist/tests/clinician/filter-patient.spec.d.ts | 1 - dist/tests/clinician/filter-patient.spec.js | 65 --- dist/tests/fixtures/account-helpers.d.ts | 20 - dist/tests/fixtures/account-helpers.js | 84 --- dist/tests/fixtures/base.d.ts | 23 - dist/tests/fixtures/base.js | 219 -------- dist/tests/fixtures/clinic-helpers.d.ts | 61 --- dist/tests/fixtures/clinic-helpers.js | 274 ---------- dist/tests/fixtures/network-helpers.d.ts | 112 ---- dist/tests/fixtures/network-helpers.js | 442 ---------------- dist/tests/fixtures/patient-helpers.d.ts | 18 - dist/tests/fixtures/patient-helpers.js | 477 ----------------- dist/tests/fixtures/test-tags.d.ts | 60 --- dist/tests/fixtures/test-tags.js | 93 ---- dist/tests/global-setup.d.ts | 2 - dist/tests/global-setup.js | 41 -- .../edit-personal-profile-API.spec.d.ts | 1 - .../edit-personal-profile-API.spec.js | 73 --- .../personal/basic-functionality.spec.d.ts | 1 - .../personal/basic-functionality.spec.js | 235 --------- dist/tests/personal/login.spec.d.ts | 1 - dist/tests/personal/login.spec.js | 61 --- dist/utilities/annotations.d.ts | 15 - dist/utilities/annotations.js | 21 - dist/utilities/env.d.ts | 17 - dist/utilities/env.js | 37 -- dist/utilities/xray-json-reporter.d.ts | 93 ---- dist/utilities/xray-json-reporter.js | 263 ---------- dist/utilities/xray-reporter.d.ts | 44 -- dist/utilities/xray-reporter.js | 129 ----- 126 files changed, 10134 deletions(-) delete mode 100644 build/endpoint-schema/auth-endpoints.js delete mode 100644 build/endpoint-schema/endpoint-registry.js delete mode 100644 build/endpoint-schema/patient-data-endpoints.js delete mode 100644 build/endpoint-schema/profile-endpoints.js delete mode 100644 build/page-objects/LoginPage.js delete mode 100644 build/page-objects/account/AccountNavigation.js delete mode 100644 build/page-objects/account/AccountSettingsPage.js delete mode 100644 build/page-objects/clinician/ClinicCreationPage.js delete mode 100644 build/page-objects/clinician/ClinicianDashboardPage.js delete mode 100644 build/page-objects/clinician/ClinicianNavigation.js delete mode 100644 build/page-objects/clinician/WorkspaceSettingsPage.js delete mode 100644 build/page-objects/clinician/WorkspacesPage.js delete mode 100644 build/page-objects/clinician/components/navigation-menu.section.js delete mode 100644 build/page-objects/clinician/components/navigation.section.js delete mode 100644 build/page-objects/patient/BasicsPage.js delete mode 100644 build/page-objects/patient/DailyPage.js delete mode 100644 build/page-objects/patient/PatientNavigation.js delete mode 100644 build/page-objects/patient/ProfilePage.js delete mode 100644 build/page-objects/patient/components/daily-chart.js delete mode 100644 build/playwright.config.js delete mode 100644 build/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js delete mode 100644 build/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js delete mode 100644 build/tests/claimed/API-User/claimed-email-edit.spec.js delete mode 100644 build/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js delete mode 100644 build/tests/clinician/add-patient.spec.js delete mode 100644 build/tests/clinician/create-clinic-workspace.spec.js delete mode 100644 build/tests/clinician/edit-clinic-address.spec.js delete mode 100644 build/tests/clinician/filter-patient.spec.js delete mode 100644 build/tests/fixtures/account-helpers.js delete mode 100644 build/tests/fixtures/base.js delete mode 100644 build/tests/fixtures/clinic-helpers.js delete mode 100644 build/tests/fixtures/network-helpers.js delete mode 100644 build/tests/fixtures/patient-helpers.js delete mode 100644 build/tests/fixtures/test-tags.js delete mode 100644 build/tests/global-setup.js delete mode 100644 build/tests/personal/AP-Profile/edit-personal-profile-API.spec.js delete mode 100644 build/tests/personal/basic-functionality.spec.js delete mode 100644 build/tests/personal/login.spec.js delete mode 100644 build/utilities/annotations.js delete mode 100644 build/utilities/env.js delete mode 100644 build/utilities/xray-json-reporter.js delete mode 100644 build/utilities/xray-reporter.js delete mode 100644 dist/endpoint-schema/auth-endpoints.d.ts delete mode 100644 dist/endpoint-schema/auth-endpoints.js delete mode 100644 dist/endpoint-schema/endpoint-registry.d.ts delete mode 100644 dist/endpoint-schema/endpoint-registry.js delete mode 100644 dist/endpoint-schema/patient-data-endpoints.d.ts delete mode 100644 dist/endpoint-schema/patient-data-endpoints.js delete mode 100644 dist/endpoint-schema/profile-endpoints.d.ts delete mode 100644 dist/endpoint-schema/profile-endpoints.js delete mode 100644 dist/page-objects/LoginPage.d.ts delete mode 100644 dist/page-objects/LoginPage.js delete mode 100644 dist/page-objects/account/AccountNavigation.d.ts delete mode 100644 dist/page-objects/account/AccountNavigation.js delete mode 100644 dist/page-objects/account/AccountSettingsPage.d.ts delete mode 100644 dist/page-objects/account/AccountSettingsPage.js delete mode 100644 dist/page-objects/clinician/ClinicCreationPage.d.ts delete mode 100644 dist/page-objects/clinician/ClinicCreationPage.js delete mode 100644 dist/page-objects/clinician/ClinicianDashboardPage.d.ts delete mode 100644 dist/page-objects/clinician/ClinicianDashboardPage.js delete mode 100644 dist/page-objects/clinician/ClinicianNavigation.d.ts delete mode 100644 dist/page-objects/clinician/ClinicianNavigation.js delete mode 100644 dist/page-objects/clinician/WorkspaceSettingsPage.d.ts delete mode 100644 dist/page-objects/clinician/WorkspaceSettingsPage.js delete mode 100644 dist/page-objects/clinician/WorkspacesPage.d.ts delete mode 100644 dist/page-objects/clinician/WorkspacesPage.js delete mode 100644 dist/page-objects/clinician/components/navigation-menu.section.d.ts delete mode 100644 dist/page-objects/clinician/components/navigation-menu.section.js delete mode 100644 dist/page-objects/clinician/components/navigation.section.d.ts delete mode 100644 dist/page-objects/clinician/components/navigation.section.js delete mode 100644 dist/page-objects/patient/BasicsPage.d.ts delete mode 100644 dist/page-objects/patient/BasicsPage.js delete mode 100644 dist/page-objects/patient/DailyPage.d.ts delete mode 100644 dist/page-objects/patient/DailyPage.js delete mode 100644 dist/page-objects/patient/PatientNavigation.d.ts delete mode 100644 dist/page-objects/patient/PatientNavigation.js delete mode 100644 dist/page-objects/patient/ProfilePage.d.ts delete mode 100644 dist/page-objects/patient/ProfilePage.js delete mode 100644 dist/page-objects/patient/components/daily-chart.d.ts delete mode 100644 dist/page-objects/patient/components/daily-chart.js delete mode 100644 dist/playwright.config.d.ts delete mode 100644 dist/playwright.config.js delete mode 100644 dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.d.ts delete mode 100644 dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js delete mode 100644 dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.d.ts delete mode 100644 dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js delete mode 100644 dist/tests/claimed/API-User/claimed-email-edit.spec.d.ts delete mode 100644 dist/tests/claimed/API-User/claimed-email-edit.spec.js delete mode 100644 dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.d.ts delete mode 100644 dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js delete mode 100644 dist/tests/clinician/add-patient.spec.d.ts delete mode 100644 dist/tests/clinician/add-patient.spec.js delete mode 100644 dist/tests/clinician/create-clinic-workspace.spec.d.ts delete mode 100644 dist/tests/clinician/create-clinic-workspace.spec.js delete mode 100644 dist/tests/clinician/edit-clinic-address.spec.d.ts delete mode 100644 dist/tests/clinician/edit-clinic-address.spec.js delete mode 100644 dist/tests/clinician/filter-patient.spec.d.ts delete mode 100644 dist/tests/clinician/filter-patient.spec.js delete mode 100644 dist/tests/fixtures/account-helpers.d.ts delete mode 100644 dist/tests/fixtures/account-helpers.js delete mode 100644 dist/tests/fixtures/base.d.ts delete mode 100644 dist/tests/fixtures/base.js delete mode 100644 dist/tests/fixtures/clinic-helpers.d.ts delete mode 100644 dist/tests/fixtures/clinic-helpers.js delete mode 100644 dist/tests/fixtures/network-helpers.d.ts delete mode 100644 dist/tests/fixtures/network-helpers.js delete mode 100644 dist/tests/fixtures/patient-helpers.d.ts delete mode 100644 dist/tests/fixtures/patient-helpers.js delete mode 100644 dist/tests/fixtures/test-tags.d.ts delete mode 100644 dist/tests/fixtures/test-tags.js delete mode 100644 dist/tests/global-setup.d.ts delete mode 100644 dist/tests/global-setup.js delete mode 100644 dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.d.ts delete mode 100644 dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.js delete mode 100644 dist/tests/personal/basic-functionality.spec.d.ts delete mode 100644 dist/tests/personal/basic-functionality.spec.js delete mode 100644 dist/tests/personal/login.spec.d.ts delete mode 100644 dist/tests/personal/login.spec.js delete mode 100644 dist/utilities/annotations.d.ts delete mode 100644 dist/utilities/annotations.js delete mode 100644 dist/utilities/env.d.ts delete mode 100644 dist/utilities/env.js delete mode 100644 dist/utilities/xray-json-reporter.d.ts delete mode 100644 dist/utilities/xray-json-reporter.js delete mode 100644 dist/utilities/xray-reporter.d.ts delete mode 100644 dist/utilities/xray-reporter.js diff --git a/build/endpoint-schema/auth-endpoints.js b/build/endpoint-schema/auth-endpoints.js deleted file mode 100644 index aa3c6ec..0000000 --- a/build/endpoint-schema/auth-endpoints.js +++ /dev/null @@ -1,53 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.refreshTokenSchema = exports.logoutSchema = exports.loginSchema = void 0; -/** - * Schema for user authentication login - */ -exports.loginSchema = { - url: /\/auth\/login$/, - method: 'POST', - expectedStatus: 200, - requestSchema: { - username: 'string', - password: 'string', - }, - responseSchema: { - userid: 'string', - username: 'string', - emails: 'object', - roles: 'object', - }, - validationFields: ['userid', 'username', 'emails', 'roles'], - requiredFields: [ - 'userid', // Auth endpoints require userid instead of fullName - 'username', // Username is also critical for auth - ], -}; -/** - * Schema for user logout - */ -exports.logoutSchema = { - url: /\/auth\/logout$/, - method: 'POST', - expectedStatus: 200, - validationFields: [ - // Logout typically doesn't return data to validate - ], -}; -/** - * Schema for token refresh - */ -exports.refreshTokenSchema = { - url: /\/auth\/token$/, - method: 'POST', - expectedStatus: 200, - responseSchema: { - userid: 'string', - username: 'string', - }, - validationFields: ['userid', 'username'], - requiredFields: [ - 'userid', // Token refresh must return userid - ], -}; diff --git a/build/endpoint-schema/endpoint-registry.js b/build/endpoint-schema/endpoint-registry.js deleted file mode 100644 index d608347..0000000 --- a/build/endpoint-schema/endpoint-registry.js +++ /dev/null @@ -1,52 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.ENDPOINT_REGISTRY = void 0; -exports.getEndpointSchema = getEndpointSchema; -const profile_endpoints_1 = require("./profile-endpoints"); -const patient_data_endpoints_1 = require("./patient-data-endpoints"); -const auth_endpoints_1 = require("./auth-endpoints"); -// Import other endpoint schemas as they're created -/** - * Centralized endpoint registry for all API validation - * This allows network helpers to work with any endpoint by name - * - * ADDING NEW ENDPOINTS: - * 1. Define the endpoint schema in the appropriate *-endpoints.ts file - * 2. Include validationFields array for data consistency checking - * 3. Add the endpoint to this registry - * 4. The validationFields will automatically be used by NetworkHelper methods - * - * VALIDATION FIELDS: - * - Use dot notation for nested fields (e.g., 'patient.fullName') - * - Include all fields that should be validated for data consistency - * - Different endpoints can have different validation requirements - * - Fields are endpoint-specific and stored in the schema definition - */ -exports.ENDPOINT_REGISTRY = { - // Profile endpoints - 'profile-metadata-get': profile_endpoints_1.getProfileMetadataSchema, - 'profile-metadata-put': profile_endpoints_1.putProfileMetadataSchema, - 'profile-patient-data-get': profile_endpoints_1.getPatientDataSchema, - 'profile-metrics-get': profile_endpoints_1.getMetricsSchema, - 'profile-message-notes-get': profile_endpoints_1.getMessageNotesSchema, - // Patient data endpoints - 'patient-data-get': patient_data_endpoints_1.getPatientDataSchema, - 'patient-data-upload': patient_data_endpoints_1.uploadPatientDataSchema, - // Auth endpoints - 'auth-login': auth_endpoints_1.loginSchema, - 'auth-logout': auth_endpoints_1.logoutSchema, - 'auth-refresh-token': auth_endpoints_1.refreshTokenSchema, - // Add more endpoints as needed... - // 'clinic-get': clinicGetSchema, - // 'clinic-update': clinicUpdateSchema, -}; -/** - * Get endpoint schema by name - */ -function getEndpointSchema(endpointName) { - const schema = exports.ENDPOINT_REGISTRY[endpointName]; - if (!schema) { - throw new Error(`Endpoint schema not found: ${endpointName}`); - } - return schema; -} diff --git a/build/endpoint-schema/patient-data-endpoints.js b/build/endpoint-schema/patient-data-endpoints.js deleted file mode 100644 index 2443fb0..0000000 --- a/build/endpoint-schema/patient-data-endpoints.js +++ /dev/null @@ -1,56 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.getPatientSettingsSchema = exports.uploadPatientDataSchema = exports.getPatientDataSchema = void 0; -/** - * Schema for patient data GET endpoint - */ -exports.getPatientDataSchema = { - url: /\/v1\/patients\/[^/]+\/data$/, - method: 'GET', - expectedStatus: 200, - responseSchema: { - data: 'object', - meta: { - count: 'number', - size: 'number', - }, - }, - validationFields: ['data', 'meta.count', 'meta.size'], -}; -/** - * Schema for uploading patient data - */ -exports.uploadPatientDataSchema = { - url: /\/v1\/patients\/[^/]+\/data$/, - method: 'POST', - expectedStatus: 201, - requestSchema: { - data: 'object', - deviceId: 'string', - uploadId: 'string', - }, - responseSchema: { - id: 'string', - success: 'boolean', - }, - validationFields: ['id', 'success'], -}; -/** - * Schema for getting patient settings - */ -exports.getPatientSettingsSchema = { - url: /\/v1\/patients\/[^/]+\/settings$/, - method: 'GET', - expectedStatus: 200, - responseSchema: { - bgTarget: { - low: 'number', - high: 'number', - }, - units: { - bg: 'string', - }, - siteChangeSource: 'string', - }, - validationFields: ['bgTarget.low', 'bgTarget.high', 'units.bg', 'siteChangeSource'], -}; diff --git a/build/endpoint-schema/profile-endpoints.js b/build/endpoint-schema/profile-endpoints.js deleted file mode 100644 index 0605a5b..0000000 --- a/build/endpoint-schema/profile-endpoints.js +++ /dev/null @@ -1,107 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.getMessageNotesSchema = exports.getMetricsSchema = exports.getPatientDataSchema = exports.putProfileMetadataSchema = exports.getProfileMetadataSchema = void 0; -/** - * Schema for profile metadata GET endpoint - */ -exports.getProfileMetadataSchema = { - url: /\/metadata\/.*\/profile$/, - method: 'GET', - expectedStatus: 200, - responseSchema: { - fullName: 'string', - patient: 'object', - }, - validationFields: [ - 'fullName', - 'patient.fullName', - 'patient.birthday', - 'patient.diagnosisDate', - 'patient.diagnosisType', - 'patient.targetDevices', - 'patient.targetTimezone', - 'patient.about', - 'patient.isOtherPerson', - 'patient.mrn', - 'patient.biologicalSex', - 'email', - 'patient.email', - 'patient.emails', - 'emails', - ], - requiredFields: [ - 'fullName', // Profile endpoint must have fullName - ], -}; -/** - * Schema for profile metadata PUT endpoint - */ -exports.putProfileMetadataSchema = { - url: /\/metadata\/.*\/profile$/, - method: 'PUT', - expectedStatus: 200, - requestSchema: { - fullName: 'string', - patient: 'object', - }, - responseSchema: { - fullName: 'string', - patient: 'object', - }, - validationFields: [ - 'fullName', - 'patient.fullName', - 'patient.birthday', - 'patient.diagnosisDate', - 'patient.diagnosisType', - 'patient.targetDevices', - 'patient.targetTimezone', - 'patient.about', - 'patient.isOtherPerson', - 'patient.mrn', - 'patient.biologicalSex', - 'email', - 'patient.email', - 'patient.emails', - 'emails', - ], - requiredFields: [ - 'fullName', // Profile endpoint must have fullName - ], -}; -/** - * Schema for patient data GET endpoint - */ -exports.getPatientDataSchema = { - url: /\/data\/[^/]+\?.*$/, - method: 'GET', - expectedStatus: 200, - responseSchema: { - // Patient data array - structure will vary - }, - validationFields: [ - // Data array validation fields would go here based on specific data types - ], -}; -/** - * Schema for metrics/analytics endpoint - */ -exports.getMetricsSchema = { - url: /\/metrics\/thisuser\/.*$/, - method: 'GET', - expectedStatus: 200, - validationFields: [ - // Metrics-specific validation fields would go here - ], -}; -/** - * Schema for message notes endpoint - */ -exports.getMessageNotesSchema = { - url: /\/message\/notes\/[^/]+\?.*$/, - method: 'GET', - expectedStatus: 200, // We'll handle 404 as acceptable in the validation logic - validationFields: [ - // Message notes validation fields would go here - ], -}; diff --git a/build/page-objects/LoginPage.js b/build/page-objects/LoginPage.js deleted file mode 100644 index bf30499..0000000 --- a/build/page-objects/LoginPage.js +++ /dev/null @@ -1,44 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -/** - * @class - * @property {Page} page - * @property {Locator} emailInput - * @property {Locator} nextButton - * @property {Locator} passwordInput - * @property {Locator} loginButton - */ -class LoginPage { - /** - * @param {Page} page - */ - constructor(page) { - this.page = page; - this.emailInput = page.getByRole('textbox', { name: 'Email' }); - this.nextButton = page.getByRole('button', { name: 'Next' }); - this.passwordInput = page.getByRole('textbox', { name: 'Password' }); - this.loginButton = page.getByRole('button', { name: 'Log In' }); - } - /** - * Navigate to the login page - * @returns {Promise} - */ - async goto() { - await this.page.goto(`/`); - } - /** - * Login to the application - * @param {string} email - User's email - * @param {string} password - User's password - * @returns {Promise} - */ - // @step("When the user logs in to the application") - async login(email, password) { - await this.emailInput.fill(email); - await this.nextButton.click(); - await this.passwordInput.fill(password); - await this.loginButton.click(); - await this.page.setViewportSize({ width: 1920, height: 1080 }); - } -} -exports.default = LoginPage; diff --git a/build/page-objects/account/AccountNavigation.js b/build/page-objects/account/AccountNavigation.js deleted file mode 100644 index bfc75bc..0000000 --- a/build/page-objects/account/AccountNavigation.js +++ /dev/null @@ -1,62 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -class AccountNav { - constructor(page) { - this.page = page; - this.pages = { - AccountNav: { - name: 'AccountNav', - link: page.locator('button#navigation-menu-trigger'), // Use exact ID to identify menu trigger - verifyURL: '', - verifyElement: page - .locator('button.navigation-menu-option') - .filter({ hasText: 'Private Workspace' }), - }, - PrivateWorkspace: { - name: 'PrivateWorkspace', - link: page - .locator('button.navigation-menu-option') - .filter({ hasText: 'Private Workspace' }), - verifyURL: 'workspaces', - verifyElement: page.getByText('View data for:'), - }, - AccountSettings: { - name: 'AccountSettings', - link: page - .locator('#navigationMenu button.navigation-menu-option') - .filter({ hasText: 'Account Settings' }), - verifyURL: 'account', - verifyElement: page.locator('.profile-subnav-title').getByText('Account'), // Target the specific Account title element - }, - ManageWorkspaces: { - name: 'ManageWorkspaces', - link: page - .locator('#navigationMenu button.navigation-menu-option') - .filter({ hasText: 'Manage Workspaces' }), - verifyURL: 'workspaces', - verifyElement: page.getByText('Welcome To Tidepool'), // Should land back on the workspace selection page - }, - Logout: { - name: 'Logout', - link: page - .locator('#navigationMenu button.navigation-menu-option') - .filter({ hasText: 'Logout' }), - verifyURL: 'login', - verifyElement: page.getByRole('heading', { name: 'Log in to Tidepool' }), - }, - }; - } - /** - * Navigate to a page in the account navigation menu by key. - * Example: await accountNav.navigateTo('AccountSettings'); - */ - async navigateTo(pageKey) { - // Always open the navigation menu first - await this.pages.AccountNav.link.click(); - // Then click the desired page - await this.pages[pageKey].link.click(); - // Wait for the verification element to appear - await this.pages[pageKey].verifyElement.waitFor({ state: 'visible', timeout: 5000 }); - } -} -exports.default = AccountNav; diff --git a/build/page-objects/account/AccountSettingsPage.js b/build/page-objects/account/AccountSettingsPage.js deleted file mode 100644 index a3d10e5..0000000 --- a/build/page-objects/account/AccountSettingsPage.js +++ /dev/null @@ -1,13 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.AccountSettingsPage = void 0; -class AccountSettingsPage { - constructor(page) { - this.page = page; - this.emailInput = page.getByRole('textbox', { name: 'Email' }); - this.saveButton = page.getByRole('button', { name: /save/i }); - this.saveConfirm = page.getByText(/All Changes Saved/i); - } -} -exports.AccountSettingsPage = AccountSettingsPage; -exports.default = AccountSettingsPage; diff --git a/build/page-objects/clinician/ClinicCreationPage.js b/build/page-objects/clinician/ClinicCreationPage.js deleted file mode 100644 index e162e1b..0000000 --- a/build/page-objects/clinician/ClinicCreationPage.js +++ /dev/null @@ -1,84 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -class ClinicCreationPage { - constructor(page) { - this.url = '/clinic-details/new'; - this.page = page; - // Page header elements - this.pageHeader = page.getByText('Create your Clinic Workspace'); - this.pageDescription = page.getByText('The information below will be displayed along with your name'); - // Form input fields - this.clinicNameInput = page.getByLabel('Clinic Name'); - this.teamTypeDropdown = page.getByRole('combobox', { name: 'What best describes your team?' }); - this.countryDropdown = page.getByRole('combobox', { name: 'Country' }); - this.stateDropdown = page.getByRole('combobox', { name: 'State' }); - this.addressInput = page.getByLabel('Address'); - this.cityInput = page.getByLabel('City'); - this.zipCodeInput = page.getByLabel('Zip code'); - this.websiteInput = page.getByLabel('Website (optional)'); - // Blood glucose units radio buttons - this.mgdlRadio = page.getByLabel('mg/dL'); - this.mmolRadio = page.getByLabel('mmol/L'); - // Acknowledgement checkbox - this.adminAcknowledgeCheckbox = page.getByRole('checkbox', { - name: 'By creating this clinic, your Tidepool account will become the default administrator', - }); - // Action buttons - this.backButton = page.getByRole('button', { name: 'Back' }); - this.createWorkspaceButton = page.getByRole('button', { name: 'Create Workspace' }); - } - /** - * Navigate to the clinic creation page - */ - async goto() { - await this.page.goto(this.url); - } - /** - * Fill the clinic creation form with required information - * @param clinicName - Name of the clinic - * @param teamType - Type of the team - * @param state - State (for US clinics) - * @param address - Street address - * @param city - City name - * @param zipCode - Zip/Postal code - * @param website - Optional website URL - */ - async fillClinicForm({ clinicName, teamType = 'Provider Practice', state = 'California', address = '123 Test Street', city = 'Test City', zipCode = '12345', website = '', }) { - // Fill in clinic name - await this.clinicNameInput.fill(clinicName); - // Select team type - await this.teamTypeDropdown.selectOption(teamType); - // Select state (US is selected by default) - await this.stateDropdown.selectOption(state); - // Fill in address details - await this.addressInput.fill(address); - await this.cityInput.fill(city); - await this.zipCodeInput.fill(zipCode); - // Fill in optional website if provided - if (website) { - await this.websiteInput.fill(website); - } - } - /** - * Select blood glucose units - * @param unit - "mg/dL" or "mmol/L" - */ - async selectBloodGlucoseUnit(unit) { - if (unit === 'mg/dL') { - await this.mgdlRadio.check(); - } - else { - await this.mmolRadio.check(); - } - } - /** - * Create a clinic by filling the form and submitting - * @param clinicName - Name of the clinic to create (required) - * @param formData - Optional form data (uses defaults if not provided) - */ - async createClinic(clinicName, formData) { - await this.fillClinicForm({ clinicName, ...formData }); - await this.createWorkspaceButton.click(); - } -} -exports.default = ClinicCreationPage; diff --git a/build/page-objects/clinician/ClinicianDashboardPage.js b/build/page-objects/clinician/ClinicianDashboardPage.js deleted file mode 100644 index 01edc05..0000000 --- a/build/page-objects/clinician/ClinicianDashboardPage.js +++ /dev/null @@ -1,79 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -class ClinicianDashboardPage { - constructor(page) { - this.url = '/clinic-workspace'; - this.name = 'ClinicianDashboardPage'; // Added name for step decorator context - this.page = page; - // Main page locators - this.addNewPatientButton = page.getByRole('button', { name: 'Add New Patient' }); - this.searchInput = page.getByRole('textbox', { name: 'Search' }); - this.patientListTable = page.getByRole('table', { name: 'peopletablelabel' }); - // Add Patient Dialog locators - this.addPatientDialog = page.getByRole('dialog', { name: /Add New Patient Account/i }); - this.addPatientDialog_fullNameInput = this.addPatientDialog.getByRole('textbox', { - name: 'Full Name', - }); - this.addPatientDialog_birthdateInput = this.addPatientDialog.getByRole('textbox', { - name: 'Birthdate', - }); - this.addPatientDialog_addButton = this.addPatientDialog.getByRole('button', { - name: 'Add Patient', - }); - // Bring Data Dialog locators - this.bringDataDialog = page.getByRole('dialog', { name: /Bring Data into Tidepool/i }); - this.bringDataDialog_doneButton = this.bringDataDialog.getByRole('button', { name: 'Done' }); - } - /** - * Opens the Add Patient dialog and fills in the patient details. - * @param name - The full name of the patient. - * @param birthdate - The birthdate of the patient (e.g., MM/DD/YYYY). - */ - async openAndFillAddPatientDialog(name, birthdate) { - await this.addNewPatientButton.click(); - await this.addPatientDialog.waitFor({ state: 'visible' }); - await this.addPatientDialog_fullNameInput.fill(name); - await this.addPatientDialog_birthdateInput.fill(birthdate); - } - /** - * Clicks the Add Patient button in the dialog to submit the new patient. - */ - async submitAddPatientDialog() { - await this.addPatientDialog_addButton.click(); - } - /** - * Closes the Bring Data into Tidepool dialog by clicking Done. - */ - async closeBringDataDialog() { - await this.bringDataDialog.waitFor({ state: 'visible' }); - await this.bringDataDialog_doneButton.click(); - await this.bringDataDialog.waitFor({ state: 'hidden' }); - } - /** - * Searches for a patient in the list. - * @param name - The name of the patient to search for. - */ - async searchForPatient(name) { - await this.searchInput.fill(name); - // Press Enter to trigger search - await this.searchInput.press('Enter'); - // Wait longer for search to process and results to load - await this.page.waitForTimeout(3000); - } - /** - * Gets the locator for a patient cell in the table by name. - * @param name - The name of the patient. - * @returns Locator for the table cell containing the patient's name. - */ - getPatientCellByName(name) { - // Use exact match to avoid multiple matches with similar names - return this.patientListTable.getByRole('cell', { name, exact: true }); - } - /** - * Waits for the main elements of the Clinic Workspace page to be visible. - */ - async waitForLoadState() { - await this.addNewPatientButton.waitFor({ state: 'visible' }); - } -} -exports.default = ClinicianDashboardPage; diff --git a/build/page-objects/clinician/ClinicianNavigation.js b/build/page-objects/clinician/ClinicianNavigation.js deleted file mode 100644 index 7cabb9b..0000000 --- a/build/page-objects/clinician/ClinicianNavigation.js +++ /dev/null @@ -1,119 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -class ClinicianNav { - constructor(page) { - this.page = page; - // Define hardcoded workspace configurations (matching PatientNavigation approach) - this.workspaces = { - AdminClinicBase: { - name: 'Admin Clinic (Base)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Admin Clinic (Base) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Admin Clinic (Base)' }), - }, - AdminClinicEnterprise: { - name: 'Admin Clinic (Enterprise)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Admin Clinic (Enterprise) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Admin Clinic (Enterprise)' }), - }, - MemberClinicBase: { - name: 'Member Clinic (Base)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Member Clinic (Base) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Member Clinic (Base)' }), - }, - MemberClinicEnterprise: { - name: 'Member Clinic (Enterprise)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Member Clinic (Enterprise) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Member Clinic (Enterprise)' }), - }, - NonMemberClinicBase: { - name: 'Non-Member Clinic (Base)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Non-Member Clinic (Base) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Non-Member Clinic (Base)' }), - }, - NonMemberClinicEnterprise: { - name: 'Non-Member Clinic (Enterprise)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Non-Member Clinic (Enterprise) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Non-Member Clinic (Enterprise)' }), - }, - PartnerClinicBase: { - name: 'Partner Clinic (Base)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Partner Clinic (Base) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Partner Clinic (Base)' }), - }, - PartnerClinicEnterprise: { - name: 'Partner Clinic (Enterprise)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Partner Clinic (Enterprise) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Partner Clinic (Enterprise)' }), - }, - }; - // Define clinician page navigation (matching PatientNavigation format) - this.pages = { - PatientList: { - name: 'PatientList', - link: page.getByRole('link', { name: 'Patients' }), - verifyURL: 'clinic-workspace/patients', - verifyElement: page.getByRole('heading', { name: 'Patients' }), - }, - WorkspaceSettings: { - name: 'WorkspaceSettings', - link: page.getByRole('link', { name: 'Workspace Settings' }), - verifyURL: 'clinic-workspace/workspace/settings', - verifyElement: page.getByRole('heading', { name: 'Workspace Settings' }), - }, - AddPatient: { - name: 'AddPatient', - link: page.getByRole('button', { name: 'Add Patient' }), - verifyURL: 'clinic-workspace/patients/add', - verifyElement: page.getByRole('heading', { name: 'Add Patient' }), - }, - Profile: { - name: 'Profile', - link: page - .getByRole('button', { name: 'Patient Profile Profile' }) - .or(page.getByRole('tab', { name: 'Profile' })) - .or(page.getByRole('link', { name: 'Profile' })) - .or(page.getByRole('button', { name: 'Profile' })), - verifyURL: 'profile', - verifyElement: page - .getByRole('button', { name: 'Edit' }) - .or(page.getByRole('button', { name: 'Edit Profile' })), - }, - ProfileEdit: { - name: 'ProfileEdit', - link: page - .getByRole('button', { name: 'Edit' }) - .or(page.getByRole('button', { name: 'Edit Profile' })), - verifyURL: 'profile', - verifyElement: page - .getByRole('button', { name: 'Save changes' }) - .or(page.getByRole('button', { name: 'Save Profile' })) - .or(page.getByRole('button', { name: 'Save' })), - }, - }; - } -} -exports.default = ClinicianNav; diff --git a/build/page-objects/clinician/WorkspaceSettingsPage.js b/build/page-objects/clinician/WorkspaceSettingsPage.js deleted file mode 100644 index 2dffe7a..0000000 --- a/build/page-objects/clinician/WorkspaceSettingsPage.js +++ /dev/null @@ -1,29 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -class ClinicAdminPage { - constructor(page) { - this.url = '/clinic-admin'; - this.name = 'ClinicAdminPage'; // Added name for step decorator context - this.page = page; - this.clinicDetailsHeader = page.getByText('Workspace Settings'); - // Assuming the edit button is specifically associated with the details section - this.editDetailsButton = page.getByRole('button', { name: 'Edit' }); - this.editClinicModal = page.getByRole('dialog'); // General dialog selector - this.editClinicModalTitle = this.editClinicModal.getByRole('heading', { - name: 'Edit Workspace Details', - }); - this.addressInput = this.editClinicModal.getByLabel('Address', { exact: true }); // Use exact label match - this.saveChangesButton = this.editClinicModal.getByRole('button', { name: 'Save Changes' }); - // Assuming the details are within a specific container section related to the header - this.clinicDetailsSection = page.locator('div:has(> span:text-is("Workspace Settings")) + div'); - } - /** - * Waits for essential elements of the Clinic Admin page to be loaded. - */ - async waitForLoadState() { - await this.page.waitForLoadState(); // Wait for base elements like header/footer - await this.clinicDetailsHeader.waitFor({ state: 'visible', timeout: 40000 }); - await this.editDetailsButton.waitFor({ state: 'visible', timeout: 10000 }); - } -} -exports.default = ClinicAdminPage; diff --git a/build/page-objects/clinician/WorkspacesPage.js b/build/page-objects/clinician/WorkspacesPage.js deleted file mode 100644 index 38f982f..0000000 --- a/build/page-objects/clinician/WorkspacesPage.js +++ /dev/null @@ -1,36 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const env_1 = __importDefault(require("../../utilities/env")); -class WorkspacesPage { - constructor(page) { - this.url = `${env_1.default.BASE_URL}/workspaces`; - this.page = page; - this.header = page.getByRole('heading', { name: 'Clinic Workspace' }); - this.subHeader = page.getByRole('paragraph', { - name: 'View, share and manage patient data', - }); - this.createClinicButton = page.getByRole('button', { - name: 'Create a New Clinic', - }); - } - async goto() { - await this.page.goto(this.url); - } - async visitFirstClinic() { - await this.page.getByRole('button', { name: 'Go To Workspace' }).first().click(); - } - /** - * Visit a clinic by name - * @param clinicName - The name of the clinic to visit - */ - async visitClinic(clinicName) { - // find child element with text and filter by parent element with class - const child = this.page.getByText(clinicName); - const parent = this.page.locator('.workspace-item-clinic').filter({ has: child }); - await parent.getByRole('button', { name: 'Go To Workspace' }).first().click(); - } -} -exports.default = WorkspacesPage; diff --git a/build/page-objects/clinician/components/navigation-menu.section.js b/build/page-objects/clinician/components/navigation-menu.section.js deleted file mode 100644 index 7aa1dda..0000000 --- a/build/page-objects/clinician/components/navigation-menu.section.js +++ /dev/null @@ -1,27 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -class NavigationMenu { - constructor(page) { - this.page = page; - this.container = page.locator('div#navigation-menu'); - this.buttons = { - trigger: this.container.locator('#navigation-menu-trigger'), - menu: { - privateWorkspace: this.container.getByRole('button', { - name: 'Private Workspace', - }), - accountSettings: this.container.getByRole('button', { - name: 'Account Settings', - }), - logout: this.container.getByRole('button', { name: 'Logout' }), - }, - }; - } - async open() { - await this.buttons.trigger.click(); - } - async close() { - await this.buttons.trigger.click(); - } -} -exports.default = NavigationMenu; diff --git a/build/page-objects/clinician/components/navigation.section.js b/build/page-objects/clinician/components/navigation.section.js deleted file mode 100644 index 176d5ff..0000000 --- a/build/page-objects/clinician/components/navigation.section.js +++ /dev/null @@ -1,22 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const navigation_menu_section_1 = __importDefault(require("./navigation-menu.section")); -class NavigationSection { - constructor(page) { - this.page = page; - this.container = page.locator('div#navPatientHeader'); - this.menu = new navigation_menu_section_1.default(page); - this.buttons = { - viewData: this.container.getByRole('button', { name: 'View Data' }), - patientProfile: this.container.getByRole('button', { - name: 'Patient Profile', - }), - share: this.container.getByRole('button', { name: 'Share' }), - uploadData: this.container.getByRole('button', { name: 'Upload Data' }), - }; - } -} -exports.default = NavigationSection; diff --git a/build/page-objects/patient/BasicsPage.js b/build/page-objects/patient/BasicsPage.js deleted file mode 100644 index 5977251..0000000 --- a/build/page-objects/patient/BasicsPage.js +++ /dev/null @@ -1,143 +0,0 @@ -"use strict"; -var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) { - var useValue = arguments.length > 2; - for (var i = 0; i < initializers.length; i++) { - value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); - } - return useValue ? value : void 0; -}; -var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) { - function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; } - var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; - var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null; - var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); - var _, done = false; - for (var i = decorators.length - 1; i >= 0; i--) { - var context = {}; - for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; - for (var p in contextIn.access) context.access[p] = contextIn.access[p]; - context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); }; - var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context); - if (kind === "accessor") { - if (result === void 0) continue; - if (result === null || typeof result !== "object") throw new TypeError("Object expected"); - if (_ = accept(result.get)) descriptor.get = _; - if (_ = accept(result.set)) descriptor.set = _; - if (_ = accept(result.init)) initializers.unshift(_); - } - else if (_ = accept(result)) { - if (kind === "field") initializers.unshift(_); - else descriptor[key] = _; - } - } - if (target) Object.defineProperty(target, contextIn.name, descriptor); - done = true; -}; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const base_1 = require("@fixtures/base"); -const PatientNavigation_1 = __importDefault(require("@pom/patient/PatientNavigation")); -const navigation_section_1 = __importDefault(require("@components/navigation.section")); -function createSection(page, selector) { - const parsedSelector = selector === 'tubing-primes' ? 'siteChanges' : selector; - const container = page.locator(`.Calendar-container-${parsedSelector}`); - return { - container, - firstDayOfData: container.locator(`.Calendar-day--${parsedSelector}.Calendar-day`).first(), - calendarDayhover: { - el: container.locator('.Calendar-day--HOVER'), - async text() { - return container.locator('.Calendar-day--HOVER').locator('.Calendar-weekday').textContent(); - }, - }, - }; -} -/** - * helper function to create a stat object with locators for the container, header, hoverBar, and hoverBarLabel - */ -function createStat(page, selector) { - const container = page.locator(`#Stat--${selector}`); - return { - container, - header: container.locator('[class^="Stat--chartTitleText"]'), - hoverBar: container.locator('.HoverBar'), - hoverBarLabel: container.locator('.HoverBarLabel'), - }; -} -// list of sections in the stats sidebar -const statsSideBarSection = [ - 'timeInRange', - 'readingsInRange', - 'averageGlucose', - 'totalInsulin', - 'carbs', - 'standardDev', - 'coefficientOfVariation', - 'sensorUsage', - 'glucoseManagementIndicator', - 'totalInsulin', - 'averageDailyDose', -]; -let PatientDataBasicsPage = (() => { - var _a; - let _instanceExtraInitializers = []; - let _goto_decorators; - return _a = class PatientDataBasicsPage { - constructor(page) { - this.page = __runInitializers(this, _instanceExtraInitializers); - this.page = page; - this.url = '/patients/data/basics'; - this.emailInput = page.getByRole('textbox', { name: 'Email' }); - this.navigationBar = new navigation_section_1.default(page); - this.navigationSubMenu = new PatientNavigation_1.default(page); - this.headerBgReading = page.getByRole('heading', { name: 'BG readings' }); - this.headerBolusing = page.getByRole('heading', { name: 'Bolusing' }); - this.statsSidebar = { - toggleContainer: page.locator('.toggle-container'), - async toggleTo(toState) { - const activeToggleState = await page - .locator(".toggle-container span[class*='TwoOptionToggle--active']") - .innerText(); - if (activeToggleState === 'BGM' && toState === 'CGM') { - await this.toggleContainer.click(); - } - else if (activeToggleState === 'CGM' && toState === 'BGM') { - await this.toggleContainer.click(); - } - }, - ...Object.fromEntries(statsSideBarSection.map(stat => [stat, createStat(page, stat)])), - }; - // charts - this.bgReadingsSection = createSection(page, 'fingersticks'); - this.bolusingSection = createSection(page, 'boluses'); - this.tubingPrimeSection = { - ...createSection(page, 'tubing-primes'), - settings: page.locator('.SiteChangeSelector-option').first(), - settingsOption: { - fillTubing: page.getByLabel('Tubing Fill'), - fillCannula: page.getByLabel('Cannula Fill'), - }, - tubingIcons: page.locator('.Change--tubing').first(), - cannulaIcons: page.locator('.Change--cannula').first(), - filledDay: createSection(page, 'tubing-primes') - .container.locator('.Calendar-day') - .filter({ has: page.locator('.Change-daysSince-text') }) - .first(), - }; - this.basalsSection = createSection(page, 'basals'); - } - async goto() { - await this.page.goto(this.url); - } - }, - (() => { - const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0; - _goto_decorators = [(0, base_1.step)('Navigate to the basics page')]; - __esDecorate(_a, null, _goto_decorators, { kind: "method", name: "goto", static: false, private: false, access: { has: obj => "goto" in obj, get: obj => obj.goto }, metadata: _metadata }, null, _instanceExtraInitializers); - if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata }); - })(), - _a; -})(); -exports.default = PatientDataBasicsPage; diff --git a/build/page-objects/patient/DailyPage.js b/build/page-objects/patient/DailyPage.js deleted file mode 100644 index eb0ad4e..0000000 --- a/build/page-objects/patient/DailyPage.js +++ /dev/null @@ -1,17 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const daily_chart_js_1 = __importDefault(require("@components/daily-chart.js")); -const PatientNavigation_js_1 = __importDefault(require("@pom/patient/PatientNavigation.js")); -const navigation_section_js_1 = __importDefault(require("@components/navigation.section.js")); -class PatientDataDailyPage { - constructor(page) { - this.page = page; - this.navigationBar = new navigation_section_js_1.default(page); - this.navigationSubMenu = new PatientNavigation_js_1.default(page); - this.dailyChart = new daily_chart_js_1.default(page); - } -} -exports.default = PatientDataDailyPage; diff --git a/build/page-objects/patient/PatientNavigation.js b/build/page-objects/patient/PatientNavigation.js deleted file mode 100644 index cec9e3c..0000000 --- a/build/page-objects/patient/PatientNavigation.js +++ /dev/null @@ -1,100 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -class PatientNav { - // currentDate: Locator; - constructor(page) { - this.page = page; - this.pages = { - ViewData: { - name: 'ViewData', - link: page.getByRole('button', { name: 'View Data View' }), - verifyURL: 'data', - verifyElement: page.locator('div.patient-data-subnav-inner'), - }, - Basics: { - name: 'Basics', - link: page.getByRole('link', { name: 'Basics' }), - verifyURL: 'data/basics', - verifyElement: page.locator('.js-basics.patient-data-subnav-tablink.patient-data-subnav-active'), - }, - ChartDateRange: { - name: 'ChartDateRange', - link: page - .locator('button svg, .css-15vjjnj svg, [aria-label*="calendar"], [title*="calendar"]') - .first(), // Calendar icon in blue navigation bar - verifyURL: '', - verifyElement: page.locator('#printDateRangePickerInner').locator('*').first(), // Any content inside the dialog - closeButton: page.getByRole('button', { name: 'close dialog' }), - }, - Daily: { - name: 'Daily', - link: page.getByRole('link', { name: 'Daily' }), - verifyURL: 'data/daily', - verifyElement: page.locator('.js-daily.patient-data-subnav-tablink.patient-data-subnav-active'), - }, - ChartDate: { - name: 'ChartDate', - link: page.locator('#tidelineLabel .css-15vjjnj svg'), // Using the same calendar icon selector - verifyURL: '', - verifyElement: page.getByRole('heading', { name: 'Chart Date' }), - closeButton: page.getByRole('button', { name: 'close dialog' }), - }, - BGLog: { - name: 'BGLog', - link: page.getByRole('link', { name: 'BG Log' }), - verifyURL: 'data/bglog', - verifyElement: page.locator('.js-bgLog.patient-data-subnav-tablink.patient-data-subnav-active'), - }, - Trends: { - name: 'Trends', - link: page.getByRole('link', { name: 'Trends' }), - verifyURL: 'data/trends', - verifyElement: page.locator('.js-trends.patient-data-subnav-tablink.patient-data-subnav-active'), - }, - Devices: { - name: 'Devices', - link: page.getByRole('link', { name: 'Devices' }), - verifyURL: 'data/devices', - verifyElement: page.locator('.js-settings.patient-data-subnav-tablink.patient-data-subnav-active'), - }, - Print: { - name: 'Print', - link: page.getByRole('link', { name: 'Print PDF report Print' }), // Print link from the snapshot - verifyURL: '', - verifyElement: page.getByRole('heading', { name: 'Print Report' }), // Assuming modal title - closeButton: page.getByRole('button', { name: 'close dialog' }), - }, - Profile: { - name: 'Profile', - link: page.getByRole('button', { name: 'Profile Profile' }), - verifyURL: '', - verifyElement: page.getByRole('button', { name: 'Edit' }), // Edit button is visible on profile page - }, - ProfileEdit: { - name: 'ProfileEdit', - link: page.getByRole('button', { name: 'Edit' }), - verifyURL: 'profile', - verifyElement: page.getByRole('button', { name: 'Save changes' }), // Save changes button appears when in edit mode - }, - Share: { - name: 'Share', - link: page.getByRole('button', { name: 'Share Share' }), - verifyURL: 'share', - verifyElement: page.getByRole('heading', { name: 'Access Management' }), - }, - ShareData: { - name: 'ShareData', - link: page.getByRole('button', { name: 'Share Data' }), - verifyURL: 'share/invite', - verifyElement: page.getByRole('heading', { name: 'Share your data' }), - }, - UploadData: { - name: 'UploadData', - link: page.getByRole('button', { name: 'Upload Data Upload' }), - verifyURL: 'upload', - verifyElement: page.getByRole('heading', { name: 'Upload Data' }), - }, - }; - } -} -exports.default = PatientNav; diff --git a/build/page-objects/patient/ProfilePage.js b/build/page-objects/patient/ProfilePage.js deleted file mode 100644 index 003f029..0000000 --- a/build/page-objects/patient/ProfilePage.js +++ /dev/null @@ -1,115 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.ProfilePage = void 0; -class ProfilePage { - constructor(page) { - this.page = page; - this.fieldLocators = { - fullName: this.page.getByRole('textbox', { name: 'Full name' }), - birthDate: this.page.getByRole('textbox', { name: 'Date of birth' }), - mrn: this.page.getByRole('textbox', { name: 'MRN' }), - diagnosisDate: this.page.getByRole('textbox', { name: 'Date of diagnosis' }), - clinicalNotes: this.page.getByRole('textbox', { name: 'Anything you would like to share' }), - email: this.page.getByRole('textbox', { name: /email/i }), - }; - } - // Generic fill method for text fields - async fillField(field, value) { - const locator = this.fieldLocators[field]; - if (!locator) - throw new Error(`No locator defined for field: ${field}`); - if (await locator.isVisible({ timeout: 3000 }).catch(() => false)) { - await locator.fill(value); - } - else { - throw new Error(`Field '${field}' not found or not visible`); - } - } - // Select a diagnosis type from the dropdown - async selectDiagnosisType(index) { - const diagnosisCombo = this.page.getByRole('combobox', { name: 'Diagnosed as' }); - if (await diagnosisCombo.isVisible({ timeout: 3000 })) { - await diagnosisCombo.selectOption({ index }); - } - } - // Get the current diagnosis index from the dropdown (needed for setting a new diagnosis) - async getCurrentDiagnosisIndex() { - const diagnosisCombo = this.page.getByRole('combobox', { name: 'Diagnosed as' }); - if (await diagnosisCombo.isVisible({ timeout: 3000 })) { - const currentValue = await diagnosisCombo.inputValue(); - const options = await diagnosisCombo.locator('option').all(); - // Find current index by checking option values - for (let i = 0; i < options.length; i += 1) { - const optionValue = await options[i].getAttribute('value'); - if (optionValue === currentValue) { - return i; - } - } - } - return 1; // Default to 1 if not found - } - // For backwards compatibility, keep these as wrappers (optional) - async fillFullName(name) { - return this.fillField('fullName', name); - } - async fillBirthDate(date) { - return this.fillField('birthDate', date); - } - async fillMRN(mrn) { - return this.fillField('mrn', mrn); - } - async fillDiagnosisDate(date) { - return this.fillField('diagnosisDate', date); - } - async fillClinicalNotes(notes) { - return this.fillField('clinicalNotes', notes); - } - async fillEmail(email) { - return this.fillField('email', email); - } - async saveProfile() { - // Save button locators - const saveButtons = [ - this.page.getByRole('button', { name: 'Save changes' }), - this.page.getByRole('button', { name: 'Save Profile' }), - this.page.getByRole('button', { name: 'Save' }), - ]; - // Wait for the PUT request to complete after clicking save - const saveProfilePromise = this.page.waitForResponse(response => response.url().includes('/metadata/') && - response.url().includes('/profile') && - response.request().method() === 'PUT'); - let clicked = false; - for (const btn of saveButtons) { - if (await btn.isVisible({ timeout: 5000 }).catch(() => false)) { - await btn.click(); - clicked = true; - break; - } - } - if (!clicked) - throw new Error('No save button found'); - // Wait for the PUT request to complete (with timeout) - try { - await saveProfilePromise; - } - catch (error) { - console.log('āš ļø PUT request timeout - continuing anyway'); - } - } - /** - * Checks if the edit button is displayed and validates against expected state - * @param shouldBeVisible - Boolean indicating whether the edit button should be visible - * @throws Error if the actual visibility doesn't match the expected state - */ - async editButtonDisplays(shouldBeVisible) { - const editButton = this.page.getByRole('button', { name: 'Edit' }); - const isEditButtonVisible = await editButton.isVisible({ timeout: 3000 }).catch(() => false); - if (shouldBeVisible && !isEditButtonVisible) { - throw new Error('Edit button should be visible but was not found'); - } - else if (!shouldBeVisible && isEditButtonVisible) { - throw new Error('Edit button should not be visible for this user - security violation!'); - } - } -} -exports.ProfilePage = ProfilePage; diff --git a/build/page-objects/patient/components/daily-chart.js b/build/page-objects/patient/components/daily-chart.js deleted file mode 100644 index 5eee722..0000000 --- a/build/page-objects/patient/components/daily-chart.js +++ /dev/null @@ -1,14 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -class DailyChartSection { - constructor(page) { - this.page = page; - this.container = page.locator('div.patient-data-content'); - this.dayLabel = this.container.locator('text.d3-day-label').filter({ visible: true }); - this.newNote = this.container.locator('image.newNoteIcon'); - this.buttons = { - refresh: this.container.getByRole('button', { name: 'Refresh' }), - }; - } -} -exports.default = DailyChartSection; diff --git a/build/playwright.config.js b/build/playwright.config.js deleted file mode 100644 index d6b290c..0000000 --- a/build/playwright.config.js +++ /dev/null @@ -1,106 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const test_1 = require("@playwright/test"); -const node_path_1 = __importDefault(require("node:path")); -const env_1 = __importDefault(require("./utilities/env")); -// Helper to detect BrowserStack run -const isBrowserStack = Boolean(process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESS_KEY); -function buildBrowserStackEndpoint(testName) { - const caps = { - browser: 'chrome', - browser_version: 'latest', - os: 'os x', - os_version: 'catalina', - name: testName, - build: process.env.CI_BUILD_NUMBER || 'local-run', - 'browserstack.username': process.env.BROWSERSTACK_USERNAME, - 'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY, - }; - return `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify(caps))}`; -} -exports.default = (0, test_1.defineConfig)({ - testDir: './tests', - outputDir: './test-results', // Custom output directory - globalSetup: require.resolve(node_path_1.default.join(__dirname, 'tests/global-setup')), - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - timeout: 60000, - expect: { - toHaveScreenshot: { maxDiffPixelRatio: 0.2 }, - }, - reporter: [ - ['html', { open: 'never', outputFolder: 'playwright-report' }], - ['json', { outputFile: 'test-results/last-run.json' }], - ['./utilities/xray-json-reporter.ts'], - ], - use: { - baseURL: env_1.default.BASE_URL, - trace: 'on-first-retry', - screenshot: 'only-on-failure', - video: 'retain-on-failure', - // Custom test attachment naming - testIdAttribute: 'data-testid', - }, - projects: [ - { - name: 'chromium-personal', - testMatch: '**/personal/**/*.spec.ts', - use: { - ...test_1.devices['Desktop Chrome'], - storageState: 'tests/.auth/personal.json', - headless: false, - }, - }, - { - name: 'chromium-claimed', - testMatch: '**/claimed/**/*.spec.ts', - use: { - ...test_1.devices['Desktop Chrome'], - storageState: 'tests/.auth/claimed.json', - headless: false, - }, - }, - { - name: 'chromium-clinician', - testMatch: '**/clinician/**/*.spec.ts', - use: { - ...test_1.devices['Desktop Chrome'], - storageState: 'tests/.auth/clinician.json', - headless: false, - }, - }, - ...(isBrowserStack - ? [ - { - name: 'bs-chrome-personal', - testMatch: '**/patient/**/*.spec.ts', - use: { - storageState: 'tests/.auth/personal.json', - connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Personal Patient Tests') }, - }, - }, - { - name: 'bs-chrome-claimed', - testMatch: '**/claimed/**/*.spec.ts', - use: { - storageState: 'tests/.auth/claimed.json', - connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Claimed Patient Tests') }, - }, - }, - { - name: 'bs-chrome-clinician', - testMatch: '**/clinician/**/*.spec.ts', - use: { - storageState: 'tests/.auth/clinician.json', - connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Clinician Tests') }, - }, - }, - ] - : []), - ], -}); diff --git a/build/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js b/build/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js deleted file mode 100644 index ba00295..0000000 --- a/build/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js +++ /dev/null @@ -1,148 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const base_1 = require("../../fixtures/base"); -const patient_helpers_1 = require("../../fixtures/patient-helpers"); -const account_helpers_1 = require("../../fixtures/account-helpers"); -const clinic_helpers_1 = require("../../fixtures/clinic-helpers"); -const network_helpers_1 = require("../../fixtures/network-helpers"); -const test_tags_1 = require("../../fixtures/test-tags"); -const AccountSettingsPage_1 = require("../../../page-objects/account/AccountSettingsPage"); -const ProfilePage_1 = require("../../../page-objects/patient/ProfilePage"); -const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; -const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; -base_1.test.describe('Claimed Account Settings edit (Full Name only) updates Profile endpoint and visually updates for user, clinic, and shared member', () => { - base_1.test.setTimeout(120000); // 2 minute timeout for multi-phase test - let api; - let putCapture; - let newName; // Declare at test level scope - (0, base_1.test)('should allow navigation to account settings, edit full name, and verify profile update for claimed, shared, and clinician users', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.PATIENT, - test_tags_1.TEST_TAGS.CLINICIAN, // Added clinician tag - test_tags_1.TEST_TAGS.CLAIMED, - test_tags_1.TEST_TAGS.SHARED_MEMBER, // Added shared member tag - test_tags_1.TEST_TAGS.API, - test_tags_1.TEST_TAGS.UI, - test_tags_1.TEST_TAGS.HIGH, - test_tags_1.TEST_TAGS.API_PROFILE, - ]), - }, async ({ page }) => { - // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== - // Step 1: Log in to clinician account and setup network capture - await base_1.test.step('Given claimed account has been logged in', async () => { - api = (0, network_helpers_1.createNetworkHelper)(page); - await api.startCapture(); - await page.goto('/data'); - await patient_helpers_1.test.patient.setup(page); - }); - // Step 2: Navigate to account settings - await base_1.test.step('When user navigates to account settings', async () => { - await account_helpers_1.test.account.navigateTo('AccountSettings', page); - }); - // Step 3: GET response is pulled and validated - await base_1.test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - // Create new acccount settings page for the following test - const accountSettingsPage = new AccountSettingsPage_1.AccountSettingsPage(page); - // Step 4: Change the Full Name field to a new value - await base_1.test.step('When user updates the Full Name field', async () => { - newName = `Claimed User Updated ${Math.floor(Math.random() * 10000)}`; // Remove let declaration - const nameInput = page.getByRole('textbox', { name: /full name/i }); - await nameInput.fill(newName); - }); - // Step 5: Tap the Save button - await base_1.test.step('When user taps the save button', async () => { - await accountSettingsPage.saveButton.click(); - }); - // Step 6: Confirm save changes message displays - await base_1.test.step('Then the save changes message displays', async () => { - await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); - }); - // Step 7: Validate PUT request and save value - await base_1.test.stepNoScreenshot('Then PUT request is validated and name is set to new value', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - putCapture = api - .getCaptures() - .find((req) => req.method === 'PUT' && req.url.includes('/profile')); - if (!putCapture) - throw new Error('No PUT /profile request captured'); - if (!putCapture.requestBody || - !putCapture.requestBody.fullName || - putCapture.requestBody.fullName !== newName) { - throw new Error(`PUT request did not set fullName to ${newName}`); - } - }); - // Step 8: Navigate to Profile page - await base_1.test.step('When user navigates to Profile page', async () => { - await patient_helpers_1.test.patient.navigateTo('Profile', page); - }); - // Step 9: Confirm GET request matches the saved PUT request - await base_1.test.stepNoScreenshot('Then GET request matches the saved PUT request', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - // Get all captures and find the LATEST GET request (after the PUT) - const allCaptures = api.getCaptures(); - const putIndex = allCaptures.findIndex(req => req === putCapture); - // Find GET requests that occurred AFTER the PUT request - const laterGetCaptures = allCaptures - .slice(putIndex + 1) - .filter((req) => req.method === 'GET' && req.url.includes('/profile')); - if (laterGetCaptures.length === 0) { - throw new Error('No GET /profile request captured after the PUT request'); - } - // Use the most recent GET request - const getCapture = laterGetCaptures[laterGetCaptures.length - 1]; - if (!getCapture.responseBody || - getCapture.responseBody.fullName !== putCapture.requestBody.fullName) { - console.log('GET response fullName:', getCapture.responseBody.fullName); - console.log('PUT request fullName:', putCapture.requestBody.fullName); - console.log('Total captures:', allCaptures.length); - console.log('PUT index:', putIndex); - console.log('Later GET captures found:', laterGetCaptures.length); - throw new Error('GET response fullName does not match PUT request fullName'); - } - }); - // ========== PHASE 2: SHARED USER VIEWS PROFILE ========== - // Step 10: Switch to shared user authentication and go directly to Profile - await base_1.test.step('When shared user views claimed user profile', async () => { - await account_helpers_1.test.account.switchUser('shared', page); - await page.goto('/data'); - await patient_helpers_1.test.patient.setup(page); - // Wait a moment for the page to stabilize after user switch - await page.waitForTimeout(500); - // Navigate directly to Profile in the same step to avoid redundancy - await patient_helpers_1.test.patient.navigateTo('Profile', page); - }); - // Step 11: Verify Edit button is not present for shared users - await base_1.test.step('Then Edit button should not be present for shared patients', async () => { - const profilePage = new ProfilePage_1.ProfilePage(page); - await profilePage.editButtonDisplays(false); - }); - // Step 12: Validate shared user sees updated profile data - await base_1.test.stepNoScreenshot('Then shared user sees view-only claimed profile data with matching data', async () => { - await api.compareEndpointResponse('profile-metadata-get', putCapture); - }); - // ========== PHASE 3: CLINICIAN VIEWS PROFILE ========== - // Step 13: Switch to clinician user authentication - await base_1.test.step('When clinician accesses patient workspace', async () => { - await account_helpers_1.test.account.switchUser('clinician', page); - await page.goto('/'); - await clinic_helpers_1.test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); - }); - // Step 14: Access the specific claimed patient that was modified by the producer test - await base_1.test.step('When user accesses the claimed patient modified by producer test', async () => { - await clinic_helpers_1.test.clinician.findAndAccessPatientByPartialName(CLAIMED_PATIENT_SEARCH, page); - // Navigate directly to Profile in the same step to avoid redundancy - await clinic_helpers_1.test.clinician.navigateTo('Profile', page); - }); - // Step 15: Verify Edit button is not present for claimed patients viewed by clinicians - await base_1.test.step('Then Edit button should not be present for claimed patients', async () => { - const profilePage = new ProfilePage_1.ProfilePage(page); - await profilePage.editButtonDisplays(false); - }); - // Step 16: Validate clinician sees updated profile data - await base_1.test.stepNoScreenshot('Then clinician sees claimed profile data with matching data and no save access', async () => { - await api.compareEndpointResponse('profile-metadata-get', putCapture); - }); - }); -}); diff --git a/build/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js b/build/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js deleted file mode 100644 index 7847f31..0000000 --- a/build/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js +++ /dev/null @@ -1,159 +0,0 @@ -"use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -Object.defineProperty(exports, "__esModule", { value: true }); -const base_1 = require("../../fixtures/base"); -const patient_helpers_1 = require("../../fixtures/patient-helpers"); -const clinic_helpers_1 = require("../../fixtures/clinic-helpers"); -const account_helpers_1 = require("../../fixtures/account-helpers"); -const network_helpers_1 = require("../../fixtures/network-helpers"); -const test_tags_1 = require("../../fixtures/test-tags"); -const ProfilePage_1 = require("../../../page-objects/patient/ProfilePage"); -const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; -const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; -base_1.test.describe('Comprehensive Profile Access Test: Edit as Claimed, View as Shared and Clinician', () => { - (0, base_1.test)('should edit claimed profile then verify view-only access for shared and clinician users', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.PATIENT, // User Type (required) - test_tags_1.TEST_TAGS.CLINICIAN, // User Type (required) - test_tags_1.TEST_TAGS.CLAIMED, - test_tags_1.TEST_TAGS.SHARED_MEMBER, - test_tags_1.TEST_TAGS.API, // Test Type (required) - test_tags_1.TEST_TAGS.UI, // Test Type (required) - test_tags_1.TEST_TAGS.HIGH, // Priority (required) - test_tags_1.TEST_TAGS.API_PROFILE, // Feature (optional) - ]), - }, async ({ page }) => { - let api; - let producerPutCapture; - // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== - // Step 1: Claimed account has been logged in - await base_1.test.step('Given claimed account has been logged in', async () => { - api = (0, network_helpers_1.createNetworkHelper)(page); - await api.startCapture(); - await page.goto('/data'); - await patient_helpers_1.test.patient.setup(page); - }); - // Step 2: User navigates to Profile page - await base_1.test.step('When user navigates to Profile page', async () => { - await patient_helpers_1.test.patient.navigateTo('Profile', page); - }); - // Step 3: GET response is pulled and validated - await base_1.test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - // Step 4: Confirm edit button and click it - await base_1.test.step('When user selects Edit button', async () => { - await patient_helpers_1.test.patient.navigateTo('ProfileEdit', page); - }); - // Initialize ProfilePage for steps 4 and 5 - const profilePage = new ProfilePage_1.ProfilePage(page); - // Step 5: Change profile fields (confirmed user access) - await base_1.test.step('When user updates profile fields', async () => { - const testRunId = Math.floor(Math.random() * 10000); - const updatedName = `Claimed User Updated ${testRunId}`; - const birthYear = 1985 + (testRunId % 10); - const diagnosisYear = birthYear + 20; - const birthDate = `01/15/${birthYear}`; - const diagnosisDate = `03/10/${diagnosisYear}`; - // Generate random 15-letter string for clinical notes - const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); - // Get current diagnosis index and calculate next one (1-7, wrapping) - const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); - let nextDiagnosisIndex = currentDiagnosisIndex + 1; - if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { - nextDiagnosisIndex = 1; - } - // Update fields using ProfilePage methods - await profilePage.fillFullName(updatedName); - await profilePage.fillBirthDate(birthDate); - await profilePage.fillDiagnosisDate(diagnosisDate); - await profilePage.selectDiagnosisType(nextDiagnosisIndex); - await profilePage.fillClinicalNotes(randomString); - }); - // Step 6: Save profile edit - await base_1.test.step('When user saves profile changes', async () => { - await profilePage.saveProfile(); - }); - // Step 7: PUT response is validated and saved for comparison - await base_1.test.stepNoScreenshot('Then profile endpoint responds with PUT request consistent with schema', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - const putSchema = await Promise.resolve().then(() => __importStar(require('../../../endpoint-schema/profile-endpoints'))); - const schema = putSchema.putProfileMetadataSchema; - producerPutCapture = api.getLatestCaptureMatching(schema.method, schema.url); - }); - //= ========= SHARED MEMEBER VIEWS PROFILE ========== - // Step 8: Switch to shared user authentication - await base_1.test.step('When shared user views claimed user profile', async () => { - await account_helpers_1.test.account.switchUser('shared', page); - await page.goto('/data'); - await patient_helpers_1.test.patient.navigateTo('ViewData', page); - }); - // Step 9: Navigate to profile page - await base_1.test.step('When user navigates to Profile page', async () => { - await patient_helpers_1.test.patient.navigateTo('Profile', page); - }); - // Step 10: Confirm edit button is not present - await base_1.test.step('Then Edit button should not be present for shared patients', async () => { - await profilePage.editButtonDisplays(false); - }); - // Step 11: Validate GET response and compare it against the - await base_1.test.stepNoScreenshot('Then shared user sees view-only claimed profile data with matching data', async () => { - await api.compareEndpointResponse('profile-metadata-get', producerPutCapture); - }); - // ========== CLINICIAN VIEWS PROFILE ========== - // Step 12: Switch to clinician authentication and navigate to patient profile - await base_1.test.step('When clinician accesses patient workspace', async () => { - await account_helpers_1.test.account.switchUser('clinician', page); - await page.goto('/'); - await clinic_helpers_1.test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); - }); - // Step 13: Access the specific claimed patient that was modified by the producer test - await base_1.test.step('When user accesses the claimed patient modified by producer test', async () => { - await clinic_helpers_1.test.clinician.findAndAccessPatientByPartialName(CLAIMED_PATIENT_SEARCH, page); - }); - // Step 14: Navigate to profile - await base_1.test.step('When user navigates to Profile page', async () => { - await clinic_helpers_1.test.clinician.navigateTo('Profile', page); - }); - // Step 15: Confirm edit button is not present - await base_1.test.step('Then Edit button should not be present for claimed patients', async () => { - await profilePage.editButtonDisplays(false); - }); - // Step 16: Validate GET response and confirm appropriate permissions - await base_1.test.stepNoScreenshot('Then clinician sees claimed profile data with matching data and no save access', async () => { - await api.compareEndpointResponse('profile-metadata-get', producerPutCapture); - }); - }); -}); diff --git a/build/tests/claimed/API-User/claimed-email-edit.spec.js b/build/tests/claimed/API-User/claimed-email-edit.spec.js deleted file mode 100644 index 4076621..0000000 --- a/build/tests/claimed/API-User/claimed-email-edit.spec.js +++ /dev/null @@ -1,95 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const base_1 = require("../../fixtures/base"); -const patient_helpers_1 = require("../../fixtures/patient-helpers"); -const account_helpers_1 = require("../../fixtures/account-helpers"); -const network_helpers_1 = require("../../fixtures/network-helpers"); -const test_tags_1 = require("../../fixtures/test-tags"); -const AccountSettingsPage_1 = require("../../../page-objects/account/AccountSettingsPage"); -base_1.test.describe('Clinician Account Settings Access', () => { - // API Test cases require this to capture network activity - let api; - (0, base_1.test)('should allow navigation to account settings and capture GET response', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.PATIENT, - test_tags_1.TEST_TAGS.CLAIMED, - test_tags_1.TEST_TAGS.API, - test_tags_1.TEST_TAGS.UI, - test_tags_1.TEST_TAGS.HIGH, - test_tags_1.TEST_TAGS.API_USER, - ]), - }, async ({ page }) => { - // Step 1: Log in to clinician account and setup network capture - await base_1.test.step('Given clinician has been logged in', async () => { - api = (0, network_helpers_1.createNetworkHelper)(page); - await api.startCapture(); - await page.goto('/data'); - await patient_helpers_1.test.patient.setup(page); - }); - // Step 2: Navigate to account settings - await base_1.test.step('When user navigates to account settings', async () => { - await account_helpers_1.test.account.navigateTo('AccountSettings', page); - }); - // Step 3: Validate profile GET response - await base_1.test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema ', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - // Setup for Account Settings page and previous email for reset - const accountSettingsPage = new AccountSettingsPage_1.AccountSettingsPage(page); - let originalEmail = ''; - // Step 4: Read and change email field to temporary value - await base_1.test.step('When user updates the email field', async () => { - originalEmail = await accountSettingsPage.emailInput.inputValue(); - await accountSettingsPage.emailInput.fill('qa+TempEdit@tidepool.org'); - }); - // Step 5: Tap the save button - await base_1.test.step('When user taps the save button', async () => { - await accountSettingsPage.saveButton.click(); - }); - // Step 6: Confirm save changes message displays - await base_1.test.step('Then the save changes message displays', async () => { - await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); - }); - // Step 7: Validate PUT request and email value - await base_1.test.stepNoScreenshot('Then PUT request is validated and email is set to new value', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - const putCapture = api - .getCaptures() - .find((req) => req.method === 'PUT' && req.url.includes('/profile')); - if (!putCapture) - throw new Error('No PUT /profile request captured'); - if (!putCapture.requestBody || - !putCapture.requestBody.email || - putCapture.requestBody.email !== 'qa+TempEdit@tidepool.org') { - throw new Error('PUT request did not set email to qa+TempEdit@tidepool.org'); - } - }); - // Step 8: Change email field to temporary value - await base_1.test.step('When user sets the email field to the previous value', async () => { - await accountSettingsPage.emailInput.fill(originalEmail); - }); - // Step 9: Tap the save button - await base_1.test.step('When user taps the save button', async () => { - await accountSettingsPage.saveButton.click(); - }); - // Step 10: Confirm save changes message displays - await base_1.test.step('Then the save changes message displays', async () => { - await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); - }); - // Step 7: Validate PUT request and email value - await base_1.test.stepNoScreenshot('Then PUT request is validated and email is set to new value', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - const putCapture = api - .getCaptures() - .find((req) => req.method === 'PUT' && req.url.includes('/profile')); - if (!putCapture) - throw new Error('No PUT /profile request captured'); - if (!putCapture.requestBody || - !putCapture.requestBody.email || - putCapture.requestBody.email !== originalEmail) { - throw new Error('PUT request did not set email to originalEmail'); - } - }); - await api.stopCapture(); - }); -}); diff --git a/build/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js b/build/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js deleted file mode 100644 index d6f79c7..0000000 --- a/build/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js +++ /dev/null @@ -1,91 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const clinic_helpers_1 = require("../../fixtures/clinic-helpers"); -const network_helpers_1 = require("../../fixtures/network-helpers"); -const test_tags_1 = require("../../fixtures/test-tags"); -const ProfilePage_1 = require("../../../page-objects/patient/ProfilePage"); -clinic_helpers_1.test.describe('Custodial patients are allowed access and modification of profile details', () => { - // Define the workspace and patient at top level - const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; - const CUSTODIAL_PATIENT_SEARCH = 'Custodial Patient'; - // API Test cases require this to capture network activity - let api; - (0, clinic_helpers_1.test)('should allow navigation to profile details and edit profile fields', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.CLINICIAN, // User Type (required) - test_tags_1.TEST_TAGS.API, // Test Type (required) - test_tags_1.TEST_TAGS.UI, // Test Type (required) - test_tags_1.TEST_TAGS.HIGH, // Priority (required) - test_tags_1.TEST_TAGS.API_PROFILE, // Feature (optional) - ]), - }, async ({ page }, testInfo) => { - // Step 1: Log in to clinician account and setup network capture - await clinic_helpers_1.test.step('Given clinician has been logged in', async () => { - api = (0, network_helpers_1.createNetworkHelper)(page); - await api.startCapture(); - await clinic_helpers_1.test.clinician.setup(page); - }); - // Step 2: Navigate to workspace - await clinic_helpers_1.test.step('When user navigates to desired workspace', async () => { - await clinic_helpers_1.test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); - }); - // Step 3: Access custodial patient - await clinic_helpers_1.test.step('When user accesses a custodial patient summary', async () => { - await clinic_helpers_1.test.clinician.findAndAccessPatientByPartialName(CUSTODIAL_PATIENT_SEARCH, page); - }); - // Step 4: Navigate to profile - await clinic_helpers_1.test.step('When user navigates to Profile page', async () => { - await clinic_helpers_1.test.clinician.navigateTo('Profile', page); - }); - // Step 5: Capture GET response - await clinic_helpers_1.test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - // Step 6: Open Edit Profile - await clinic_helpers_1.test.step('When user selects Edit button', async () => { - await clinic_helpers_1.test.clinician.navigateTo('ProfileEdit', page); - }); - // Create Profile page for following steps - const profilePage = new ProfilePage_1.ProfilePage(page); - // Step 7: Change profile fields (custodial access) - await clinic_helpers_1.test.step('When user updates profile fields', async () => { - // Generate completely unique values for this custodial test run - const randomSeed = Math.random(); - const randomId = Math.floor(randomSeed * 10000); - const updatedName = `Custodial Patient Updated ${Math.floor(randomId * 10000)}`; - const birthYear = 1980 + (randomId % 15); - const diagnosisYear = birthYear + 25; - const birthDate = `05/20/${birthYear}`; - const diagnosisDate = `08/15/${diagnosisYear}`; - // Generate random 15-digit MRN - const randomMRN = Array.from({ length: 15 }, () => Math.floor(Math.random() * 10).toString()).join(''); - // Generate random 15-letter string for clinical notes - const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); - // Generate unique email - const email = `webuiautomation+custodialEdit${randomId}@tidepool.org`; - // Get current diagnosis index and calculate next one (1-7, wrapping) - const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); - let nextDiagnosisIndex = currentDiagnosisIndex + 1; - if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { - nextDiagnosisIndex = 1; - } - // Update fields using ProfilePage methods - await profilePage.fillFullName(updatedName); - await profilePage.fillBirthDate(birthDate); - await profilePage.fillMRN(randomMRN); - await profilePage.fillDiagnosisDate(diagnosisDate); - await profilePage.selectDiagnosisType(nextDiagnosisIndex); - await profilePage.fillEmail(email); - await profilePage.fillClinicalNotes(randomString); - }); - // Step 8: Save profile edit - await clinic_helpers_1.test.step('When user saves profile changes', async () => { - await profilePage.saveProfile(); - }); - // Step 9: Check profile PUT response - await clinic_helpers_1.test.step('Then profile endpoint responds with PUT request consistent with schema [no-screenshot]', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - }); - await api.stopCapture(); - }); -}); diff --git a/build/tests/clinician/add-patient.spec.js b/build/tests/clinician/add-patient.spec.js deleted file mode 100644 index 595caf8..0000000 --- a/build/tests/clinician/add-patient.spec.js +++ /dev/null @@ -1,38 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const base_1 = require("@fixtures/base"); -const ClinicianDashboardPage_1 = __importDefault(require("@pom/clinician/ClinicianDashboardPage")); -const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); -base_1.test.describe('Add new patient', () => { - // Use a unique patient name for each test run to avoid collisions - const patientName = `Test Patient Playwright ${Date.now()}`; - const patientBirthdate = '01/01/1990'; - base_1.test.beforeEach(async () => { - await base_1.test.step('Given user has been logged in and navigated to base URL', async () => { }); - }); - (0, base_1.test)('should successfully add a new patient', async ({ page }) => { - const workspacesPage = new WorkspacesPage_1.default(page); - const clinicWorkspacePage = new ClinicianDashboardPage_1.default(page); - await base_1.test.step('Given the user is on the workspaces page', async () => { - await workspacesPage.goto(); - await workspacesPage.header.waitFor({ state: 'visible' }); - }); - await base_1.test.step('When user selects the first workspace', async () => { - await workspacesPage.visitFirstClinic(); - await clinicWorkspacePage.waitForLoadState(); // Wait for clinic page elements - }); - await base_1.test.step('When user adds a new patient via dialog', async () => { - await clinicWorkspacePage.openAndFillAddPatientDialog(patientName, patientBirthdate); - await clinicWorkspacePage.submitAddPatientDialog(); - await clinicWorkspacePage.closeBringDataDialog(); - }); - await base_1.test.step('Then the new patient should appear in the patient list', async () => { - await clinicWorkspacePage.searchForPatient(patientName); - const patientCell = clinicWorkspacePage.getPatientCellByName(patientName); - await (0, base_1.expect)(patientCell).toBeVisible(); - }); - }); -}); diff --git a/build/tests/clinician/create-clinic-workspace.spec.js b/build/tests/clinician/create-clinic-workspace.spec.js deleted file mode 100644 index c6fd99f..0000000 --- a/build/tests/clinician/create-clinic-workspace.spec.js +++ /dev/null @@ -1,86 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const base_1 = require("@fixtures/base"); -const ClinicCreationPage_1 = __importDefault(require("@pom/clinician/ClinicCreationPage")); -const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); -const node_crypto_1 = require("node:crypto"); -base_1.test.describe('Create clinic workspace', () => { - const uniqueSuffix = (0, node_crypto_1.randomUUID)().substring(0, 8); - const clinicName = `Test Clinic ${uniqueSuffix}`; - let workspacesPage; - let clinicCreationPage; - base_1.test.beforeEach(async ({ page }) => { - workspacesPage = new WorkspacesPage_1.default(page); - clinicCreationPage = new ClinicCreationPage_1.default(page); - }); - (0, base_1.test)('should successfully create a new clinic workspace', async ({ page }) => { - await base_1.test.step('Given user is on the workspaces page', async () => { - await workspacesPage.goto(); - await (0, base_1.expect)(workspacesPage.header).toBeVisible(); - await (0, base_1.expect)(workspacesPage.createClinicButton).toBeVisible(); - }); - await base_1.test.step("When user clicks on the 'Create a New Clinic' button", async () => { - await workspacesPage.createClinicButton.click(); - // Wait for the clinic details page to load - await (0, base_1.expect)(page).toHaveURL(/clinic-details\/new/); - await (0, base_1.expect)(clinicCreationPage.pageHeader).toBeVisible(); - }); - await base_1.test.step('When user fills in all the required clinic information', async () => { - // Fill the clinic form with test data - await clinicCreationPage.fillClinicForm({ - clinicName, - teamType: 'Provider Practice', - state: 'California', - address: '123 Test Street', - city: 'Test City', - zipCode: '12345', - }); - // Verify blood glucose units (mg/dL is pre-selected) - await (0, base_1.expect)(clinicCreationPage.mgdlRadio).toBeChecked(); - // Verify the admin acknowledgment checkbox is checked - await (0, base_1.expect)(clinicCreationPage.adminAcknowledgeCheckbox).toBeChecked(); - // Verify Create Workspace button is enabled - await (0, base_1.expect)(clinicCreationPage.createWorkspaceButton).toBeEnabled(); - }); - await base_1.test.step("When user clicks on the 'Create Workspace' button", async () => { - await clinicCreationPage.createWorkspaceButton.click(); - // Wait for redirect to workspaces page - await (0, base_1.expect)(page).toHaveURL('/workspaces'); - }); - await base_1.test.step('Then user should see the new clinic in the list and a success message', async () => { - // Verify success message is shown - const successMessage = page.getByText(`"${clinicName}" clinic created`); - await (0, base_1.expect)(successMessage).toBeVisible(); - // Verify the new clinic appears in the list - const clinicHeaderLocator = page.getByRole('heading', { name: clinicName }); - await (0, base_1.expect)(clinicHeaderLocator).toBeVisible(); - // Verify the clinic has the necessary action buttons - const clinicContainer = page - .locator('.workspace-item-clinic') - .filter({ has: clinicHeaderLocator }); - await (0, base_1.expect)(clinicContainer.getByRole('button', { name: 'Leave Clinic' })).toBeVisible(); - await (0, base_1.expect)(clinicContainer.getByRole('button', { name: 'Go To Workspace' })).toBeVisible(); - }); - }); - (0, base_1.test)('should create a new clinic with the simplified createClinic method', async ({ page }) => { - // Navigate to the workspaces page - await page.goto('/workspaces'); - await (0, base_1.expect)(workspacesPage.header).toBeVisible(); - // Click the "Create a New Clinic" button - await workspacesPage.createClinicButton.click(); - await (0, base_1.expect)(page).toHaveURL(/clinic-details\/new/); - // Use the simplified method to create a clinic in one step - await clinicCreationPage.createClinic(clinicName); - // Verify we're back on the workspaces page - await (0, base_1.expect)(page).toHaveURL('/workspaces'); - // Verify the clinic was created - const successMessage = page.getByText(`"${clinicName}" clinic created`); - await (0, base_1.expect)(successMessage).toBeVisible(); - // Verify the clinic appears in the list - const clinicHeaderLocator = page.getByRole('heading', { name: clinicName }); - await (0, base_1.expect)(clinicHeaderLocator).toBeVisible(); - }); -}); diff --git a/build/tests/clinician/edit-clinic-address.spec.js b/build/tests/clinician/edit-clinic-address.spec.js deleted file mode 100644 index 0f038c1..0000000 --- a/build/tests/clinician/edit-clinic-address.spec.js +++ /dev/null @@ -1,47 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const base_1 = require("@fixtures/base"); -const WorkspaceSettingsPage_1 = __importDefault(require("@pom/clinician/WorkspaceSettingsPage")); -const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); -base_1.test.describe('Edit clinic address', () => { - const newAddress = `123 Test Street ${Date.now()}`; // Unique address for test run - let clinicAdminPage; - let workspacesPage; - base_1.test.beforeEach(async ({ page }) => { - clinicAdminPage = new WorkspaceSettingsPage_1.default(page); - workspacesPage = new WorkspacesPage_1.default(page); - await base_1.test.step('Given user has navigated to the Clinic Admin page', async () => { - await workspacesPage.goto(); - await workspacesPage.visitFirstClinic(); - await page.goto('/clinic-admin'); - await clinicAdminPage.waitForLoadState(); // Wait for clinic admin page elements - await clinicAdminPage.clinicDetailsHeader.waitFor({ state: 'visible' }); - }); - }); - (0, base_1.test)('should successfully edit the clinic address', async ({ page }) => { - await base_1.test.step('When user clicks the "Edit" button for workspace details', async () => { - await clinicAdminPage.editDetailsButton.click(); - await clinicAdminPage.editClinicModal.waitFor({ state: 'visible' }); - }); - await base_1.test.step('Then user sees the modal for Editing workspace details', async () => { - await (0, base_1.expect)(clinicAdminPage.editClinicModalTitle).toBeVisible(); - await (0, base_1.expect)(clinicAdminPage.addressInput).toBeVisible(); - }); - await base_1.test.step('When user changes the address', async () => { - await clinicAdminPage.addressInput.fill(newAddress); - }); - await base_1.test.step('When user clicks on "Save changes"', async () => { - await clinicAdminPage.saveChangesButton.click(); - await clinicAdminPage.editClinicModal.waitFor({ state: 'hidden' }); // Wait for modal to close - }); - await base_1.test.step('Then user sees the updated address on the page', async () => { - // Wait for the details section to potentially update - await page.waitForTimeout(1000); // Small wait for potential DOM update - const detailsText = clinicAdminPage.clinicDetailsSection; - await (0, base_1.expect)(detailsText).toContainText(newAddress); - }); - }); -}); diff --git a/build/tests/clinician/filter-patient.spec.js b/build/tests/clinician/filter-patient.spec.js deleted file mode 100644 index 5032ef2..0000000 --- a/build/tests/clinician/filter-patient.spec.js +++ /dev/null @@ -1,70 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const base_1 = require("@fixtures/base"); -const ClinicianDashboardPage_1 = __importDefault(require("@pom/clinician/ClinicianDashboardPage")); -const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); -base_1.test.describe('Filter patients in clinic', () => { - // Use unique patient names for each test run - const timestamp = Date.now(); - const patientName1 = `Filter Patient A ${timestamp}`; - const patientName2 = `Filter Patient B ${timestamp}`; - const patientBirthdate = '01/01/1995'; // Shared birthdate for simplicity - let workspacesPage; - let clinicWorkspacePage; - base_1.test.beforeEach(async ({ page }) => { - workspacesPage = new WorkspacesPage_1.default(page); - clinicWorkspacePage = new ClinicianDashboardPage_1.default(page); - await base_1.test.step('Given user has been logged in and navigated to base URL', async () => { - await workspacesPage.goto(); - await page.waitForURL(workspacesPage.url); - await workspacesPage.header.waitFor({ state: 'visible' }); - }); - await base_1.test.step('Given the user is on the first clinic workspace', async () => { - await workspacesPage.visitFirstClinic(); - await clinicWorkspacePage.waitForLoadState(); // Wait for clinic page elements - }); - await base_1.test.step('Given two patients exist', async () => { - // Add first patient - await clinicWorkspacePage.openAndFillAddPatientDialog(patientName1, patientBirthdate); - await clinicWorkspacePage.submitAddPatientDialog(); - await clinicWorkspacePage.closeBringDataDialog(); - // Ensure the first patient is added before adding the second - await (0, base_1.expect)(clinicWorkspacePage.getPatientCellByName(patientName1)).toBeVisible({ - timeout: 10000, - }); - // Add second patient - await clinicWorkspacePage.openAndFillAddPatientDialog(patientName2, patientBirthdate); - await clinicWorkspacePage.submitAddPatientDialog(); - await clinicWorkspacePage.closeBringDataDialog(); - // Ensure the second patient is also added - await (0, base_1.expect)(clinicWorkspacePage.getPatientCellByName(patientName2)).toBeVisible({ - timeout: 10000, - }); - }); - }); - (0, base_1.test)('should successfully filter patients by name', async () => { - await base_1.test.step("When user filters by the first patient's name", async () => { - await clinicWorkspacePage.searchForPatient(patientName1); - }); - await base_1.test.step('Then only the first patient should be visible', async () => { - const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); - const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); - await (0, base_1.expect)(patientCell1).toBeVisible(); - await (0, base_1.expect)(patientCell2).not.toBeVisible(); - }); - await base_1.test.step('When user clears the filter', async () => { - // Assuming a method like clearPatientSearch exists or searchForPatient('') clears - await clinicWorkspacePage.searchForPatient(''); // Clear search by searching for empty string - // Or potentially: await clinicWorkspacePage.clearPatientSearch(); - }); - await base_1.test.step('Then both patients should be visible again', async () => { - const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); - const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); - await (0, base_1.expect)(patientCell1).toBeVisible(); - await (0, base_1.expect)(patientCell2).toBeVisible(); - }); - }); -}); diff --git a/build/tests/fixtures/account-helpers.js b/build/tests/fixtures/account-helpers.js deleted file mode 100644 index 4532eef..0000000 --- a/build/tests/fixtures/account-helpers.js +++ /dev/null @@ -1,123 +0,0 @@ -"use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.test = void 0; -const base_1 = require("@fixtures/base"); -const AccountNavigation_1 = __importDefault(require("@pom/account/AccountNavigation")); -/** - * Switch user authentication context by loading different storageState - * @param userType - The user type corresponding to the storageState file (e.g., 'shared', 'clinician', 'claimed') - * @param page - The Playwright page instance - */ -async function switchUser(userType, page) { - try { - // Import fs dynamically - const fs = await Promise.resolve().then(() => __importStar(require('node:fs'))); - // Load the specified user's storage state - const storageStatePath = `tests/.auth/${userType}.json`; - const storageState = JSON.parse(fs.readFileSync(storageStatePath, 'utf-8')); - // Clear existing cookies first - await page.context().clearCookies(); - // Set cookies from the new user's storage state - if (storageState.cookies) { - await page.context().addCookies(storageState.cookies); - } - // Set localStorage from the new user's storage state - if (storageState.origins) { - for (const origin of storageState.origins) { - await page.addInitScript(originData => { - if (originData.localStorage) { - for (const item of originData.localStorage) { - localStorage.setItem(item.name, item.value); - } - } - }, origin); - } - } - console.log(`āœ… Successfully switched to ${userType} user authentication`); - } - catch (error) { - throw new Error(`Failed to switch to ${userType} user: ${error}`); - } -} -/** - * Core navigation function that handles account navigation consistently - */ -async function navigateTo(targetPage, page) { - const nav = new AccountNavigation_1.default(page); - const pageConfig = nav.pages[targetPage]; - try { - // Single page check at start - if (page.isClosed()) - return; - // Quick DOM ready check only - await page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => { }); - // Open navigation menu if needed (only for non-AccountNav targets) - if (targetPage !== 'AccountNav') { - const menuVisible = await nav.pages.AccountNav.verifyElement - .isVisible({ timeout: 1000 }) - .catch(() => false); - if (!menuVisible) { - await nav.pages.AccountNav.link.click(); - await nav.pages.AccountNav.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); - } - } - // Handle logout specially - if (targetPage === 'Logout') { - await pageConfig.link.click(); - await page - .waitForURL(/.*login.*/, { waitUntil: 'domcontentloaded', timeout: 5000 }) - .catch(() => { }); - } - else { - // Standard navigation - click and verify - await pageConfig.link.click(); - await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); - } - } - catch (error) { - if (!page.isClosed()) - throw error; - } -} -const test = base_1.test; -exports.test = test; -test.account = { - navigateTo, - switchUser, -}; diff --git a/build/tests/fixtures/base.js b/build/tests/fixtures/base.js deleted file mode 100644 index 2c7e91d..0000000 --- a/build/tests/fixtures/base.js +++ /dev/null @@ -1,262 +0,0 @@ -"use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -Object.defineProperty(exports, "__esModule", { value: true }); -exports.expect = exports.test = void 0; -exports.step = step; -const test_1 = require("@playwright/test"); -const fs = __importStar(require("node:fs")); -const path = __importStar(require("node:path")); -// Define the test type with custom fixtures -exports.test = test_1.test.extend({ - page: async ({ page }, use, testInfo) => { - const modifiedTestInfo = testInfo; - modifiedTestInfo.snapshotSuffix = ''; - modifiedTestInfo.snapshotPath = name => `${testInfo.file}-snapshots/${name}`; - // Make testInfo globally available for network helpers - globalThis.testInfo = testInfo; - try { - await use(page); - } - finally { - // Clean up after test - delete globalThis.testInfo; - } - }, - timeLogger: [ - async ({ page }, use, testInfo) => { - testInfo.annotations.push({ - type: 'Start', - description: new Date().toISOString(), - }); - await use(page); - testInfo.annotations.push({ - type: 'End', - description: new Date().toISOString(), - }); - }, - { auto: true }, - ], - timeStepLogger: [ - async ({ page }, use, testInfo) => { - const startTime = Date.now(); - console.time(`[test] ${testInfo.title}`); - await use(page); - console.timeEnd(`[test] ${testInfo.title}`); - const endTime = Date.now(); - const duration = endTime - startTime; - testInfo.annotations.push({ - type: 'Duration', - description: `${duration}ms`, - }); - testInfo.annotations.push({ - type: 'End', - description: new Date().toISOString(), - }); - }, - { auto: true }, - ], - stepTimer: [ - async ({ page }, use, testInfo) => { - const originalStep = exports.test.step; - const stepTimings = new Map(); - // Create a new step function with the same interface as the original - const newStep = function newStepWrapper(name, fn) { - return originalStep.call(this, name, async (stepInfo) => { - const startTime = Date.now(); - console.time(`[step] ${name}`); - const result = await fn(stepInfo); - console.timeEnd(`[step] ${name}`); - const endTime = Date.now(); - const duration = endTime - startTime; - stepTimings.set(name, duration); - testInfo.annotations.push({ - type: `Step Duration: ${name}`, - description: `${duration}ms`, - }); - return result; - }); - }; - // Add the skip method to match the original test.step interface - newStep.skip = function skipStep(name, fn) { - return originalStep.skip.call(this, name, fn); - }; - // Replace the original step with our enhanced version - exports.test.step = newStep; - await use(page); - // Restore original test.step - exports.test.step = originalStep; - }, - { auto: true }, - ], - stepScreenshoter: [ - async ({ page }, use, testInfo) => { - const originalStep = exports.test.step; - let stepCounter = 0; - // Create a safe directory name based on test info - const testDirName = path.basename(testInfo.file, '.spec.ts').replace(/[^a-z0-9]/gi, '-'); - const screenshotDir = path.join('test-results', testDirName); - // Store current step name for network helpers - let currentStepName = ''; - // Make step counter accessible globally for network helper - globalThis.stepCounter = { - get: () => stepCounter, - increment: () => { - stepCounter += 1; - return stepCounter; - }, - getDirectory: () => screenshotDir, - getCurrentStepName: () => currentStepName, - setCurrentStepName: (name) => { - currentStepName = name; - }, - }; - // Clean up existing screenshots from previous runs - try { - await fs.promises.access(screenshotDir); - await fs.promises.rm(screenshotDir, { recursive: true, force: true }); - } - catch { - // Directory doesn't exist, no need to clean up - } - // Create a new step function that takes screenshots after completion and attaches them to the report - const newStep = function newStepScreenshot(name, fn) { - return originalStep.call(this, name, async (stepInfo) => { - // Set current step name for network helpers (clean name without [no-screenshot]) - const stepCounterObj = globalThis.stepCounter; - if (stepCounterObj) { - const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); - stepCounterObj.setCurrentStepName(cleanName); - } - const result = await fn(stepInfo); - // Skip screenshot if step name contains [no-screenshot] - if (name.includes('[no-screenshot]')) { - return result; - } - // Take screenshot after step completion - stepCounter += 1; - try { - if (!page.isClosed()) { - // Use clean name for filename (without [no-screenshot]) - const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); - const screenshotName = `step-${stepCounter.toString().padStart(2, '0')}-${cleanName.toLowerCase().replace(/[^a-z0-9]/g, '-')}.png`; - // Take screenshot directly to buffer (no local file) - const screenshot = await page.screenshot({ - fullPage: true, - }); - // Attach to Playwright report AND force test-results folder creation - if (testInfo && typeof testInfo.attach === 'function') { - await testInfo.attach(screenshotName, { - body: screenshot, - contentType: 'image/png', - }); - // Also save to test-results for organized viewing (single source) - const testResultsDir = path.join(testInfo.outputDir, 'attachments'); - await fs.promises.mkdir(testResultsDir, { recursive: true }); - const screenshotPath = path.join(testResultsDir, screenshotName); - await fs.promises.writeFile(screenshotPath, screenshot); - } - } - } - catch (error) { - // Screenshot capture failed, continue without screenshot - } - return result; - }); - }; - // Add the skip method to match the original test.step interface - newStep.skip = function skipStepScreenshot(name, fn) { - return originalStep.skip.call(this, name, fn); - }; - // Add a custom stepNoScreenshot function for API validation steps - const stepNoScreenshot = function stepNoScreenshot(name, fn) { - return originalStep.call(this, name, async (stepInfo) => { - // Set current step name for network helpers (clean name) - const stepCounterObj = globalThis.stepCounter; - if (stepCounterObj) { - stepCounterObj.setCurrentStepName(name); - } - const result = await fn(stepInfo); - // No screenshot taken for this step type - // console.log(`ā­ļø API step completed without screenshot: ${name}`); - return result; - }); - }; - // Replace the original step with our enhanced version - exports.test.step = newStep; - // Add the no-screenshot step function to the test object - exports.test.stepNoScreenshot = stepNoScreenshot; - await use(page); - // Restore original test.step - exports.test.step = originalStep; - }, - { auto: true }, - ], - exceptionLogger: [ - async ({ page }, use, testInfo) => { - const errors = []; - page.on('pageerror', (error) => { - errors.push(error); - }); - await use(page); - if (errors.length > 0) { - await testInfo.attach('frontend-exceptions', { - body: errors.map(error => `${error.message}\n${error.stack}`).join('\n---------\n'), - }); - throw new Error('Some frontend exceptions occurred'); - } - }, - { auto: true }, - ], -}); -var test_2 = require("@playwright/test"); -Object.defineProperty(exports, "expect", { enumerable: true, get: function () { return test_2.expect; } }); -/** - * Decorator function for wrapping POM methods in a test.step. - * - * Use it without a step name `@step()`. - * - * Or with a step name `@step("Search something")`. - * - * @param stepName - The name of the test step. - * @returns A decorator function that can be used to decorate test methods. - */ -function step(stepName) { - return function decorator(target, context) { - return function replacementMethod(...args) { - const name = `${stepName || context.name} (${this.name})`; - return exports.test.step(name, async () => await target.call(this, ...args)); - }; - }; -} diff --git a/build/tests/fixtures/clinic-helpers.js b/build/tests/fixtures/clinic-helpers.js deleted file mode 100644 index b328d86..0000000 --- a/build/tests/fixtures/clinic-helpers.js +++ /dev/null @@ -1,280 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.test = void 0; -const base_1 = require("@fixtures/base"); -const ClinicianNavigation_1 = __importDefault(require("../../page-objects/clinician/ClinicianNavigation")); -const ClinicianDashboardPage_1 = __importDefault(require("../../page-objects/clinician/ClinicianDashboardPage")); -const AccountNavigation_1 = __importDefault(require("../../page-objects/account/AccountNavigation")); -/** - * Initialize clinician navigation helpers after login - */ -async function setupClinicianSession(page) { - // Wait for clinician navigation to be available - const nav = new ClinicianNavigation_1.default(page); - // Navigate to login and setup clinic session if needed - if (!page.url().includes('clinic-workspace')) { - await page.goto('/login'); - // Add any necessary login steps here - } - console.log('šŸ„ Clinic session setup complete'); - return nav; -} -/** - * Navigate to workspace selection page - */ -async function navigateToWorkspaceSelection(page) { - const accountNav = new AccountNavigation_1.default(page); - // Open the account navigation menu first - await accountNav.pages.AccountNav.link.click(); - // Then click the ManageWorkspaces option - await accountNav.pages.ManageWorkspaces.link.click(); - // Verify we're on the workspace selection page using the known verification element - await accountNav.pages.ManageWorkspaces.verifyElement.waitFor({ - state: 'visible', - timeout: 5000, - }); - // console.log('āœ… Navigated to workspace selection page'); -} -/** - * Navigate to a specific workspace using hardcoded workspace key - */ -async function navigateToWorkspace(workspaceKey, page) { - const clinicianNav = new ClinicianNavigation_1.default(page); - // First navigate to workspace selection if not already there - if (!page.url().includes('workspaces')) { - await navigateToWorkspaceSelection(page); - } - // Click on the specific workspace using the page object locator - await clinicianNav.workspaces[workspaceKey].link.click(); - // Verify we're in the correct workspace using URL verification - await page.waitForURL(new RegExp(clinicianNav.workspaces[workspaceKey].verifyURL), { - timeout: 5000, - }); - // console.log(`āœ… Successfully navigated to workspace: ${clinicianNav.workspaces[workspaceKey].name}`); -} -/** - * Core navigation function that handles workspace prerequisites and page navigation - */ -async function navigateTo(targetPage, page, workspaceKey) { - const clinicianNav = new ClinicianNavigation_1.default(page); - const pageConfig = clinicianNav.pages[targetPage]; - // Ensure we're in a workspace context (but don't auto-switch if already in one) - const isInWorkspaceContext = page.url().includes('clinic-workspace') || - page.url().includes('/patients/') || - page.url().includes('/profile'); - if (!isInWorkspaceContext) { - const defaultWorkspace = workspaceKey || 'AdminClinicBase'; - await navigateToWorkspace(defaultWorkspace, page); - } - else if (workspaceKey) { - // Only switch if specifically requested and we can verify we're in wrong workspace - const currentUrl = page.url(); - const targetWorkspacePattern = clinicianNav.workspaces[workspaceKey].verifyURL; - if (!currentUrl.includes(targetWorkspacePattern)) { - await navigateToWorkspace(workspaceKey, page); - } - } - // Handle page-specific prerequisites - if (targetPage === 'AddPatient') { - // AddPatient might need to be on PatientList first - if (!page.url().includes('patients')) { - await clinicianNav.pages.PatientList.link.click(); - await clinicianNav.pages.PatientList.verifyElement.waitFor({ - state: 'visible', - timeout: 5000, - }); - } - } - // Perform the actual navigation - try { - await pageConfig.link.click(); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.log(`Failed to click ${targetPage}: ${errorMessage}`); - throw error; - } - // Verify navigation succeeded - try { - if (pageConfig.verifyURL) { - await page.waitForURL(`**/*${pageConfig.verifyURL}*`, { timeout: 5000 }); - } - if (pageConfig.verifyElement) { - await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); - } - // console.log(`āœ… Navigated to page: ${targetPage}`); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - // console.log(`Navigation verification failed for ${targetPage}: ${errorMessage}`); - } -} -/** - * Execute test logic across multiple workspaces - */ -async function executeAcrossWorkspaces(workspaceConfigs, action, page) { - for (const config of workspaceConfigs) { - console.log(`šŸ”„ Executing across workspace: ${config.workspaceKey}`); - // Navigate to the workspace - await navigateToWorkspace(config.workspaceKey, page); - // Execute the action - await action(config); - // Navigate back to workspace selection for next iteration - if (workspaceConfigs.indexOf(config) < workspaceConfigs.length - 1) { - await navigateToWorkspaceSelection(page); - } - } -} -/** - * Find and access any available patient (fastest option) - * @param page - The Playwright page object - * @returns The full name of the first patient that was accessed - */ -async function findAndAccessAnyPatient(page) { - const dashboard = new ClinicianDashboardPage_1.default(page); - try { - // Clear search to show all patients - await dashboard.searchInput.click(); - await dashboard.searchInput.fill(' '); - await page.waitForTimeout(500); - await dashboard.searchInput.fill(''); - await page.waitForTimeout(1500); - let allCells = await dashboard.patientListTable.getByRole('cell').all(); - // If no cells, try pressing Enter on empty search - if (allCells.length === 0) { - await dashboard.searchInput.press('Enter'); - await page.waitForTimeout(1500); - allCells = await dashboard.patientListTable.getByRole('cell').all(); - } - // Find the first cell that looks like a patient name - for (const cell of allCells) { - const cellText = await cell.textContent(); - if (cellText && cellText.trim().length > 3 && cellText.includes(' ')) { - await cell.click(); - await page.waitForTimeout(800); - return cellText.trim(); - } - } - throw new Error('No patient names found in table'); - } - catch (error) { - throw new Error(`Failed to find any patient: ${error}`); - } -} -/** - * Find and access any patient whose name contains the search term (optimized version) - * @param searchTerm - Partial name to search for (e.g., "Custodial") - * @param page - The Playwright page object - * @returns The full name of the patient that was accessed - */ -async function findAndAccessPatientByPartialName(searchTerm, page) { - const dashboard = new ClinicianDashboardPage_1.default(page); - // If empty search term, find any available patient - if (!searchTerm || searchTerm.trim() === '') { - return findAndAccessAnyPatient(page); - } - // Strategy 1: Fill search field THEN click Show All (proven fastest method) - try { - await dashboard.searchInput.fill(searchTerm); - await page.waitForTimeout(500); - const showAllButton = page - .getByRole('button', { name: 'Show All' }) - .or(page.getByRole('button', { name: 'Show all' })) - .or(page.getByText('Show All')) - .or(page.getByText('Show all')); - if (await showAllButton.isVisible({ timeout: 1000 })) { - await showAllButton.click(); - await page.waitForTimeout(1000); - const searchResultCells = await dashboard.patientListTable.getByRole('cell').all(); - if (searchResultCells.length > 0) { - for (const cell of searchResultCells) { - const cellText = await cell.textContent(); - if (cellText && cellText.toLowerCase().includes(searchTerm.toLowerCase())) { - await cell.click(); - await page.waitForTimeout(600); - return cellText.trim(); - } - } - } - } - else { - await dashboard.searchInput.press('Enter'); - await page.waitForTimeout(1000); - const searchResultCells = await dashboard.patientListTable.getByRole('cell').all(); - if (searchResultCells.length > 0) { - for (const cell of searchResultCells) { - const cellText = await cell.textContent(); - if (cellText && cellText.toLowerCase().includes(searchTerm.toLowerCase())) { - await cell.click(); - await page.waitForTimeout(600); - return cellText.trim(); - } - } - } - } - } - catch (error) { - // Silent fallback to any patient - } - // Strategy 2: Fallback to any available patient if specific search fails - try { - return await findAndAccessAnyPatient(page); - } - catch (fallbackError) { - throw new Error(`No patient found containing "${searchTerm}" and no fallback patients available`); - } -} -/** - * Access a specific patient by name and navigate to their summary page - * @param patientName - The name of the patient to access - * @param page - The Playwright page object - */ -async function accessPatient(patientName, page) { - const dashboard = new ClinicianDashboardPage_1.default(page); - console.log(`šŸ” Searching for patient: ${patientName}`); - // Try optimized search first - await dashboard.searchForPatient(patientName); - await page.waitForTimeout(1000); // Reduced wait time - // Check if search worked - const patientCell = dashboard.getPatientCellByName(patientName); - const isVisible = await patientCell.isVisible({ timeout: 2000 }); - if (isVisible) { - console.log(`šŸ‘¤ Found patient via search: ${patientName}`); - await patientCell.click(); - await page.waitForTimeout(1000); - console.log(`āœ… Successfully accessed patient summary for: ${patientName}`); - return; - } - // If search failed, fall back to show all + find - console.log(`šŸ”„ Search failed, trying show all approach...`); - const showAllButton = page.getByRole('button', { name: 'Show All' }); - if (await showAllButton.isVisible({ timeout: 1000 })) { - await showAllButton.click(); - await page.waitForTimeout(1500); - } - // Try again after showing all - const isVisibleAfterShowAll = await patientCell.isVisible({ timeout: 2000 }); - if (isVisibleAfterShowAll) { - await patientCell.click(); - await page.waitForTimeout(1000); - // console.log(`āœ… Successfully accessed patient summary for: ${patientName}`); - return; - } - // If still not found, throw error - throw new Error(`Patient "${patientName}" not found in current workspace`); -} -const test = base_1.test; -exports.test = test; -test.clinician = { - navigateTo, - navigateToWorkspace, - navigateToWorkspaceSelection, - executeAcrossWorkspaces, - accessPatient, - findAndAccessPatientByPartialName, - findAndAccessAnyPatient, - setup: setupClinicianSession, -}; diff --git a/build/tests/fixtures/network-helpers.js b/build/tests/fixtures/network-helpers.js deleted file mode 100644 index ea7dd18..0000000 --- a/build/tests/fixtures/network-helpers.js +++ /dev/null @@ -1,480 +0,0 @@ -"use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -Object.defineProperty(exports, "__esModule", { value: true }); -exports.NetworkHelper = void 0; -exports.createNetworkHelper = createNetworkHelper; -const fs = __importStar(require("node:fs")); -const path = __importStar(require("node:path")); -const endpoint_registry_1 = require("../../endpoint-schema/endpoint-registry"); -const ENDPOINTS = { - profile: /\/data\/[^\/]+$/, // GET requests for patient data - profileUpdate: /\/data\/[^\/]+$/, // PUT requests for patient data updates - profileMetrics: /\/metrics\/thisuser\//, - profileMessage: /\/message\/notes\//, -}; -/** - * Simple network helper for API validation - */ -class NetworkHelper { - constructor(page) { - this.captures = []; - this.isCapturing = false; - this.page = page; - } - async startCapture() { - if (this.isCapturing) - return; - // Only intercept API requests we care about to avoid interfering with other requests - const apiPatterns = [ - '**/data/**', - '**/metrics/**', - '**/message/**', - '**/auth/**', - '**/v1/**', - '**/metadata/**', - '**/user/**', - '**/users/**', - '**/profile/**', - ]; - for (const pattern of apiPatterns) { - await this.page.route(pattern, async (route) => { - const request = route.request(); - try { - const response = await route.fetch(); - let requestBody; - let responseBody; - try { - requestBody = request.postDataJSON(); - } - catch { - requestBody = request.postData(); - } - try { - responseBody = await response.json(); - } - catch { - responseBody = await response.text(); - } - this.captures.push({ - url: request.url(), - method: request.method(), - requestBody, - responseBody, - statusCode: response.status(), - timestamp: Date.now(), - }); - await route.fulfill({ response }); - } - catch (error) { - // If there's an error, continue the request without handling - try { - await route.continue(); - } - catch { - // Route might already be handled, ignore - } - } - }); - } - this.isCapturing = true; - } - async stopCapture() { - if (!this.isCapturing) - return; - // Remove all API route handlers - const apiPatterns = ['**/data/**', '**/metrics/**', '**/message/**', '**/auth/**', '**/v1/**']; - for (const pattern of apiPatterns) { - await this.page.unroute(pattern); - } - this.isCapturing = false; - } - async waitForEndpoint(endpointName, method, timeout = 30000) { - const pattern = ENDPOINTS[endpointName]; - if (!pattern) { - throw new Error(`Unknown endpoint: ${endpointName}`); - } - const startTime = Date.now(); - while (Date.now() - startTime < timeout) { - const matches = this.captures.filter(capture => pattern.test(capture.url) && capture.method.toLowerCase() === method.toLowerCase()); - if (matches.length > 0) { - return matches[matches.length - 1]; // Return latest match - } - await this.page.waitForTimeout(100); - } - throw new Error(`${method} request to ${endpointName} not found within ${timeout}ms`); - } - getCaptures() { - return [...this.captures]; - } - /** - * Simple helper to validate endpoint requests by URL pattern and method - */ - validateEndpointRequests(urlPattern, method) { - return this.captures.filter(c => c.url.includes(urlPattern) && c.method === method); - } - /** - * Save all captures to a JSON file - */ - async saveCapturesTo(filename, testInfo) { - const logDir = path.join(process.cwd(), 'log'); - if (!fs.existsSync(logDir)) { - fs.mkdirSync(logDir, { recursive: true }); - } - // Create capture data - const captureData = { - timestamp: new Date().toISOString(), - totalCaptures: this.captures.length, - captures: this.captures, - }; - // Use Playwright's automatic attachment instead of manual file writing - if (testInfo && typeof testInfo.attach === 'function') { - await testInfo.attach(filename, { - body: JSON.stringify(captureData, null, 2), - contentType: 'application/json', - }); - console.log(`šŸ“„ Network captures attached to Playwright report: ${filename}`); - } - else { - console.log(`šŸ“„ Network captures ready (${this.captures.length} captures)`); - } - } - /** - * Print a summary of all captures to console - */ - printCaptureSummary() { - console.log(`\nšŸ“Š Network Capture Summary (${this.captures.length} total requests):`); - console.log('='.repeat(60)); - this.captures.forEach((capture, index) => { - const timestamp = new Date(capture.timestamp).toLocaleTimeString(); - console.log(`${index + 1}. ${capture.method} ${capture.statusCode} - ${capture.url}`); - console.log(` Time: ${timestamp}`); - if (capture.requestBody) { - console.log(` Request: ${JSON.stringify(capture.requestBody).substring(0, 100)}...`); - } - console.log(''); - }); - } - /** - * Get captures filtered by status code - */ - getCapturesByStatus(statusCode) { - return this.captures.filter(c => c.statusCode === statusCode); - } - /** - * Get the most recent capture matching method and URL pattern - */ - getLatestCaptureMatching(method, urlPattern) { - const matches = this.captures - .filter(c => c.method === method && urlPattern.test(c.url)) - .sort((a, b) => b.timestamp - a.timestamp); - return matches.length > 0 ? matches[0] : null; - } - /** - * Get all captures for a specific endpoint - */ - getCapturesForEndpoint(endpointName) { - const pattern = ENDPOINTS[endpointName]; - if (!pattern) { - throw new Error(`Unknown endpoint: ${endpointName}`); - } - return this.captures.filter(c => pattern.test(c.url)); - } - /** - * Get all captures - */ - getAllCaptures() { - return [...this.captures]; - } - /** - * Save API response as JSON attachment and to organized test-results folder - */ - async saveApiResponse(response, endpoint, method, fileName, testInfo) { - const responseData = { - _request: { - method, - endpoint, - }, - ...response, - }; - const jsonContent = JSON.stringify(responseData, null, 2); - // Attach to Playwright report AND save to organized test-results folder - if (testInfo && typeof testInfo.attach === 'function') { - await testInfo.attach(fileName, { - body: jsonContent, - contentType: 'application/json', - }); - // Also save to test-results for organized viewing (like screenshots) - const testResultsDir = path.join(testInfo.outputDir, 'attachments'); - await fs.promises.mkdir(testResultsDir, { recursive: true }); - const jsonPath = path.join(testResultsDir, fileName); - await fs.promises.writeFile(jsonPath, jsonContent, 'utf8'); - } - } - /** - * Validate and save API response for any endpoint defined in the endpoint registry - * @param endpointName - The endpoint name from the registry (e.g., 'profile-metadata-get') - * @returns The captured network request or null if not found - */ - async validateEndpointResponse(endpointName) { - const schema = (0, endpoint_registry_1.getEndpointSchema)(endpointName); - const request = this.getLatestCaptureMatching(schema.method, schema.url); - if (request?.responseBody) { - // Access the shared step counter from the stepScreenshoter fixture - const stepCounterObj = globalThis.stepCounter; - if (stepCounterObj) { - const stepNumber = stepCounterObj.increment(); - const currentStepName = stepCounterObj.getCurrentStepName(); - // Create consistent filename with step number and step name (like screenshots) - const stepNameForFile = currentStepName - ? currentStepName.toLowerCase().replace(/[^a-z0-9]/g, '-') - : endpointName.replace(/[^a-z0-9]/gi, '-'); - const fileName = `step-${stepNumber.toString().padStart(2, '0')}-${stepNameForFile}-response.json`; - await this.saveApiResponse(request.responseBody, request.url, schema.method, fileName, globalThis.testInfo); - } - } - return request; - } - /** - * Save network capture for producer/consumer test patterns - * @param endpointName - The endpoint to save - * @param testName - Name of the test (used for file naming) - * @returns The saved network capture or null - */ - async saveForDependentTests(endpointName, testName) { - const schema = (0, endpoint_registry_1.getEndpointSchema)(endpointName); - const capture = this.getLatestCaptureMatching(schema.method, schema.url); - if (capture) { - // Create step-based filename for better organization - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const stepName = testName.toLowerCase().replace(/[^a-z0-9]/g, '-'); - const fileName = `step-api-${stepName}-${endpointName.replace(/[^a-z0-9]/gi, '-')}-${timestamp}.json`; - console.log(`āœ… Saved ${endpointName} response for dependent tests`); - // Use Playwright's automatic attachment instead of file system - const { testInfo } = globalThis; - if (testInfo && typeof testInfo.attach === 'function') { - await testInfo.attach(fileName, { - body: JSON.stringify(capture, null, 2), - contentType: 'application/json', - }); - } - return capture; - } - return null; - } - /** - * Load producer test data for consumer tests - * @param testName - Name of the producer test (used for file naming) - * @returns The loaded network capture or null - */ - loadFromProducerTest(testName) { - const filePath = path.join(process.cwd(), 'log', 'test-data-pipeline', `${testName}-response.json`); - if (fs.existsSync(filePath)) { - const fileContent = fs.readFileSync(filePath, 'utf-8'); - const capture = JSON.parse(fileContent); - console.log(`āœ… Loaded ${testName} response from producer test`); - return capture; - } - throw new Error(`Producer test data not found at: ${filePath}. Please run ${testName} test first.`); - } - /** - * Validate data consistency between producer and consumer responses - * @param producerCapture - The producer test network capture - * @param consumerCapture - The consumer test network capture - * @param fieldsToValidate - Array of field paths to validate (e.g., ['fullName', 'patient.birthday']) - * @param requiredFields - Array of fields that must exist and match (defaults to common required fields) - */ - validateDataConsistency(producerCapture, consumerCapture, fieldsToValidate, requiredFields = ['fullName']) { - // Use provided fields or fall back to a basic set for backward compatibility - const defaultFields = ['fullName', 'patient.fullName', 'patient.birthday', 'email']; - const fieldsToCheck = fieldsToValidate || defaultFields; - const producerData = producerCapture.responseBody; - const consumerData = consumerCapture.responseBody; - if (!producerData || !consumerData) { - throw new Error('Missing response data for consistency validation'); - } - console.log('šŸ” Validating data consistency:'); - // Only log full data in development mode - if (process.env.VERBOSE_VALIDATION) { - console.log('Producer:', JSON.stringify(producerData, null, 2)); - console.log('Consumer:', JSON.stringify(consumerData, null, 2)); - } - else { - console.log('Producer fullName:', producerData.fullName); - console.log('Consumer fullName:', consumerData.fullName); - } - // Validate each specified field - for (const fieldPath of fieldsToCheck) { - const producerValue = this.getNestedValue(producerData, fieldPath); - const consumerValue = this.getNestedValue(consumerData, fieldPath); - // Check if this field is marked as required - const isRequired = requiredFields.includes(fieldPath); - if (isRequired) { - if (producerValue === undefined || producerValue === null) { - throw new Error(`Required field ${fieldPath} is missing in producer data`); - } - if (consumerValue === undefined || consumerValue === null) { - throw new Error(`Required field ${fieldPath} is missing in consumer data`); - } - } - // For optional fields: only validate if the field exists in producer data - // If it exists in producer, it must also exist in consumer with same value - if (producerValue !== undefined && producerValue !== null) { - // Handle array comparison - if (Array.isArray(producerValue) && Array.isArray(consumerValue)) { - if (JSON.stringify(producerValue) !== JSON.stringify(consumerValue)) { - throw new Error(`${fieldPath} mismatch - Expected: ${JSON.stringify(producerValue)}, Got: ${JSON.stringify(consumerValue)}`); - } - } - else if (producerValue !== consumerValue) { - throw new Error(`${fieldPath} mismatch - Expected: ${producerValue}, Got: ${consumerValue}`); - } - } - // If producer value doesn't exist, consumer doesn't need to have it either (optional field) - } - console.log('āœ… Data consistency validated: consumer data reflects producer changes'); - } - /** - * Helper method to get nested object values using dot notation - * @param obj - The object to search - * @param path - The dot-notation path (e.g., 'patient.birthday') - * @returns The value at the path or undefined - */ - getNestedValue(obj, propertyPath) { - return propertyPath.split('.').reduce((current, key) => current?.[key], obj); - } - /** - * Validate producer-consumer data consistency for profile endpoints - * @param producerEndpointName - The PUT endpoint name (e.g., 'profile-metadata-put') - * @param consumerEndpointName - The GET endpoint name (e.g., 'profile-metadata-get') - * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) - * @throws Error if validation fails - */ - async validateProducerConsumerData(producerEndpointName, consumerEndpointName, fieldsToValidate) { - const producerSchema = (0, endpoint_registry_1.getEndpointSchema)(producerEndpointName); - const consumerSchema = (0, endpoint_registry_1.getEndpointSchema)(consumerEndpointName); - // Use provided fields, or consumer endpoint validation fields, or producer endpoint validation fields - const validationFields = fieldsToValidate || - consumerSchema.validationFields || - producerSchema.validationFields || ['fullName', 'email']; - // Use consumer endpoint required fields, or producer endpoint required fields, or default - const requiredFields = consumerSchema.requiredFields || - producerSchema.requiredFields || ['fullName']; - const producerCapture = this.getLatestCaptureMatching(producerSchema.method, producerSchema.url); - const consumerCapture = this.getLatestCaptureMatching(consumerSchema.method, consumerSchema.url); - if (!producerCapture) { - throw new Error(`No ${producerEndpointName} capture found for producer validation`); - } - if (!consumerCapture) { - throw new Error(`No ${consumerEndpointName} capture found for consumer validation`); - } - this.validateDataConsistency(producerCapture, consumerCapture, validationFields, requiredFields); - } - /** - * Private method to validate endpoint response without generating JSON file - * @param endpointName - The endpoint name from the registry - * @returns The captured network request or null if not found - */ - validateEndpointResponseSilent(endpointName) { - const schema = (0, endpoint_registry_1.getEndpointSchema)(endpointName); - const request = this.getLatestCaptureMatching(schema.method, schema.url); - return request; - } - /** - * Complete validation workflow for a user viewing profile data - * Validates both API schema and data consistency in one call - * @param consumerEndpointName - The GET endpoint name - * @param producerCapture - The stored PUT capture from the producer - * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) - */ - async compareEndpointResponse(consumerEndpointName, producerCapture, fieldsToValidate) { - // Get the endpoint schema to determine validation fields - const consumerSchema = (0, endpoint_registry_1.getEndpointSchema)(consumerEndpointName); - // Use provided fields, or endpoint-specific fields, or fall back to basic fields - const validationFields = fieldsToValidate || - consumerSchema.validationFields || ['fullName', 'patient.fullName', 'email']; - // Use endpoint-specific required fields, or default to fullName for backward compatibility - const requiredFields = consumerSchema.requiredFields || ['fullName']; - // Validate GET response schema without generating JSON file - const consumerCapture = this.validateEndpointResponseSilent(consumerEndpointName); - if (!consumerCapture) { - throw new Error(`No compare endpoint found`); - } - if (!producerCapture) { - throw new Error('No base endpoint found'); - } - // Generate comparison JSON file similar to validateEndpointResponse - const stepCounterObj = globalThis.stepCounter; - if (stepCounterObj) { - // Increment for JSON file naming (this is correct behavior) - const stepNumber = stepCounterObj.increment(); - const currentStepName = stepCounterObj.getCurrentStepName(); - // Create comparison data object - const comparisonData = { - _comparison: { - description: `Data consistency comparison for ${consumerEndpointName}`, - timestamp: new Date().toISOString(), - fieldsValidated: validationFields, - requiredFields, - }, - original: { - url: producerCapture.url, - method: producerCapture.method, - timestamp: producerCapture.timestamp, - responseBody: producerCapture.responseBody, - }, - new: { - url: consumerCapture.url, - method: consumerCapture.method, - timestamp: consumerCapture.timestamp, - responseBody: consumerCapture.responseBody, - }, - }; - // Create consistent filename with step number and step name (like screenshots) - const stepNameForFile = currentStepName - ? currentStepName.toLowerCase().replace(/[^a-z0-9]/g, '-') - : consumerEndpointName.replace(/[^a-z0-9]/gi, '-'); - const fileName = `step-${stepNumber.toString().padStart(2, '0')}-${stepNameForFile}-comparison.json`; - // Save the comparison data using the unified approach - const { testInfo } = globalThis; - await this.saveApiResponse(comparisonData, consumerCapture.url, consumerCapture.method, fileName, testInfo); - } - // Validate data consistency using the determined validation fields and required fields - this.validateDataConsistency(producerCapture, consumerCapture, validationFields, requiredFields); - } -} -exports.NetworkHelper = NetworkHelper; -function createNetworkHelper(page) { - return new NetworkHelper(page); -} diff --git a/build/tests/fixtures/patient-helpers.js b/build/tests/fixtures/patient-helpers.js deleted file mode 100644 index b47b24c..0000000 --- a/build/tests/fixtures/patient-helpers.js +++ /dev/null @@ -1,484 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.test = void 0; -const base_1 = require("@fixtures/base"); -const PatientNavigation_1 = __importDefault(require("@pom/patient/PatientNavigation")); -const env_1 = __importDefault(require("../../utilities/env")); -/** - * Initialize patient navigation helpers after login - */ -async function setupPatientSession(page) { - // Wait for patient navigation to be available - const nav = new PatientNavigation_1.default(page); - await Promise.all([ - nav.pages.ViewData.link.waitFor({ state: 'visible' }), - nav.pages.Profile.link.waitFor({ state: 'visible' }), - ]); - return nav; -} -/** - * Close any open modal dialogs that might block navigation - */ -async function closeOpenDialogs(page) { - try { - if (page.isClosed()) - return; - // Simple and fast: just press Escape twice to close any modals - await page.keyboard.press('Escape'); - await page.keyboard.press('Escape'); - } - catch (error) { - // Ignore errors in dialog closing - they're not critical - } -} -/** - * Check if we're in a context where patient navigation is supported - */ -async function isInPatientContext(nav, page) { - try { - // Check if any patient navigation elements are visible - const patientElements = [nav.pages.ViewData.link, nav.pages.Profile.link, nav.pages.Share.link]; - for (const element of patientElements) { - if (await element.isVisible({ timeout: 1000 })) { - return true; - } - } - return false; - } - catch { - return false; - } -} -/** - * Get current page state by checking URL and visible elements - */ -async function getCurrentPageState(nav, page) { - const url = page.url(); - // Check each page in order of specificity - for (const [pageName, pageConfig] of Object.entries(nav.pages)) { - try { - if (pageConfig.verifyURL && url.includes(pageConfig.verifyURL)) { - if (pageConfig.verifyElement && - (await pageConfig.verifyElement.isVisible({ timeout: 1000 }))) { - return pageName; - } - } - } - catch { - // Continue checking other pages - } - } - return 'unknown'; -} -/** - * Navigation strategies for different page types - */ -const navigationStrategies = { - // Basic page navigation - default: [ - { - name: 'close-dialogs', - action: async (state) => closeOpenDialogs(state.page), - }, - { - name: 'check-patient-context', - condition: async (state) => !(await isInPatientContext(state.nav, state.page)), - action: async (state) => { - console.log('Not in patient context, navigating to /data URL to reset'); - // Navigate to /data endpoint specifically, not just base URL - await state.page.goto(`${env_1.default.BASE_URL}/data`); - await state.page.waitForLoadState('domcontentloaded'); - // Wait for patient navigation to be available - await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); - console.log('Successfully reset to patient context via /data URL'); - }, - }, - { - name: 'wait-for-loading', - action: async (state) => { - const loading = state.page.getByText('Loading...', { exact: true }); - try { - await loading.waitFor({ state: 'hidden', timeout: 3000 }); - } - catch { - // Loading might not be visible - } - }, - }, - { - name: 'navigate-click', - action: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - await pageConfig.link.click({ timeout: 5000 }); - }, - }, - { - name: 'verify-navigation', - verify: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - if (pageConfig.verifyElement) { - try { - await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); - return true; - } - catch { - return false; - } - } - return true; - }, - }, - ], - // Profile page - handle account settings conflict - Profile: [ - { - name: 'close-dialogs', - action: async (state) => closeOpenDialogs(state.page), - }, - { - name: 'check-patient-context', - condition: async (state) => !(await isInPatientContext(state.nav, state.page)), - action: async (state) => { - console.log('Not in patient context, navigating to /data URL to reset'); - // Navigate to /data endpoint specifically, not just base URL - await state.page.goto(`${env_1.default.BASE_URL}/data`); - await state.page.waitForLoadState('domcontentloaded'); - // Wait for patient navigation to be available - await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); - console.log('Successfully reset to patient context via /data URL'); - }, - }, - { - name: 'handle-account-settings-conflict', - condition: async (state) => state.page.url().includes('/profile') && - (await state.page - .getByRole('heading', { name: /account/i }) - .or(state.page.getByText('Account Settings')) - .or(state.page.getByText('Account')) - .or(state.page.locator('.profile-subnav-title').getByText('Account')) - .isVisible() - .catch(() => false)), - action: async (state) => { - console.log('On account settings page, redirecting to base URL first'); - await state.page.goto(env_1.default.BASE_URL); - await state.page.waitForTimeout(500); - }, - }, - { - name: 'navigate-click', - action: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - await pageConfig.link.click({ timeout: 5000 }); - }, - }, - { - name: 'verify-navigation', - verify: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - if (pageConfig.verifyElement) { - try { - await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); - return true; - } - catch { - return false; - } - } - return true; - }, - }, - ], - // Modal dialogs - modal: [ - { - name: 'close-dialogs', - action: async (state) => closeOpenDialogs(state.page), - }, - { - name: 'navigate-click', - action: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - await pageConfig.link.click({ timeout: 5000 }); - }, - }, - { - name: 'wait-for-modal', - action: async (state) => { - await state.page.waitForTimeout(500); - }, - }, - ], - // Data pages that need ViewData prerequisite - 'data-page': [ - { - name: 'close-dialogs', - action: async (state) => closeOpenDialogs(state.page), - }, - { - name: 'ensure-data-view', - condition: async (state) => !state.page.url().includes('/data/'), - action: async (state) => { - await state.nav.pages.ViewData.link.click(); - await state.nav.pages.ViewData.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); - }, - }, - { - name: 'navigate-click', - action: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - await pageConfig.link.click({ timeout: 5000 }); - }, - }, - { - name: 'verify-navigation', - verify: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - if (pageConfig.verifyElement) { - try { - await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); - return true; - } - catch { - return false; - } - } - return true; - }, - }, - ], - // ShareData requires Share main page to be accessible first - ShareData: [ - { - name: 'close-dialogs', - action: async (state) => closeOpenDialogs(state.page), - }, - { - name: 'check-patient-context', - condition: async (state) => !(await isInPatientContext(state.nav, state.page)), - action: async (state) => { - console.log('Not in patient context, navigating to /data URL to reset'); - await state.page.goto(`${env_1.default.BASE_URL}/data`); - await state.page.waitForLoadState('domcontentloaded'); - await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); - console.log('Successfully reset to patient context via /data URL'); - }, - }, - { - name: 'ensure-share-dependency', - action: async (state) => { - // First ensure Share main page is accessible - try { - await state.nav.pages.Share.link.waitFor({ state: 'visible', timeout: 3000 }); - console.log('Share dependency met - Share button is accessible'); - } - catch { - console.log('Share dependency not met - performing URL reset to /data'); - await state.page.goto(`${env_1.default.BASE_URL}/data`); - await state.page.waitForLoadState('domcontentloaded'); - await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); - console.log('URL reset completed, Share dependency should now be available'); - } - }, - }, - { - name: 'navigate-to-share-first', - action: async (state) => { - // Navigate to Share main page first to establish context - try { - await state.nav.pages.Share.link.click({ timeout: 3000 }); - await state.nav.pages.Share.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); - console.log('Successfully navigated to Share main page'); - } - catch { - console.log('Could not reach Share main page, staying in current state'); - } - }, - }, - { - name: 'navigate-to-sharedata', - action: async (state) => { - // Now try to navigate to ShareData sub-page - try { - await state.nav.pages.ShareData.link.click({ timeout: 5000 }); - console.log('Successfully clicked ShareData button'); - } - catch { - console.log('ShareData button not available - this is expected and OK'); - } - }, - }, - { - name: 'verify-navigation', - verify: async (state) => { - // Try to verify ShareData first, fall back to Share if not available - try { - await state.nav.pages.ShareData.verifyElement.waitFor({ - state: 'visible', - timeout: 3000, - }); - console.log('āœ… ShareData page verified'); - return true; - } - catch { - try { - await state.nav.pages.Share.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); - console.log('āœ… Share main page verified (ShareData not available - this is OK)'); - return true; - } - catch { - console.log('Neither ShareData nor Share page could be verified'); - return false; - } - } - }, - }, - ], -}; -/** - * Page type mappings to determine which strategy to use - */ -const pageStrategies = { - ViewData: 'default', - Basics: 'data-page', - Daily: 'data-page', - BGLog: 'data-page', - Trends: 'data-page', - Devices: 'data-page', - Profile: 'Profile', - ProfileEdit: 'default', // TODO: Add prerequisite logic - Share: 'default', - ShareData: 'ShareData', // Uses dependency-aware strategy - UploadData: 'default', - ChartDateRange: 'modal', - ChartDate: 'modal', - Print: 'modal', -}; -/** - * Execute navigation strategy - */ -async function executeNavigationStrategy(state) { - const strategyName = pageStrategies[state.targetPage] || 'default'; - const strategy = navigationStrategies[strategyName]; - console.log(`Executing ${strategyName} strategy for ${state.targetPage}`); - for (const step of strategy) { - try { - // Check condition if present - if (step.condition && !(await step.condition(state))) { - console.log(`Skipping step ${step.name} - condition not met`); - // eslint-disable-next-line no-continue - continue; - } - console.log(`Executing step: ${step.name}`); - // Execute action if present - if (step.action) { - await step.action(state); - } - // Verify if present - if (step.verify && !(await step.verify(state))) { - console.log(`Step ${step.name} verification failed`); - return false; - } - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.log(`Step ${step.name} failed:`, errorMessage); - return false; - } - } - return true; -} -/** - * New scalable navigation function using state machine approach - */ -async function navigateTo(targetPage, page) { - if (page.isClosed()) { - console.log(`Page is closed, cannot navigate to ${targetPage}`); - return; - } - const nav = new PatientNavigation_1.default(page); - const currentPage = await getCurrentPageState(nav, page); - const state = { - currentPage, - targetPage, - nav, - page, - }; - console.log(`Navigating from ${currentPage} to ${targetPage}`); - // Execute primary navigation strategy - const success = await executeNavigationStrategy(state); - if (!success) { - console.log(`Primary navigation failed, trying fallback strategies`); - // Fallback strategy - go to base URL and try again - if (targetPage === 'Profile') { - try { - console.log('Profile fallback: going to base URL and trying again'); - await page.goto(env_1.default.BASE_URL); - await page.waitForTimeout(500); - await nav.pages[targetPage].link.click({ timeout: 3000 }); - console.log(`Successfully navigated to ${targetPage} via fallback`); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.log(`Profile fallback failed: ${errorMessage}`); - throw error; - } - } - else if (nav.pages[targetPage].verifyURL) { - // Generic URL fallback for pages with backup URLs - try { - let fallbackURL = env_1.default.BASE_URL; - // For sub-pages that might not be available, fall back to the main page - if (targetPage === 'ShareData') { - fallbackURL = `${env_1.default.BASE_URL}/share`; // Fall back to main Share page - } - else if (targetPage === 'ProfileEdit') { - fallbackURL = `${env_1.default.BASE_URL}/profile`; // Fall back to main Profile page - } - else if (['Basics', 'Daily', 'BGLog', 'Trends', 'Devices'].includes(targetPage)) { - fallbackURL = `${env_1.default.BASE_URL}/data`; // Fall back to main ViewData page - } - else if (nav.pages[targetPage].verifyURL) { - fallbackURL = `${env_1.default.BASE_URL}/${nav.pages[targetPage].verifyURL}`; - } - await page.goto(fallbackURL); - console.log(`Used backup URL for ${targetPage}: ${fallbackURL}`); - // For sub-pages that fall back to main pages, verify the main page elements - let { verifyElement } = nav.pages[targetPage]; - if (targetPage === 'ShareData') { - verifyElement = nav.pages.Share.verifyElement; // Verify main Share page instead - } - else if (targetPage === 'ProfileEdit') { - verifyElement = nav.pages.Profile.verifyElement; // Verify main Profile page instead - } - else if (['Basics', 'Daily', 'BGLog', 'Trends', 'Devices'].includes(targetPage)) { - verifyElement = nav.pages.ViewData.verifyElement; // Verify main ViewData page instead - } - // Wait for the fallback page to actually load and verify we're there - if (verifyElement) { - await verifyElement.waitFor({ - state: 'visible', - timeout: 10000, - }); - console.log(`āœ… Backup URL navigation to ${targetPage} verified successfully (using fallback verification)`); - } - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.log(`Backup URL failed: ${errorMessage}`); - throw error; - } - } - else { - throw new Error(`Navigation to ${targetPage} failed and no fallback available`); - } - } -} -const test = base_1.test; -exports.test = test; -test.patient = { - navigateTo, - setup: setupPatientSession, -}; diff --git a/build/tests/fixtures/test-tags.js b/build/tests/fixtures/test-tags.js deleted file mode 100644 index a2f7ec6..0000000 --- a/build/tests/fixtures/test-tags.js +++ /dev/null @@ -1,98 +0,0 @@ -"use strict"; -/** - * Test Tags Fixture - * - * Simple tag definitions for test organization and Xray integration. - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.TAG_CATEGORIES = exports.TEST_TAGS = void 0; -exports.validateRequiredTags = validateRequiredTags; -exports.createValidatedTags = createValidatedTags; -exports.TEST_TAGS = { - /** - * Generate a Jira-related tag for linking tests to Jira tickets. - * Usage: TEST_TAGS.RELATED('JIRA-1234') => '@jira(JIRA-1234)' - */ - RELATED: (jiraId) => { - // Accepts formats like ABC-1234 or JIRA-1234 - const jiraPattern = /^[A-Z][A-Z0-9]+-\d+$/; - if (!jiraPattern.test(jiraId)) { - throw new Error(`Invalid Jira ID: ${jiraId}. Must match pattern ABC-1234.`); - } - return `@jira(${jiraId})`; - }, - // Backend Services - BACK_SHORELINE: '@back-shoreline', - BACK_CLINIC: '@back-clinic', - BACK_HIGHWATER: '@back-highwater', - BACK_HYDROPHONE: '@back-hydrophone', - BACK_PLATFORM: '@back-platform', - BACK_SEAGULL: '@back-seagull', - BACK_TIDEWHISPERER: '@back-tidewhisperer', - BACK_MESSAGEAPI: '@back-messageapi', - BACK_JELLYFISH: '@back-jellyfish', - BACK_GATEKEEPER: '@back-gatekeeper', - BACK_EXPORT: '@back-export', - BACK_KEYCLOAK: '@back-keycloak', - // User Types - PATIENT: '@patient', - CLINICIAN: '@clinician', - // User-Subtypes - CUSTODIAL: '@custodial', - SHARED_MEMBER: '@shared_member', - PERSONAL: '@personal', - CLAIMED: '@claimed', - // Test Types - API: '@api', - UI: '@ui', - SMOKE: '@smoke', - REGRESSION: '@regression', - // Priority - CRITICAL: '@critical', - HIGH: '@high', - MEDIUM: '@medium', - LOW: '@low', - // Endpoint API Testing - API_PROFILE: '@api_profile', - API_USER: '@api_user', -}; -// Tag Categories for Validation -exports.TAG_CATEGORIES = { - USER_TYPES: [exports.TEST_TAGS.PATIENT, exports.TEST_TAGS.CLINICIAN], - TEST_TYPES: [exports.TEST_TAGS.API, exports.TEST_TAGS.UI, exports.TEST_TAGS.SMOKE, exports.TEST_TAGS.REGRESSION], - PRIORITIES: [exports.TEST_TAGS.CRITICAL, exports.TEST_TAGS.HIGH, exports.TEST_TAGS.MEDIUM, exports.TEST_TAGS.LOW], -}; -/** - * Validates that tags include at least one from each required category - * @param tags Array of tags to validate - * @returns Object with validation results - */ -function validateRequiredTags(tags) { - const hasUserType = tags.some(tag => exports.TAG_CATEGORIES.USER_TYPES.includes(tag)); - const hasTestType = tags.some(tag => exports.TAG_CATEGORIES.TEST_TYPES.includes(tag)); - const hasPriority = tags.some(tag => exports.TAG_CATEGORIES.PRIORITIES.includes(tag)); - const isValid = hasUserType && hasTestType && hasPriority; - const missing = []; - if (!hasUserType) - missing.push('User Type'); - if (!hasTestType) - missing.push('Test Type'); - if (!hasPriority) - missing.push('Priority'); - return { - isValid, - missing, - message: isValid ? 'All required tags present' : `Missing required tags: ${missing.join(', ')}`, - }; -} -/** - * Helper function to create tags with validation - * Throws error if required tags are missing - */ -function createValidatedTags(tags) { - const validation = validateRequiredTags(tags); - if (!validation.isValid) { - throw new Error(`Test tags validation failed: ${validation.message}`); - } - return tags; -} diff --git a/build/tests/global-setup.js b/build/tests/global-setup.js deleted file mode 100644 index 03e5990..0000000 --- a/build/tests/global-setup.js +++ /dev/null @@ -1,47 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.default = globalSetup; -const test_1 = require("@playwright/test"); -const LoginPage_1 = __importDefault(require("@pom/LoginPage")); -const node_fs_1 = __importDefault(require("node:fs")); -const node_path_1 = __importDefault(require("node:path")); -const env_1 = __importDefault(require("../utilities/env")); -async function loginUserType(role) { - const browser = await test_1.chromium.launch(); - const context = await browser.newContext({ - baseURL: env_1.default.BASE_URL, - }); - const page = await context.newPage(); - await page.goto(env_1.default.BASE_URL); - const loginPage = new LoginPage_1.default(page); - if (role === 'personal') { - await loginPage.login(env_1.default.PERSONAL_USERNAME, env_1.default.PERSONAL_PASSWORD); - await page.waitForURL('**/data'); - } - else if (role === 'claimed') { - await loginPage.login(env_1.default.CLAIMED_USERNAME, env_1.default.CLAIMED_PASSWORD); - await page.waitForURL('**/data'); - } - else if (role === 'shared') { - await loginPage.login(env_1.default.SHARED_USERNAME, env_1.default.SHARED_PASSWORD); - await page.waitForURL('**/data'); - } - else { - await loginPage.login(env_1.default.CLINICIAN_USERNAME, env_1.default.CLINICIAN_PASSWORD); - await page.waitForURL('**/workspaces'); - } - const authDir = node_path_1.default.resolve(process.cwd(), 'tests', '.auth'); - await node_fs_1.default.promises.mkdir(authDir, { recursive: true }); - const filePath = node_path_1.default.join(authDir, `${role}.json`); - await context.storageState({ path: filePath }); - await browser.close(); -} -async function globalSetup(_config) { - await loginUserType('personal'); - await loginUserType('claimed'); - await loginUserType('shared'); - await loginUserType('clinician'); -} diff --git a/build/tests/personal/AP-Profile/edit-personal-profile-API.spec.js b/build/tests/personal/AP-Profile/edit-personal-profile-API.spec.js deleted file mode 100644 index 45bc9b2..0000000 --- a/build/tests/personal/AP-Profile/edit-personal-profile-API.spec.js +++ /dev/null @@ -1,75 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const patient_helpers_1 = require("../../fixtures/patient-helpers"); -const network_helpers_1 = require("../../fixtures/network-helpers"); -const test_tags_1 = require("../../fixtures/test-tags"); -const ProfilePage_1 = require("../../../page-objects/patient/ProfilePage"); -patient_helpers_1.test.describe('Personal Accounts allow access and modification of profile details', () => { - // API Test cases require this to capture network activity - let api; - (0, patient_helpers_1.test)('should allow navigation to profile details and edit profile fields', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.PATIENT, // User Type (required) - test_tags_1.TEST_TAGS.PERSONAL, // User Subtype (required) - test_tags_1.TEST_TAGS.API, // Test Type (required) - test_tags_1.TEST_TAGS.UI, // Test Type (required) - test_tags_1.TEST_TAGS.HIGH, // Priority (required) - test_tags_1.TEST_TAGS.API_PROFILE, // Feature (optional) - ]), - }, async ({ page }) => { - // Step 1: Log in to personal account and setup network capture - await patient_helpers_1.test.step('Given personal account has been logged in', async () => { - api = (0, network_helpers_1.createNetworkHelper)(page); - await api.startCapture(); - await page.goto('/data'); - await patient_helpers_1.test.patient.setup(page); - // Step 2: Navigate to profile - await patient_helpers_1.test.step('When user navigates to Profile page', async () => { - await patient_helpers_1.test.patient.navigateTo('Profile', page); - }); - // Step 3: Check profile GET response - await patient_helpers_1.test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - // Step 4: Open Edit Profile - await patient_helpers_1.test.step('When user selects Edit button', async () => { - await patient_helpers_1.test.patient.navigateTo('ProfileEdit', page); - }); - // Initialize ProfilePage for steps 4 and 5 - const profilePage = new ProfilePage_1.ProfilePage(page); - // Step 5: Change profile fields (confirmed user access) - await patient_helpers_1.test.step('When user updates profile fields', async () => { - // Generate completely unique values for this confirmed user test run - const testRunId = Math.floor(Math.random() * 10000); - const updatedName = `Personal Patient Updated ${testRunId}`; - const birthYear = 1985 + (testRunId % 10); - const diagnosisYear = birthYear + 20; - const birthDate = `01/15/${birthYear}`; - const diagnosisDate = `03/10/${diagnosisYear}`; - // Generate random 15-letter string for clinical notes - const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); - // Get current diagnosis index and calculate next one (1-7, wrapping) - const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); - let nextDiagnosisIndex = currentDiagnosisIndex + 1; - if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { - nextDiagnosisIndex = 1; - } - // Update fields using ProfilePage methods - await profilePage.fillFullName(updatedName); - await profilePage.fillBirthDate(birthDate); - await profilePage.fillDiagnosisDate(diagnosisDate); - await profilePage.selectDiagnosisType(nextDiagnosisIndex); - await profilePage.fillClinicalNotes(randomString); - }); - // Step 6: Save profile edit - await patient_helpers_1.test.step('When user saves profile changes', async () => { - await profilePage.saveProfile(); - }); - // Step 7: Check profile PUT response - await patient_helpers_1.test.stepNoScreenshot('Then profile endpoint responds with PUT request consistent with schema', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - }); - await api.stopCapture(); - }); - }); -}); diff --git a/build/tests/personal/basic-functionality.spec.js b/build/tests/personal/basic-functionality.spec.js deleted file mode 100644 index 48e40fa..0000000 --- a/build/tests/personal/basic-functionality.spec.js +++ /dev/null @@ -1,240 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -// @ts-check -const base_1 = require("@fixtures/base"); -const BasicsPage_1 = __importDefault(require("@pom/patient/BasicsPage")); -const DailyPage_1 = __importDefault(require("@pom/patient/DailyPage")); -base_1.test.describe('Patient Data Navigation and Visualization', () => { - base_1.test.beforeEach(async ({ page }) => { - await base_1.test.step('Given user has been logged in', async () => { - const basicsPage = new BasicsPage_1.default(page); - await basicsPage.goto(); - // await page.getByText("Loading").waitFor({ state: "detached", timeout: 10000 }); - }); - }); - // BG readings dashboard functionality - (0, base_1.test)('should display daily chart when selecting a date from basics page', async ({ page }) => { - const basicsPage = new BasicsPage_1.default(page); - const dailyPage = new DailyPage_1.default(page); - let selectedDateText; - await base_1.test.step('When the navigation bar is visible', async () => { - await basicsPage.navigationBar.buttons.viewData.waitFor({ - state: 'visible', - }); - }); - await base_1.test.step('When the user clicks on the most recent day', async () => { - const recentDayElement = basicsPage.bgReadingsSection.firstDayOfData; - await recentDayElement.waitFor({ state: 'visible' }); - await recentDayElement.hover(); - selectedDateText = await basicsPage.bgReadingsSection.calendarDayhover.text(); - await basicsPage.bgReadingsSection.calendarDayhover.el.click(); - }); - await base_1.test.step('Then the daily chart is visible and correctly rendered', async () => { - const chartContainer = dailyPage.dailyChart.container; - await chartContainer.waitFor({ state: 'visible' }); - if (!selectedDateText) { - throw new Error('Selected date text is null'); - } - // Verify the selected date matches the displayed date - // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); - // Capture chart screenshot for visual regression - await (0, base_1.expect)(chartContainer).toHaveScreenshot('daily-chart-1.png'); - }); - }); - // Bolus dashboard functionality - (0, base_1.test)('should display bolus dashboard when selecting a date from basics page', async ({ page, }) => { - const basicsPage = new BasicsPage_1.default(page); - const dailyPage = new DailyPage_1.default(page); - let selectedDateText; - await base_1.test.step('When the navigation bar is visible', async () => { - await basicsPage.navigationBar.buttons.viewData.waitFor({ - state: 'visible', - }); - }); - await base_1.test.step('When the user clicks on the most recent day', async () => { - const recentDayElement = basicsPage.bolusingSection.firstDayOfData; - await recentDayElement.waitFor({ state: 'visible' }); - await recentDayElement.hover(); - selectedDateText = await basicsPage.bolusingSection.calendarDayhover.text(); - await basicsPage.bolusingSection.calendarDayhover.el.click(); - }); - await base_1.test.step('Then the daily chart is visible and correctly rendered', async () => { - const chartContainer = dailyPage.dailyChart.container; - await chartContainer.waitFor({ state: 'visible' }); - if (!selectedDateText) { - throw new Error('Selected date text is null'); - } - // Verify the selected date matches the displayed date - // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); - // Capture chart screenshot for visual regression - await (0, base_1.expect)(chartContainer).toHaveScreenshot('daily-chart-2.png'); - }); - }); - // Infusion Site Changes dashboard functionality - (0, base_1.test)('should display Infusion site changes dashboard when selecting a date from basics page', async ({ page, }) => { - const basicsPage = new BasicsPage_1.default(page); - const dailyPage = new DailyPage_1.default(page); - let selectedDateText; - await base_1.test.step('When the infusion site changes dashboard is visible', async () => { - // Verify dashboard title and initial state - // await expect(basicsPage.tubingPrimeSection.title).toBeVisible(); - // await expect(basicsPage.tubingPrimeSection.description).toHaveText( - // "We are using Fill Cannula to visualize your infusion site changes." - // ); - }); - await base_1.test.step('When testing Fill Cannula functionality', async () => { - // Verify radio button options - await basicsPage.tubingPrimeSection.settingsOption.fillCannula.waitFor({ - state: 'visible', - timeout: 60000, - }); - await (0, base_1.expect)(basicsPage.tubingPrimeSection.settingsOption.fillCannula).toBeVisible(); - await (0, base_1.expect)(basicsPage.tubingPrimeSection.settingsOption.fillTubing).toBeVisible(); - // Select Fill Cannula and verify highlighted days - await basicsPage.tubingPrimeSection.settingsOption.fillCannula.click(); - // // Verify duration indicator is visible - // await expect( - // basicsPage.tubingPrimeSection.durationIndicator - // ).toContainText("4 days"); - // Verify cannula icons are visible and tubing icons are not - await (0, base_1.expect)(basicsPage.tubingPrimeSection.cannulaIcons).toBeAttached(); - await (0, base_1.expect)(basicsPage.tubingPrimeSection.tubingIcons).not.toBeAttached(); - // Select a highlighted day - const highlightedDay = basicsPage.tubingPrimeSection.filledDay; - await highlightedDay.hover(); - selectedDateText = await basicsPage.tubingPrimeSection.calendarDayhover.text(); - await basicsPage.tubingPrimeSection.calendarDayhover.el.click(); - }); - await base_1.test.step('Then the daily chart shows correct cannula fill date', async () => { - const chartContainer = dailyPage.dailyChart.container; - await chartContainer.waitFor({ state: 'visible' }); - if (!selectedDateText) { - throw new Error('Selected date text is null'); - } - // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); - await (0, base_1.expect)(chartContainer).toHaveScreenshot('daily-chart-cannula.png'); - }); - // Return to basics page and test Fill Tubing Option - await base_1.test.step('When testing Fill Tubing functionality', async () => { - // Navigate back to basics - await base_1.test.step('When the navigation bar is visible', async () => { - await basicsPage.navigationBar.buttons.viewData.waitFor({ - state: 'visible', - }); - }); - // await basicsPage.navigationSubMenu.links.basics.click(); - await basicsPage.tubingPrimeSection.settings.waitFor({ - state: 'visible', - }); - // Click settings and select Fill Tubing - await basicsPage.tubingPrimeSection.settings.click(); - await basicsPage.tubingPrimeSection.settingsOption.fillTubing.click(); - // Verify filled tubing day is visible and cannula day is not - await (0, base_1.expect)(basicsPage.tubingPrimeSection.tubingIcons).toBeAttached(); - await (0, base_1.expect)(basicsPage.tubingPrimeSection.cannulaIcons).not.toBeAttached(); - // Click on the most recent day with tubing fill - const tubingDay = basicsPage.tubingPrimeSection.filledDay; - await tubingDay.hover(); - selectedDateText = await basicsPage.tubingPrimeSection.calendarDayhover.text(); - await basicsPage.tubingPrimeSection.calendarDayhover.el.click(); - }); - await base_1.test.step('Then the daily chart shows correct tubing fill date', async () => { - const chartContainer = dailyPage.dailyChart.container; - await chartContainer.waitFor({ state: 'visible' }); - if (!selectedDateText) { - throw new Error('Selected date text is null'); - } - // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); - await (0, base_1.expect)(chartContainer).toHaveScreenshot('daily-chart-tubing.png'); - }); - }); - // TODO: Previous test doesn't test values. Should we? :) - // Readings in range functionality - (0, base_1.test)('The hover over elements in sidebar shows correct values', async ({ page }) => { - // Stats for BGM - const expectedHeadersReadingInRange = [ - { header: 'Readings Below Range', value: 3 }, - { header: 'Readings Below Range', value: 0 }, - { header: 'Readings In Range', value: 71 }, - { header: 'Readings Above Range', value: 24 }, - { header: 'Readings Above Range', value: 2 }, - ]; - const basicsPage = new BasicsPage_1.default(page); - await base_1.test.step('When the navigation bar is visible', async () => { - await basicsPage.navigationBar.buttons.viewData.waitFor({ - state: 'visible', - }); - }); - // Other BGM tooltip functionality - await basicsPage.statsSidebar.toggleTo('BGM'); - for (let i = 0; i < 5; i += 1) { - const bar = basicsPage.statsSidebar.readingsInRange.hoverBar.nth(i); - const barLabel = basicsPage.statsSidebar.readingsInRange.hoverBarLabel.nth(i); - await base_1.test.step('When the user hovers over the Avg. Daily Readings In Range chart', async () => { - await bar.hover(); - }); - await base_1.test.step('Then the correct header is visible', async () => { - await base_1.expect - .soft(basicsPage.statsSidebar.readingsInRange.header) - .toContainText(expectedHeadersReadingInRange[i].header); - }); - await base_1.test.step('Then the correct value is visible', async () => { - await base_1.expect - .soft(barLabel) - .toContainText(expectedHeadersReadingInRange[i].value.toString()); - }); - } - // Stats for CGM - // Time in range functionality - const expectedHeadersTimeInRange = [ - { header: 'Time Below Range', value: 0.1 }, - { header: 'Time Below Range', value: 1 }, - { header: 'Time In Range', value: 90 }, - { header: 'Time Above Range', value: 9 }, - { header: 'Time Above Range', value: 0.3 }, - ]; - await basicsPage.statsSidebar.toggleTo('CGM'); - for (let i = 0; i < expectedHeadersTimeInRange.length; i += 1) { - const bar = basicsPage.statsSidebar.timeInRange.hoverBar.nth(i); - const barLabel = basicsPage.statsSidebar.timeInRange.hoverBarLabel.nth(i); - await base_1.test.step('When the user hovers over the Avg. Daily Time In Range chart', async () => { - await bar.hover(); - }); - await base_1.test.step('Then the correct header is visible', async () => { - await base_1.expect - .soft(basicsPage.statsSidebar.timeInRange.header) - .toContainText(expectedHeadersTimeInRange[i].header); - }); - await base_1.test.step('Then the correct value is visible', async () => { - await base_1.expect.soft(barLabel).toContainText(expectedHeadersTimeInRange[i].value.toString()); - }); - } - }); - // Other CGM tooltip functionality - (0, base_1.test)('other CGM tooltip functionality', async ({ page }) => { - const basicsPage = new BasicsPage_1.default(page); - await basicsPage.statsSidebar.toggleTo('CGM'); - const expectedHeadersTimeInRange = [ - { header: 'Basal Insulin', value: 14.7, percentage: 44 }, - { header: 'Bolus Insulin', value: 18.8, percentage: 56 }, - ]; - for (let i = 0; i < expectedHeadersTimeInRange.length; i += 1) { - const bar = basicsPage.statsSidebar.totalInsulin.hoverBar.nth(i); - const barLabel = basicsPage.statsSidebar.totalInsulin.hoverBarLabel.nth(i); - await base_1.test.step('When the user hovers over the Avg. Daily Total Insulin chart', async () => { - await bar.hover(); - }); - await base_1.test.step('Then the correct header is visible', async () => { - await base_1.expect - .soft(basicsPage.statsSidebar.timeInRange.header) - .toContainText(expectedHeadersTimeInRange[i].header); - }); - await base_1.test.step('Then the correct value is visible', async () => { - await base_1.expect.soft(barLabel).toContainText(expectedHeadersTimeInRange[i].value.toString()); - }); - } - }); -}); diff --git a/build/tests/personal/login.spec.js b/build/tests/personal/login.spec.js deleted file mode 100644 index 8c78393..0000000 --- a/build/tests/personal/login.spec.js +++ /dev/null @@ -1,95 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -// @ts-check -const base_1 = require("@fixtures/base"); -const LoginPage_1 = __importDefault(require("page-objects/LoginPage")); -const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); -const env_1 = __importDefault(require("../../utilities/env")); -const test_tags_1 = require("../fixtures/test-tags"); -// make sure we don't have any cookies or origins -base_1.test.use({ storageState: { cookies: [], origins: [] } }); -// Possible testcases: https://tidepool.atlassian.net/jira/software/c/projects/WEB/issues/?jql=project%20%3D%20%22WEB%22%20AND%20type%20%3D%20Test%20AND%20textfields%20~%20%22login%22%20ORDER%20BY%20created%20DESC -base_1.test.describe('Login into application', () => { - (0, base_1.test)('should work with valid credentials for clinician with multiple clinics', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.CLINICIAN, - test_tags_1.TEST_TAGS.UI, - test_tags_1.TEST_TAGS.SMOKE, - test_tags_1.TEST_TAGS.CRITICAL, - ]), - }, async ({ page }) => { - const loginPage = new LoginPage_1.default(page); - await base_1.test.step('When user is logged into application', async () => { - await loginPage.goto(); - await loginPage.login(env_1.default.CLINICIAN_USERNAME, env_1.default.CLINICIAN_PASSWORD); - }); - await base_1.test.step('Then the user is redirected to workspaces page', async () => { - const workspacesPage = new WorkspacesPage_1.default(page); - await page.waitForURL(workspacesPage.url); - await (0, base_1.expect)(workspacesPage.header).toBeVisible(); - }); - }); - (0, base_1.test)('should show error message with invalid credentials', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.CLINICIAN, - test_tags_1.TEST_TAGS.UI, - test_tags_1.TEST_TAGS.SMOKE, - test_tags_1.TEST_TAGS.HIGH, - ]), - }, async ({ page }) => { - const loginPage = new LoginPage_1.default(page); - await base_1.test.step('When user attempts to login with invalid credentials', async () => { - await loginPage.goto(); - // Enter email - await page.fill('#username', 'invalid@email.com'); - await page.click('#kc-login'); - }); - await base_1.test.step('Then error message should be displayed', async () => { - // Wait for the error message to appear - await (0, base_1.expect)(page.locator('#input-error-username')).toBeVisible(); - await (0, base_1.expect)(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); - }); - }); - (0, base_1.test)('should validate email format', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.CLINICIAN, - test_tags_1.TEST_TAGS.UI, - test_tags_1.TEST_TAGS.REGRESSION, - test_tags_1.TEST_TAGS.MEDIUM, - ]), - }, async ({ page }) => { - const loginPage = new LoginPage_1.default(page); - await base_1.test.step('When user attempts to login with invalid email format', async () => { - await loginPage.goto(); - // Enter invalid email format - await page.fill('#username', 'invalidemail'); - await page.click('#kc-login'); - }); - await base_1.test.step('Then email validation error should be displayed', async () => { - // Check for email validation error message - await (0, base_1.expect)(page.locator('#input-error-username')).toBeVisible(); - await (0, base_1.expect)(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); - }); - }); - (0, base_1.test)('should show error message with invalid credentials 1', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.CLINICIAN, - test_tags_1.TEST_TAGS.UI, - test_tags_1.TEST_TAGS.SMOKE, - test_tags_1.TEST_TAGS.HIGH, - ]), - }, async ({ page }) => { - const loginPage = new LoginPage_1.default(page); - await base_1.test.step('When user is logged into application', async () => { - await loginPage.goto(); - await loginPage.login(env_1.default.CLINICIAN_USERNAME, `${env_1.default.CLINICIAN_PASSWORD}1`); - }); - await base_1.test.step('Then error message should be displayed', async () => { - await (0, base_1.expect)(page.locator('#input-error')).toBeVisible(); - await (0, base_1.expect)(page.locator('#input-error')).toContainText('Invalid password.'); - }); - }); -}); diff --git a/build/utilities/annotations.js b/build/utilities/annotations.js deleted file mode 100644 index 528cbcc..0000000 --- a/build/utilities/annotations.js +++ /dev/null @@ -1,24 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.default = addTestAnnotations; -/** - * Add test annotations to the test info for JIRA integration - */ -function addTestAnnotations(testInfo, annotations) { - testInfo.annotations.push({ - type: 'test_key', - description: annotations.testKey, - }); - testInfo.annotations.push({ - type: 'test_summary', - description: annotations.testSummary, - }); - testInfo.annotations.push({ - type: 'requirements', - description: annotations.requirements, - }); - testInfo.annotations.push({ - type: 'test_description', - description: annotations.testDescription, - }); -} diff --git a/build/utilities/env.js b/build/utilities/env.js deleted file mode 100644 index 123e678..0000000 --- a/build/utilities/env.js +++ /dev/null @@ -1,46 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const dotenv_1 = __importDefault(require("dotenv")); -const zod_1 = __importDefault(require("zod")); -dotenv_1.default.config(); -const envSchema = zod_1.default.object({ - BROWSERSTACK_USERNAME: zod_1.default.string().optional(), - BROWSERSTACK_ACCESS_KEY: zod_1.default.string().optional(), - PERSONAL_USERNAME: zod_1.default.string(), - PERSONAL_PASSWORD: zod_1.default.string(), - CLAIMED_USERNAME: zod_1.default.string(), - CLAIMED_PASSWORD: zod_1.default.string(), - SHARED_USERNAME: zod_1.default.string(), - SHARED_PASSWORD: zod_1.default.string(), - CLINICIAN_USERNAME: zod_1.default.string(), - CLINICIAN_PASSWORD: zod_1.default.string(), - TARGET_ENV: zod_1.default.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production', 'prd', 'int']), - XRAY_CLIENT_ID: zod_1.default.string().optional(), - XRAY_CLIENT_SECRET: zod_1.default.string().optional(), - XRAY_PROJECT_KEY: zod_1.default.string().default('SAND'), - XRAY_EVIDENCE_SIZE_THRESHOLD_KB: zod_1.default.coerce.number().default(100), - JIRA_EMAIL: zod_1.default.string().optional(), - JIRA_API_KEY: zod_1.default.string().optional(), -}); -const env = envSchema.safeParse(process.env); -if (!env.success) { - console.error('āŒ Invalid environment variables:\n', env.error.format()); - throw new Error('Invalid environment variables. Check your .env file.'); -} -const URL_MAP = { - qa1: 'https://qa1.development.tidepool.org', - qa2: 'https://qa2.development.tidepool.org', - qa3: 'https://qa3.development.tidepool.org', - qa4: 'https://qa4.development.tidepool.org', - qa5: 'https://qa5.development.tidepool.org', - production: 'https://app.tidepool.org', - prd: 'https://app.tidepool.org', // Alias for production - int: 'https://int.development.tidepool.org', // Integration environment -}; -exports.default = { - ...env.data, - BASE_URL: URL_MAP[env.data.TARGET_ENV], -}; diff --git a/build/utilities/xray-json-reporter.js b/build/utilities/xray-json-reporter.js deleted file mode 100644 index 56046f6..0000000 --- a/build/utilities/xray-json-reporter.js +++ /dev/null @@ -1,473 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const node_fs_1 = __importDefault(require("node:fs")); -const node_path_1 = __importDefault(require("node:path")); -const env_1 = __importDefault(require("./env")); -const xray_graphql_evidence_1 = require("./xray-graphql-evidence"); -/** - * Unified Xray JSON Reporter for Playwright - * Maps rich Playwright test data to Xray's JSON format with intelligent evidence handling - */ -class XrayJsonReporter { - constructor() { - this.styles = { - success: 'āœ…', - error: 'āŒ', - info: 'ā„¹ļø', - warning: 'āš ļø', - upload: 'šŸš€', - test: '🧪', - evidence: 'šŸ“Ž', - separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - }; - this.startTime = ''; - this.endTime = ''; - this.deferredEvidenceUploads = []; - } - /** - * Authenticates with Xray API using client credentials - */ - async authenticateWithXray() { - const startAuth = Date.now(); - try { - console.log(`${this.styles.info} Authenticating with Xray Cloud API...`); - if (!env_1.default.XRAY_CLIENT_ID || !env_1.default.XRAY_CLIENT_SECRET) { - throw new Error('XRAY_CLIENT_ID and XRAY_CLIENT_SECRET are required for authentication'); - } - const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - client_id: env_1.default.XRAY_CLIENT_ID, - client_secret: env_1.default.XRAY_CLIENT_SECRET, - }), - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Authentication failed (HTTP ${response.status}): ${errorText || 'No error details'}`); - } - const token = await response.text(); - const cleanToken = token.replace(/"/g, ''); // Remove quotes from token - if (!cleanToken || cleanToken.length < 10) { - throw new Error(`Invalid token received: ${cleanToken.substring(0, 20)}...`); - } - const authDuration = Date.now() - startAuth; - console.log(`${this.styles.success} Successfully authenticated with Xray (${authDuration}ms)`); - return cleanToken; - } - catch (error) { - console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); - throw error; - } - } - /** - * Maps Playwright test status to Xray status - */ - getTestStatus(status) { - if (status === 'passed') - return 'PASS'; - if (status === 'skipped') - return 'PENDING'; - return 'FAIL'; - } - /** - * Converts file to base64 string for Xray evidence - */ - async fileToBase64(filePath) { - try { - const fileBuffer = node_fs_1.default.readFileSync(filePath); - return fileBuffer.toString('base64'); - } - catch (error) { - console.warn(`${this.styles.warning} Could not read file ${filePath}:`, error); - return ''; - } - } - /** - * Get file size in bytes - */ - getFileSize(filePath) { - try { - const stats = node_fs_1.default.statSync(filePath); - return stats.size; - } - catch (error) { - return 0; - } - } - /** - * Classifies evidence based on type, size, and test result - */ - classifyEvidence(attachment, testStatus, contentType) { - const filePath = attachment.path; - if (!filePath || !node_fs_1.default.existsSync(filePath)) { - return 'skip'; - } - const sizeBytes = this.getFileSize(filePath); - const sizeKB = sizeBytes / 1024; - const thresholdKB = env_1.default.XRAY_EVIDENCE_SIZE_THRESHOLD_KB || 100; - // Videos: Only for failed tests - if (contentType.includes('video')) { - if (testStatus !== 'passed') { - return 'deferred'; // Always defer videos (large files) - } - return 'skip'; - } - // Screenshots (PNG/JPEG): Always include - if (contentType.includes('image')) { - if (sizeKB < thresholdKB) { - return 'inline'; - } - return 'deferred'; - } - // JSON responses: Always inline (small) - if (contentType.includes('json')) { - return 'inline'; - } - // Other attachments: Check size - if (sizeKB < thresholdKB) { - return 'inline'; - } - return 'deferred'; - } - /** - * Extracts step information from test annotations and maps evidence - */ - async extractSteps(annotations, attachments, testStatus) { - const steps = []; - const classifiedEvidence = []; - const stepAnnotations = annotations.filter(ann => ann.type.startsWith('Step Duration:')); - for (let i = 0; i < stepAnnotations.length; i += 1) { - const stepAnn = stepAnnotations[i]; - const stepName = stepAnn.type.replace('Step Duration: ', ''); - const duration = stepAnn.description; - const stepNumber = i + 1; - // Find associated step attachments using step number pattern - const stepPattern = `step-${stepNumber.toString().padStart(2, '0')}`; - const stepAttachments = attachments.filter(att => att.name.toLowerCase().includes(stepPattern)); - const step = { - action: stepName, - data: `Duration: ${duration}`, - result: stepName.includes('Then') ? stepName : undefined, - status: 'PASS', // Will be updated if test failed - evidences: [], - }; - // Classify and process step evidence - for (const attachment of stepAttachments) { - if (attachment.path && node_fs_1.default.existsSync(attachment.path)) { - const contentType = attachment.contentType || 'application/octet-stream'; - const classification = this.classifyEvidence(attachment, testStatus, contentType); - if (classification !== 'skip') { - const sizeBytes = this.getFileSize(attachment.path); - if (classification === 'inline') { - // Embed in Xray JSON - const base64Data = await this.fileToBase64(attachment.path); - if (base64Data) { - step.evidences?.push({ - data: base64Data, - filename: node_path_1.default.basename(attachment.path), - contentType, - }); - } - } - else { - // Mark for deferred upload - classifiedEvidence.push({ - evidence: { - data: '', // Will be loaded during GraphQL upload - filename: node_path_1.default.basename(attachment.path), - contentType, - }, - classification: 'deferred', - stepIndex: i, - filePath: attachment.path, - fileSize: sizeBytes, - }); - } - } - } - } - steps.push(step); - } - return { steps, classified: classifiedEvidence }; - } - /** - * Maps Playwright test result to Xray test format - */ - async mapPlaywrightTestToXray(testCase, testResult) { - const tags = testCase.tags || []; - const annotations = testResult.annotations || []; - const attachments = testResult.attachments || []; - const testStatus = testResult.status; - // Extract steps from annotations - const { steps, classified: stepDeferred } = await this.extractSteps(annotations, attachments, testStatus); - // Mark failed steps if test failed - if (testStatus !== 'passed' && steps.length > 0) { - steps[steps.length - 1].status = 'FAIL'; - steps[steps.length - 1].actualResult = testResult.error?.message || 'Test failed'; - } - // Collect test-level evidence (screenshots, videos) - const testEvidences = []; - const testLevelDeferred = []; - for (const attachment of attachments) { - // Only process test-level evidence (not step-level) - if (attachment.path && - node_fs_1.default.existsSync(attachment.path) && - !attachment.name.toLowerCase().includes('step-')) { - const contentType = attachment.contentType || 'application/octet-stream'; - const classification = this.classifyEvidence(attachment, testStatus, contentType); - if (classification !== 'skip') { - const sizeBytes = this.getFileSize(attachment.path); - if (classification === 'inline') { - const base64Data = await this.fileToBase64(attachment.path); - if (base64Data) { - testEvidences.push({ - data: base64Data, - filename: attachment.name, - contentType, - }); - } - } - else { - testLevelDeferred.push({ - evidence: { - data: '', - filename: attachment.name, - contentType, - }, - classification: 'deferred', - filePath: attachment.path, - fileSize: sizeBytes, - }); - } - } - } - } - const xrayTest = { - testInfo: { - summary: testCase.title, - type: 'Generic', - projectKey: env_1.default.XRAY_PROJECT_KEY || 'SAND', - labels: tags, - }, - status: this.getTestStatus(testStatus), - comment: testResult.error?.message, - evidences: testEvidences.length > 0 ? testEvidences : undefined, - steps: steps.length > 0 ? steps : undefined, - }; - return { - test: xrayTest, - deferred: [...stepDeferred, ...testLevelDeferred], - }; - } - /** - * Converts Playwright JSON results to Xray format - */ - async convertPlaywrightJsonToXray(playwrightJsonPath) { - const jsonContent = node_fs_1.default.readFileSync(playwrightJsonPath, 'utf8'); - const playwrightResult = JSON.parse(jsonContent); - const tests = []; - this.deferredEvidenceUploads = []; // Reset deferred uploads - // Process all test suites - for (const suite of playwrightResult.suites || []) { - await this.processSuite(suite, tests); - } - const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; - const targetEnv = process.env.TARGET_ENV || 'qa1'; - // Calculate statistics - const passedCount = tests.filter(t => t.status === 'PASS').length; - const failedCount = tests.filter(t => t.status === 'FAIL').length; - const pendingCount = tests.filter(t => t.status === 'PENDING').length; - const xrayResult = { - info: { - summary: `Playwright Test Execution - ${new Date().toISOString()}`, - description: `Automated test execution for ${targetEnv} environment\n\nResults: ${passedCount} passed, ${failedCount} failed, ${pendingCount} pending`, - version: '1.0', - testExecutionKey: testExecKey && testExecKey !== 'none' && testExecKey.trim() !== '' - ? testExecKey - : undefined, - startDate: playwrightResult.stats?.startTime || new Date().toISOString(), - finishDate: new Date(new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + - (playwrightResult.stats?.duration || 0)).toISOString(), - testEnvironments: [targetEnv], - }, - tests, - }; - // Log deferred evidence summary - if (this.deferredEvidenceUploads.length > 0) { - const totalSizeKB = this.deferredEvidenceUploads.reduce((sum, d) => sum + this.getFileSize(d.filePath) / 1024, 0); - console.log(`${this.styles.evidence} ${this.deferredEvidenceUploads.length} evidence files marked for deferred upload (${totalSizeKB.toFixed(1)} KB)`); - } - return xrayResult; - } - /** - * Recursively processes test suites - */ - async processSuite(suite, tests) { - // Process specs in this suite - for (const spec of suite.specs || []) { - for (const test of spec.tests || []) { - for (const result of test.results || []) { - const { test: xrayTest, deferred } = await this.mapPlaywrightTestToXray(spec, result); - tests.push(xrayTest); - // Store deferred evidence for later upload - // Note: We'll need test run IDs from import response to upload these - for (const evidence of deferred) { - this.deferredEvidenceUploads.push({ - testRunId: '', // Will be populated after import - testRunStepId: evidence.stepIndex !== undefined ? '' : undefined, - filePath: evidence.filePath, - filename: evidence.evidence.filename, - contentType: evidence.evidence.contentType, - stepAction: evidence.stepIndex !== undefined - ? xrayTest.steps?.[evidence.stepIndex]?.action - : undefined, - }); - } - } - } - } - // Process nested suites - for (const nestedSuite of suite.suites || []) { - await this.processSuite(nestedSuite, tests); - } - } - /** - * Uploads Xray execution result to Xray - */ - async uploadToXray(xrayResult) { - try { - const uploadStart = Date.now(); - const payloadSize = JSON.stringify(xrayResult).length; - const payloadSizeKB = (payloadSize / 1024).toFixed(1); - console.log(`${this.styles.info} Uploading test execution to Xray...`); - console.log(`${this.styles.info} Payload: ${xrayResult.tests.length} tests, ${payloadSizeKB} KB`); - const token = await this.authenticateWithXray(); - const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(xrayResult), - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Upload failed (HTTP ${response.status}): ${errorText}`); - } - const result = await response.json(); - const uploadDuration = Date.now() - uploadStart; - console.log(`${this.styles.success} Successfully uploaded to Xray (${uploadDuration}ms)`); - console.log(`${this.styles.success} Test Execution Key: ${result.testExecIssue?.key || 'N/A'}`); - return result; - } - catch (error) { - console.error(`${this.styles.error} Failed to upload to Xray:`, error); - throw error; - } - } - /** - * Upload deferred evidence via GraphQL - */ - async uploadDeferredEvidenceViaGraphQL(importResponse) { - try { - console.log(`${this.styles.evidence} Uploading ${this.deferredEvidenceUploads.length} deferred evidence files via GraphQL...`); - // Get fresh token for GraphQL - const token = await this.authenticateWithXray(); - // Create GraphQL client - const graphqlClient = new xray_graphql_evidence_1.XrayGraphQLClient(); - graphqlClient.setAuthToken(token); - // Note: The import response doesn't directly provide test run IDs - // For now, we'll skip GraphQL upload and log a warning - // This requires additional API calls to fetch test run details - console.log(`${this.styles.warning} GraphQL evidence upload requires test run ID mapping`); - console.log(`${this.styles.info} Test Execution: ${importResponse.testExecIssue?.key}`); - console.log(`${this.styles.info} Deferred uploads will be enhanced in a future update to fetch test run IDs`); - // TODO: Implement test run ID fetching via GraphQL query - // Query: { getTestExecution(issueId: "...") { testRuns { id, test { summary } } } } - // Then map playwright test titles to test run IDs - // Then call graphqlClient.uploadBatch(uploadsWithIds) - } - catch (error) { - console.error(`${this.styles.error} Failed to upload deferred evidence:`, error); - // Don't throw - evidence upload is non-critical - } - } - /** - * Main method to process and upload results - */ - async processAndUpload(playwrightJsonPath) { - if (!(env_1.default.XRAY_CLIENT_ID && env_1.default.XRAY_CLIENT_SECRET)) { - console.log(`${this.styles.warning} No Xray credentials found, skipping upload to JIRA Xray`); - return; - } - try { - const processStart = Date.now(); - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.info} Processing Playwright results for Xray...`); - console.log(`${this.styles.info} Project Key: ${env_1.default.XRAY_PROJECT_KEY || 'SAND'}`); - console.log(`${this.styles.info} Environment: ${process.env.TARGET_ENV || 'qa1'}`); - const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; - if (testExecKey && testExecKey !== 'none' && testExecKey.trim() !== '') { - console.log(`${this.styles.info} Linking to Test Execution: ${testExecKey}`); - } - else { - console.log(`${this.styles.info} Creating new Test Execution`); - } - const xrayResult = await this.convertPlaywrightJsonToXray(playwrightJsonPath); - // Save converted result for debugging - node_fs_1.default.writeFileSync('test-results/xray-execution.json', JSON.stringify(xrayResult, null, 2)); - console.log(`${this.styles.info} Saved Xray JSON to: test-results/xray-execution.json`); - const importResponse = await this.uploadToXray(xrayResult); - // Phase 3 - Upload deferred evidence via GraphQL - if (this.deferredEvidenceUploads.length > 0 && importResponse) { - await this.uploadDeferredEvidenceViaGraphQL(importResponse); - } - const totalDuration = Date.now() - processStart; - console.log(`${this.styles.upload} Xray upload completed successfully (${totalDuration}ms)`); - console.log(`${this.styles.separator}\n`); - } - catch (error) { - console.error(`${this.styles.error} Failed to process and upload:`, error); - throw error; - } - } - /** - * Reporter lifecycle methods for direct Playwright integration - */ - onBegin(_config, suite) { - this.startTime = new Date().toISOString(); - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); - console.log(`${this.styles.separator}\n`); - } - onTestBegin(test, _result) { - console.log(`${this.styles.test} Starting: ${test.title}`); - } - onTestEnd(test, result) { - const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; - console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); - } - async onEnd(result) { - this.endTime = new Date().toISOString(); - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.info} Test Run Summary:`); - console.log(`Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`); - console.log(`Duration: ${result.duration}ms`); - console.log(`${this.styles.separator}\n`); - // Only attempt upload if Xray credentials and execution key are configured - const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; - if (env_1.default.XRAY_CLIENT_ID && env_1.default.XRAY_CLIENT_SECRET && testExecKey && testExecKey !== 'none') { - const jsonPath = 'test-results/last-run.json'; - if (node_fs_1.default.existsSync(jsonPath)) { - await this.processAndUpload(jsonPath); - } - } - } -} -exports.default = XrayJsonReporter; diff --git a/build/utilities/xray-reporter.js b/build/utilities/xray-reporter.js deleted file mode 100644 index 0532c49..0000000 --- a/build/utilities/xray-reporter.js +++ /dev/null @@ -1,134 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const node_fs_1 = __importDefault(require("node:fs")); -const env_1 = __importDefault(require("./env")); -/** - * Reporter class for uploading test results to Xray - */ -class XRayReporter { - constructor() { - this.styles = { - success: 'āœ…', - error: 'āŒ', - info: 'ā„¹ļø', - warning: 'ā›”ļø', - upload: 'šŸš€', - test: '🧪', - separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - }; - } - /** - * Authenticates with Xray API using client credentials - * @returns {Promise} The authentication token - * @throws {Error} If authentication fails - */ - async authenticateWithXray() { - try { - console.log(`${this.styles.info} Authenticating with Xray...`); - const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - client_id: env_1.default.XRAY_CLIENT_ID, - client_secret: env_1.default.XRAY_CLIENT_SECRET, - }), - }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}, ${response.body}`); - } - const data = await response.json(); - console.log(`${this.styles.success} Successfully authenticated with Xray`); - return data.token; - } - catch (error) { - console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); - throw error; - } - } - /** - * Uploads test results to Xray - * @param {string} token - The authentication token - * @param {string} xmlContent - The JUnit XML content to upload - * @returns {Promise} - * @throws {Error} If upload fails - */ - async uploadTestResults(token, xmlContent) { - try { - console.log(`${this.styles.info} Uploading test results to Xray...`); - const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution/junit?projectKey=XT&testPlanKey=XT-380', { - method: 'POST', - headers: { - 'Content-Type': 'text/xml', - Authorization: `Bearer ${token}`, - }, - body: xmlContent, - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); - } - console.log(`${this.styles.success} Successfully uploaded test results to Xray`); - } - catch (error) { - console.error(`${this.styles.error} Failed to upload test results to Xray:`, error); - throw error; - } - } - /** - * Called when test run begins - * @param suite - Test suite object containing all tests - */ - onBegin(_config, suite) { - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); - console.log(`${this.styles.separator}\n`); - } - /** - * Called when a test begins - * @param test - Test case object - */ - onTestBegin(test, _result) { - console.log(`${this.styles.test} Starting: ${test.title}`); - } - /** - * Called when a test ends - * @param {Object} test - Test case object - * @param {Object} result - Test result object containing status and other details - */ - onTestEnd(test, result) { - const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; - console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); - } - /** - * Called when all tests have finished - * @param result - Full test run result object containing status and duration - */ - async onEnd(result) { - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.info} Test Run Summary:`); - console.log(`Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`); - console.log(`Duration: ${result.duration}ms`); - console.log(`${this.styles.separator}\n`); - if (!(env_1.default.XRAY_CLIENT_ID || env_1.default.XRAY_CLIENT_SECRET)) { - console.log(`${this.styles.warning} No Xray client ID or secret found, skipping upload to JIRA Xray`); - return; - } - try { - console.log(`${this.styles.info} Reading test results file...`); - const testResults = node_fs_1.default.readFileSync('./test-results/test-results.xml', 'utf8'); - const token = await this.authenticateWithXray(); - await this.uploadTestResults(token, testResults); - console.log(`${this.styles.upload} Successfully uploaded test results to Xray`); - } - catch (error) { - console.error(`${this.styles.error} Failed to process test results:`, error); - } - console.log(`${this.styles.separator}\n`); - } -} -exports.default = XRayReporter; diff --git a/dist/endpoint-schema/auth-endpoints.d.ts b/dist/endpoint-schema/auth-endpoints.d.ts deleted file mode 100644 index a130449..0000000 --- a/dist/endpoint-schema/auth-endpoints.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { EndpointSchema } from './profile-endpoints'; -/** - * Schema for user authentication login - */ -export declare const loginSchema: EndpointSchema; -/** - * Schema for user logout - */ -export declare const logoutSchema: EndpointSchema; -/** - * Schema for token refresh - */ -export declare const refreshTokenSchema: EndpointSchema; diff --git a/dist/endpoint-schema/auth-endpoints.js b/dist/endpoint-schema/auth-endpoints.js deleted file mode 100644 index 8eff4cc..0000000 --- a/dist/endpoint-schema/auth-endpoints.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Schema for user authentication login - */ -export const loginSchema = { - url: /\/auth\/login$/, - method: 'POST', - expectedStatus: 200, - requestSchema: { - username: 'string', - password: 'string', - }, - responseSchema: { - userid: 'string', - username: 'string', - emails: 'object', - roles: 'object', - }, - validationFields: ['userid', 'username', 'emails', 'roles'], - requiredFields: [ - 'userid', // Auth endpoints require userid instead of fullName - 'username', // Username is also critical for auth - ], -}; -/** - * Schema for user logout - */ -export const logoutSchema = { - url: /\/auth\/logout$/, - method: 'POST', - expectedStatus: 200, - validationFields: [ - // Logout typically doesn't return data to validate - ], -}; -/** - * Schema for token refresh - */ -export const refreshTokenSchema = { - url: /\/auth\/token$/, - method: 'POST', - expectedStatus: 200, - responseSchema: { - userid: 'string', - username: 'string', - }, - validationFields: ['userid', 'username'], - requiredFields: [ - 'userid', // Token refresh must return userid - ], -}; diff --git a/dist/endpoint-schema/endpoint-registry.d.ts b/dist/endpoint-schema/endpoint-registry.d.ts deleted file mode 100644 index f29522f..0000000 --- a/dist/endpoint-schema/endpoint-registry.d.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { EndpointSchema } from './profile-endpoints'; -/** - * Centralized endpoint registry for all API validation - * This allows network helpers to work with any endpoint by name - * - * ADDING NEW ENDPOINTS: - * 1. Define the endpoint schema in the appropriate *-endpoints.ts file - * 2. Include validationFields array for data consistency checking - * 3. Add the endpoint to this registry - * 4. The validationFields will automatically be used by NetworkHelper methods - * - * VALIDATION FIELDS: - * - Use dot notation for nested fields (e.g., 'patient.fullName') - * - Include all fields that should be validated for data consistency - * - Different endpoints can have different validation requirements - * - Fields are endpoint-specific and stored in the schema definition - */ -export declare const ENDPOINT_REGISTRY: { - readonly 'profile-metadata-get': EndpointSchema; - readonly 'profile-metadata-put': EndpointSchema; - readonly 'profile-patient-data-get': EndpointSchema; - readonly 'profile-metrics-get': EndpointSchema; - readonly 'profile-message-notes-get': EndpointSchema; - readonly 'patient-data-get': EndpointSchema; - readonly 'patient-data-upload': EndpointSchema; - readonly 'auth-login': EndpointSchema; - readonly 'auth-logout': EndpointSchema; - readonly 'auth-refresh-token': EndpointSchema; -}; -export type EndpointName = keyof typeof ENDPOINT_REGISTRY; -/** - * Get endpoint schema by name - */ -export declare function getEndpointSchema(endpointName: EndpointName): EndpointSchema; diff --git a/dist/endpoint-schema/endpoint-registry.js b/dist/endpoint-schema/endpoint-registry.js deleted file mode 100644 index 6e64934..0000000 --- a/dist/endpoint-schema/endpoint-registry.js +++ /dev/null @@ -1,48 +0,0 @@ -import { getProfileMetadataSchema, putProfileMetadataSchema, getPatientDataSchema as profileGetPatientDataSchema, getMetricsSchema as profileGetMetricsSchema, getMessageNotesSchema as profileGetMessageNotesSchema, } from './profile-endpoints'; -import { getPatientDataSchema, uploadPatientDataSchema } from './patient-data-endpoints'; -import { loginSchema, logoutSchema, refreshTokenSchema } from './auth-endpoints'; -// Import other endpoint schemas as they're created -/** - * Centralized endpoint registry for all API validation - * This allows network helpers to work with any endpoint by name - * - * ADDING NEW ENDPOINTS: - * 1. Define the endpoint schema in the appropriate *-endpoints.ts file - * 2. Include validationFields array for data consistency checking - * 3. Add the endpoint to this registry - * 4. The validationFields will automatically be used by NetworkHelper methods - * - * VALIDATION FIELDS: - * - Use dot notation for nested fields (e.g., 'patient.fullName') - * - Include all fields that should be validated for data consistency - * - Different endpoints can have different validation requirements - * - Fields are endpoint-specific and stored in the schema definition - */ -export const ENDPOINT_REGISTRY = { - // Profile endpoints - 'profile-metadata-get': getProfileMetadataSchema, - 'profile-metadata-put': putProfileMetadataSchema, - 'profile-patient-data-get': profileGetPatientDataSchema, - 'profile-metrics-get': profileGetMetricsSchema, - 'profile-message-notes-get': profileGetMessageNotesSchema, - // Patient data endpoints - 'patient-data-get': getPatientDataSchema, - 'patient-data-upload': uploadPatientDataSchema, - // Auth endpoints - 'auth-login': loginSchema, - 'auth-logout': logoutSchema, - 'auth-refresh-token': refreshTokenSchema, - // Add more endpoints as needed... - // 'clinic-get': clinicGetSchema, - // 'clinic-update': clinicUpdateSchema, -}; -/** - * Get endpoint schema by name - */ -export function getEndpointSchema(endpointName) { - const schema = ENDPOINT_REGISTRY[endpointName]; - if (!schema) { - throw new Error(`Endpoint schema not found: ${endpointName}`); - } - return schema; -} diff --git a/dist/endpoint-schema/patient-data-endpoints.d.ts b/dist/endpoint-schema/patient-data-endpoints.d.ts deleted file mode 100644 index 5562b5b..0000000 --- a/dist/endpoint-schema/patient-data-endpoints.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { EndpointSchema } from './profile-endpoints'; -/** - * Schema for patient data GET endpoint - */ -export declare const getPatientDataSchema: EndpointSchema; -/** - * Schema for uploading patient data - */ -export declare const uploadPatientDataSchema: EndpointSchema; -/** - * Schema for getting patient settings - */ -export declare const getPatientSettingsSchema: EndpointSchema; diff --git a/dist/endpoint-schema/patient-data-endpoints.js b/dist/endpoint-schema/patient-data-endpoints.js deleted file mode 100644 index fa48d94..0000000 --- a/dist/endpoint-schema/patient-data-endpoints.js +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Schema for patient data GET endpoint - */ -export const getPatientDataSchema = { - url: /\/v1\/patients\/[^/]+\/data$/, - method: 'GET', - expectedStatus: 200, - responseSchema: { - data: 'object', - meta: { - count: 'number', - size: 'number', - }, - }, - validationFields: ['data', 'meta.count', 'meta.size'], -}; -/** - * Schema for uploading patient data - */ -export const uploadPatientDataSchema = { - url: /\/v1\/patients\/[^/]+\/data$/, - method: 'POST', - expectedStatus: 201, - requestSchema: { - data: 'object', - deviceId: 'string', - uploadId: 'string', - }, - responseSchema: { - id: 'string', - success: 'boolean', - }, - validationFields: ['id', 'success'], -}; -/** - * Schema for getting patient settings - */ -export const getPatientSettingsSchema = { - url: /\/v1\/patients\/[^/]+\/settings$/, - method: 'GET', - expectedStatus: 200, - responseSchema: { - bgTarget: { - low: 'number', - high: 'number', - }, - units: { - bg: 'string', - }, - siteChangeSource: 'string', - }, - validationFields: ['bgTarget.low', 'bgTarget.high', 'units.bg', 'siteChangeSource'], -}; diff --git a/dist/endpoint-schema/profile-endpoints.d.ts b/dist/endpoint-schema/profile-endpoints.d.ts deleted file mode 100644 index d1d3739..0000000 --- a/dist/endpoint-schema/profile-endpoints.d.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Schema definition for API endpoints - */ -export interface EndpointSchema { - url: string | RegExp; - method: string; - expectedStatus?: number; - responseSchema?: any; - requestSchema?: any; - validationFields?: string[]; - requiredFields?: string[]; -} -/** - * Schema for profile metadata GET endpoint - */ -export declare const getProfileMetadataSchema: EndpointSchema; -/** - * Schema for profile metadata PUT endpoint - */ -export declare const putProfileMetadataSchema: EndpointSchema; -/** - * Schema for patient data GET endpoint - */ -export declare const getPatientDataSchema: EndpointSchema; -/** - * Schema for metrics/analytics endpoint - */ -export declare const getMetricsSchema: EndpointSchema; -/** - * Schema for message notes endpoint - */ -export declare const getMessageNotesSchema: EndpointSchema; diff --git a/dist/endpoint-schema/profile-endpoints.js b/dist/endpoint-schema/profile-endpoints.js deleted file mode 100644 index 3e2101c..0000000 --- a/dist/endpoint-schema/profile-endpoints.js +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Schema for profile metadata GET endpoint - */ -export const getProfileMetadataSchema = { - url: /\/metadata\/.*\/profile$/, - method: 'GET', - expectedStatus: 200, - responseSchema: { - fullName: 'string', - patient: 'object', - }, - validationFields: [ - 'fullName', - 'patient.fullName', - 'patient.birthday', - 'patient.diagnosisDate', - 'patient.diagnosisType', - 'patient.targetDevices', - 'patient.targetTimezone', - 'patient.about', - 'patient.isOtherPerson', - 'patient.mrn', - 'patient.biologicalSex', - 'email', - 'patient.email', - 'patient.emails', - 'emails', - ], - requiredFields: [ - 'fullName', // Profile endpoint must have fullName - ], -}; -/** - * Schema for profile metadata PUT endpoint - */ -export const putProfileMetadataSchema = { - url: /\/metadata\/.*\/profile$/, - method: 'PUT', - expectedStatus: 200, - requestSchema: { - fullName: 'string', - patient: 'object', - }, - responseSchema: { - fullName: 'string', - patient: 'object', - }, - validationFields: [ - 'fullName', - 'patient.fullName', - 'patient.birthday', - 'patient.diagnosisDate', - 'patient.diagnosisType', - 'patient.targetDevices', - 'patient.targetTimezone', - 'patient.about', - 'patient.isOtherPerson', - 'patient.mrn', - 'patient.biologicalSex', - 'email', - 'patient.email', - 'patient.emails', - 'emails', - ], - requiredFields: [ - 'fullName', // Profile endpoint must have fullName - ], -}; -/** - * Schema for patient data GET endpoint - */ -export const getPatientDataSchema = { - url: /\/data\/[^/]+\?.*$/, - method: 'GET', - expectedStatus: 200, - responseSchema: { - // Patient data array - structure will vary - }, - validationFields: [ - // Data array validation fields would go here based on specific data types - ], -}; -/** - * Schema for metrics/analytics endpoint - */ -export const getMetricsSchema = { - url: /\/metrics\/thisuser\/.*$/, - method: 'GET', - expectedStatus: 200, - validationFields: [ - // Metrics-specific validation fields would go here - ], -}; -/** - * Schema for message notes endpoint - */ -export const getMessageNotesSchema = { - url: /\/message\/notes\/[^/]+\?.*$/, - method: 'GET', - expectedStatus: 200, // We'll handle 404 as acceptable in the validation logic - validationFields: [ - // Message notes validation fields would go here - ], -}; diff --git a/dist/page-objects/LoginPage.d.ts b/dist/page-objects/LoginPage.d.ts deleted file mode 100644 index 8a0e079..0000000 --- a/dist/page-objects/LoginPage.d.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Locator, Page } from '@playwright/test'; -/** - * @class - * @property {Page} page - * @property {Locator} emailInput - * @property {Locator} nextButton - * @property {Locator} passwordInput - * @property {Locator} loginButton - */ -export default class LoginPage { - page: Page; - emailInput: Locator; - nextButton: Locator; - passwordInput: Locator; - loginButton: Locator; - /** - * @param {Page} page - */ - constructor(page: Page); - /** - * Navigate to the login page - * @returns {Promise} - */ - goto(): Promise; - /** - * Login to the application - * @param {string} email - User's email - * @param {string} password - User's password - * @returns {Promise} - */ - login(email: string, password: string): Promise; -} diff --git a/dist/page-objects/LoginPage.js b/dist/page-objects/LoginPage.js deleted file mode 100644 index 0d3b7c3..0000000 --- a/dist/page-objects/LoginPage.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @class - * @property {Page} page - * @property {Locator} emailInput - * @property {Locator} nextButton - * @property {Locator} passwordInput - * @property {Locator} loginButton - */ -export default class LoginPage { - /** - * @param {Page} page - */ - constructor(page) { - this.page = page; - this.emailInput = page.getByRole('textbox', { name: 'Email' }); - this.nextButton = page.getByRole('button', { name: 'Next' }); - this.passwordInput = page.getByRole('textbox', { name: 'Password' }); - this.loginButton = page.getByRole('button', { name: 'Log In' }); - } - /** - * Navigate to the login page - * @returns {Promise} - */ - async goto() { - await this.page.goto(`/`); - } - /** - * Login to the application - * @param {string} email - User's email - * @param {string} password - User's password - * @returns {Promise} - */ - // @step("When the user logs in to the application") - async login(email, password) { - await this.emailInput.fill(email); - await this.nextButton.click(); - await this.passwordInput.fill(password); - await this.loginButton.click(); - await this.page.setViewportSize({ width: 1920, height: 1080 }); - } -} diff --git a/dist/page-objects/account/AccountNavigation.d.ts b/dist/page-objects/account/AccountNavigation.d.ts deleted file mode 100644 index eabf680..0000000 --- a/dist/page-objects/account/AccountNavigation.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Locator, Page } from '@playwright/test'; -export interface AccountNavVerify { - name: string; - link: Locator; - verifyURL: string; - verifyElement: Locator; - closeButton?: Locator; -} -export default class AccountNav { - readonly page: Page; - readonly pages: Record<'AccountNav' | 'PrivateWorkspace' | 'AccountSettings' | 'ManageWorkspaces' | 'Logout', AccountNavVerify>; - constructor(page: Page); - /** - * Navigate to a page in the account navigation menu by key. - * Example: await accountNav.navigateTo('AccountSettings'); - */ - navigateTo(pageKey: keyof AccountNav['pages']): Promise; -} diff --git a/dist/page-objects/account/AccountNavigation.js b/dist/page-objects/account/AccountNavigation.js deleted file mode 100644 index ef4dfe6..0000000 --- a/dist/page-objects/account/AccountNavigation.js +++ /dev/null @@ -1,59 +0,0 @@ -export default class AccountNav { - constructor(page) { - this.page = page; - this.pages = { - AccountNav: { - name: 'AccountNav', - link: page.locator('button#navigation-menu-trigger'), // Use exact ID to identify menu trigger - verifyURL: '', - verifyElement: page - .locator('button.navigation-menu-option') - .filter({ hasText: 'Private Workspace' }), - }, - PrivateWorkspace: { - name: 'PrivateWorkspace', - link: page - .locator('button.navigation-menu-option') - .filter({ hasText: 'Private Workspace' }), - verifyURL: 'workspaces', - verifyElement: page.getByText('View data for:'), - }, - AccountSettings: { - name: 'AccountSettings', - link: page - .locator('#navigationMenu button.navigation-menu-option') - .filter({ hasText: 'Account Settings' }), - verifyURL: 'account', - verifyElement: page.locator('.profile-subnav-title').getByText('Account'), // Target the specific Account title element - }, - ManageWorkspaces: { - name: 'ManageWorkspaces', - link: page - .locator('#navigationMenu button.navigation-menu-option') - .filter({ hasText: 'Manage Workspaces' }), - verifyURL: 'workspaces', - verifyElement: page.getByText('Welcome To Tidepool'), // Should land back on the workspace selection page - }, - Logout: { - name: 'Logout', - link: page - .locator('#navigationMenu button.navigation-menu-option') - .filter({ hasText: 'Logout' }), - verifyURL: 'login', - verifyElement: page.getByRole('heading', { name: 'Log in to Tidepool' }), - }, - }; - } - /** - * Navigate to a page in the account navigation menu by key. - * Example: await accountNav.navigateTo('AccountSettings'); - */ - async navigateTo(pageKey) { - // Always open the navigation menu first - await this.pages.AccountNav.link.click(); - // Then click the desired page - await this.pages[pageKey].link.click(); - // Wait for the verification element to appear - await this.pages[pageKey].verifyElement.waitFor({ state: 'visible', timeout: 5000 }); - } -} diff --git a/dist/page-objects/account/AccountSettingsPage.d.ts b/dist/page-objects/account/AccountSettingsPage.d.ts deleted file mode 100644 index 6250bf8..0000000 --- a/dist/page-objects/account/AccountSettingsPage.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Page, Locator } from '@playwright/test'; -export declare class AccountSettingsPage { - readonly page: Page; - readonly emailInput: Locator; - readonly saveButton: Locator; - readonly saveConfirm: Locator; - constructor(page: Page); -} -export default AccountSettingsPage; diff --git a/dist/page-objects/account/AccountSettingsPage.js b/dist/page-objects/account/AccountSettingsPage.js deleted file mode 100644 index 2247c70..0000000 --- a/dist/page-objects/account/AccountSettingsPage.js +++ /dev/null @@ -1,9 +0,0 @@ -export class AccountSettingsPage { - constructor(page) { - this.page = page; - this.emailInput = page.getByRole('textbox', { name: 'Email' }); - this.saveButton = page.getByRole('button', { name: /save/i }); - this.saveConfirm = page.getByText(/All Changes Saved/i); - } -} -export default AccountSettingsPage; diff --git a/dist/page-objects/clinician/ClinicCreationPage.d.ts b/dist/page-objects/clinician/ClinicCreationPage.d.ts deleted file mode 100644 index b21595a..0000000 --- a/dist/page-objects/clinician/ClinicCreationPage.d.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Locator, Page } from '@playwright/test'; -export default class ClinicCreationPage { - page: Page; - url: string; - pageHeader: Locator; - pageDescription: Locator; - clinicNameInput: Locator; - teamTypeDropdown: Locator; - countryDropdown: Locator; - stateDropdown: Locator; - addressInput: Locator; - cityInput: Locator; - zipCodeInput: Locator; - websiteInput: Locator; - mgdlRadio: Locator; - mmolRadio: Locator; - adminAcknowledgeCheckbox: Locator; - backButton: Locator; - createWorkspaceButton: Locator; - constructor(page: Page); - /** - * Navigate to the clinic creation page - */ - goto(): Promise; - /** - * Fill the clinic creation form with required information - * @param clinicName - Name of the clinic - * @param teamType - Type of the team - * @param state - State (for US clinics) - * @param address - Street address - * @param city - City name - * @param zipCode - Zip/Postal code - * @param website - Optional website URL - */ - fillClinicForm({ clinicName, teamType, state, address, city, zipCode, website, }: { - clinicName: string; - teamType?: string; - state?: string; - address?: string; - city?: string; - zipCode?: string; - website?: string; - }): Promise; - /** - * Select blood glucose units - * @param unit - "mg/dL" or "mmol/L" - */ - selectBloodGlucoseUnit(unit: 'mg/dL' | 'mmol/L'): Promise; - /** - * Create a clinic by filling the form and submitting - * @param clinicName - Name of the clinic to create (required) - * @param formData - Optional form data (uses defaults if not provided) - */ - createClinic(clinicName: string, formData?: Omit[0], 'clinicName'>): Promise; -} diff --git a/dist/page-objects/clinician/ClinicCreationPage.js b/dist/page-objects/clinician/ClinicCreationPage.js deleted file mode 100644 index 4a0a94f..0000000 --- a/dist/page-objects/clinician/ClinicCreationPage.js +++ /dev/null @@ -1,81 +0,0 @@ -export default class ClinicCreationPage { - constructor(page) { - this.url = '/clinic-details/new'; - this.page = page; - // Page header elements - this.pageHeader = page.getByText('Create your Clinic Workspace'); - this.pageDescription = page.getByText('The information below will be displayed along with your name'); - // Form input fields - this.clinicNameInput = page.getByLabel('Clinic Name'); - this.teamTypeDropdown = page.getByRole('combobox', { name: 'What best describes your team?' }); - this.countryDropdown = page.getByRole('combobox', { name: 'Country' }); - this.stateDropdown = page.getByRole('combobox', { name: 'State' }); - this.addressInput = page.getByLabel('Address'); - this.cityInput = page.getByLabel('City'); - this.zipCodeInput = page.getByLabel('Zip code'); - this.websiteInput = page.getByLabel('Website (optional)'); - // Blood glucose units radio buttons - this.mgdlRadio = page.getByLabel('mg/dL'); - this.mmolRadio = page.getByLabel('mmol/L'); - // Acknowledgement checkbox - this.adminAcknowledgeCheckbox = page.getByRole('checkbox', { - name: 'By creating this clinic, your Tidepool account will become the default administrator', - }); - // Action buttons - this.backButton = page.getByRole('button', { name: 'Back' }); - this.createWorkspaceButton = page.getByRole('button', { name: 'Create Workspace' }); - } - /** - * Navigate to the clinic creation page - */ - async goto() { - await this.page.goto(this.url); - } - /** - * Fill the clinic creation form with required information - * @param clinicName - Name of the clinic - * @param teamType - Type of the team - * @param state - State (for US clinics) - * @param address - Street address - * @param city - City name - * @param zipCode - Zip/Postal code - * @param website - Optional website URL - */ - async fillClinicForm({ clinicName, teamType = 'Provider Practice', state = 'California', address = '123 Test Street', city = 'Test City', zipCode = '12345', website = '', }) { - // Fill in clinic name - await this.clinicNameInput.fill(clinicName); - // Select team type - await this.teamTypeDropdown.selectOption(teamType); - // Select state (US is selected by default) - await this.stateDropdown.selectOption(state); - // Fill in address details - await this.addressInput.fill(address); - await this.cityInput.fill(city); - await this.zipCodeInput.fill(zipCode); - // Fill in optional website if provided - if (website) { - await this.websiteInput.fill(website); - } - } - /** - * Select blood glucose units - * @param unit - "mg/dL" or "mmol/L" - */ - async selectBloodGlucoseUnit(unit) { - if (unit === 'mg/dL') { - await this.mgdlRadio.check(); - } - else { - await this.mmolRadio.check(); - } - } - /** - * Create a clinic by filling the form and submitting - * @param clinicName - Name of the clinic to create (required) - * @param formData - Optional form data (uses defaults if not provided) - */ - async createClinic(clinicName, formData) { - await this.fillClinicForm({ clinicName, ...formData }); - await this.createWorkspaceButton.click(); - } -} diff --git a/dist/page-objects/clinician/ClinicianDashboardPage.d.ts b/dist/page-objects/clinician/ClinicianDashboardPage.d.ts deleted file mode 100644 index 5f1113a..0000000 --- a/dist/page-objects/clinician/ClinicianDashboardPage.d.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Locator, Page } from '@playwright/test'; -declare class ClinicianDashboardPage { - page: Page; - url: string; - name: string; - readonly addNewPatientButton: Locator; - readonly searchInput: Locator; - readonly patientListTable: Locator; - readonly addPatientDialog: Locator; - readonly addPatientDialog_fullNameInput: Locator; - readonly addPatientDialog_birthdateInput: Locator; - readonly addPatientDialog_addButton: Locator; - readonly bringDataDialog: Locator; - readonly bringDataDialog_doneButton: Locator; - constructor(page: Page); - /** - * Opens the Add Patient dialog and fills in the patient details. - * @param name - The full name of the patient. - * @param birthdate - The birthdate of the patient (e.g., MM/DD/YYYY). - */ - openAndFillAddPatientDialog(name: string, birthdate: string): Promise; - /** - * Clicks the Add Patient button in the dialog to submit the new patient. - */ - submitAddPatientDialog(): Promise; - /** - * Closes the Bring Data into Tidepool dialog by clicking Done. - */ - closeBringDataDialog(): Promise; - /** - * Searches for a patient in the list. - * @param name - The name of the patient to search for. - */ - searchForPatient(name: string): Promise; - /** - * Gets the locator for a patient cell in the table by name. - * @param name - The name of the patient. - * @returns Locator for the table cell containing the patient's name. - */ - getPatientCellByName(name: string): Locator; - /** - * Waits for the main elements of the Clinic Workspace page to be visible. - */ - waitForLoadState(): Promise; -} -export default ClinicianDashboardPage; diff --git a/dist/page-objects/clinician/ClinicianDashboardPage.js b/dist/page-objects/clinician/ClinicianDashboardPage.js deleted file mode 100644 index 558fd9b..0000000 --- a/dist/page-objects/clinician/ClinicianDashboardPage.js +++ /dev/null @@ -1,77 +0,0 @@ -class ClinicianDashboardPage { - constructor(page) { - this.url = '/clinic-workspace'; - this.name = 'ClinicianDashboardPage'; // Added name for step decorator context - this.page = page; - // Main page locators - this.addNewPatientButton = page.getByRole('button', { name: 'Add New Patient' }); - this.searchInput = page.getByRole('textbox', { name: 'Search' }); - this.patientListTable = page.getByRole('table', { name: 'peopletablelabel' }); - // Add Patient Dialog locators - this.addPatientDialog = page.getByRole('dialog', { name: /Add New Patient Account/i }); - this.addPatientDialog_fullNameInput = this.addPatientDialog.getByRole('textbox', { - name: 'Full Name', - }); - this.addPatientDialog_birthdateInput = this.addPatientDialog.getByRole('textbox', { - name: 'Birthdate', - }); - this.addPatientDialog_addButton = this.addPatientDialog.getByRole('button', { - name: 'Add Patient', - }); - // Bring Data Dialog locators - this.bringDataDialog = page.getByRole('dialog', { name: /Bring Data into Tidepool/i }); - this.bringDataDialog_doneButton = this.bringDataDialog.getByRole('button', { name: 'Done' }); - } - /** - * Opens the Add Patient dialog and fills in the patient details. - * @param name - The full name of the patient. - * @param birthdate - The birthdate of the patient (e.g., MM/DD/YYYY). - */ - async openAndFillAddPatientDialog(name, birthdate) { - await this.addNewPatientButton.click(); - await this.addPatientDialog.waitFor({ state: 'visible' }); - await this.addPatientDialog_fullNameInput.fill(name); - await this.addPatientDialog_birthdateInput.fill(birthdate); - } - /** - * Clicks the Add Patient button in the dialog to submit the new patient. - */ - async submitAddPatientDialog() { - await this.addPatientDialog_addButton.click(); - } - /** - * Closes the Bring Data into Tidepool dialog by clicking Done. - */ - async closeBringDataDialog() { - await this.bringDataDialog.waitFor({ state: 'visible' }); - await this.bringDataDialog_doneButton.click(); - await this.bringDataDialog.waitFor({ state: 'hidden' }); - } - /** - * Searches for a patient in the list. - * @param name - The name of the patient to search for. - */ - async searchForPatient(name) { - await this.searchInput.fill(name); - // Press Enter to trigger search - await this.searchInput.press('Enter'); - // Wait longer for search to process and results to load - await this.page.waitForTimeout(3000); - } - /** - * Gets the locator for a patient cell in the table by name. - * @param name - The name of the patient. - * @returns Locator for the table cell containing the patient's name. - */ - getPatientCellByName(name) { - // Use exact match to avoid multiple matches with similar names - return this.patientListTable.getByRole('cell', { name, exact: true }); - } - /** - * Waits for the main elements of the Clinic Workspace page to be visible. - */ - async waitForLoadState() { - await this.addNewPatientButton.waitFor({ state: 'visible' }); - } -} -export default ClinicianDashboardPage; diff --git a/dist/page-objects/clinician/ClinicianNavigation.d.ts b/dist/page-objects/clinician/ClinicianNavigation.d.ts deleted file mode 100644 index d3996f9..0000000 --- a/dist/page-objects/clinician/ClinicianNavigation.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Locator, Page } from '@playwright/test'; -export interface WorkspaceNavVerify { - name: string; - link: Locator; - verifyURL: string; - verifyElement: Locator; -} -export interface PageNavVerify { - name: string; - link: Locator; - verifyURL: string; - verifyElement: Locator; - closeButton?: Locator; -} -export default class ClinicianNav { - readonly page: Page; - readonly workspaces: Record<'AdminClinicBase' | 'AdminClinicEnterprise' | 'MemberClinicBase' | 'MemberClinicEnterprise' | 'NonMemberClinicBase' | 'NonMemberClinicEnterprise' | 'PartnerClinicBase' | 'PartnerClinicEnterprise', WorkspaceNavVerify>; - readonly pages: Record<'PatientList' | 'WorkspaceSettings' | 'AddPatient' | 'Profile' | 'ProfileEdit', PageNavVerify>; - constructor(page: Page); -} diff --git a/dist/page-objects/clinician/ClinicianNavigation.js b/dist/page-objects/clinician/ClinicianNavigation.js deleted file mode 100644 index 5a7502e..0000000 --- a/dist/page-objects/clinician/ClinicianNavigation.js +++ /dev/null @@ -1,116 +0,0 @@ -export default class ClinicianNav { - constructor(page) { - this.page = page; - // Define hardcoded workspace configurations (matching PatientNavigation approach) - this.workspaces = { - AdminClinicBase: { - name: 'Admin Clinic (Base)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Admin Clinic (Base) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Admin Clinic (Base)' }), - }, - AdminClinicEnterprise: { - name: 'Admin Clinic (Enterprise)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Admin Clinic (Enterprise) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Admin Clinic (Enterprise)' }), - }, - MemberClinicBase: { - name: 'Member Clinic (Base)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Member Clinic (Base) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Member Clinic (Base)' }), - }, - MemberClinicEnterprise: { - name: 'Member Clinic (Enterprise)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Member Clinic (Enterprise) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Member Clinic (Enterprise)' }), - }, - NonMemberClinicBase: { - name: 'Non-Member Clinic (Base)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Non-Member Clinic (Base) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Non-Member Clinic (Base)' }), - }, - NonMemberClinicEnterprise: { - name: 'Non-Member Clinic (Enterprise)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Non-Member Clinic (Enterprise) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Non-Member Clinic (Enterprise)' }), - }, - PartnerClinicBase: { - name: 'Partner Clinic (Base)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Partner Clinic (Base) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Partner Clinic (Base)' }), - }, - PartnerClinicEnterprise: { - name: 'Partner Clinic (Enterprise)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Partner Clinic (Enterprise) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Partner Clinic (Enterprise)' }), - }, - }; - // Define clinician page navigation (matching PatientNavigation format) - this.pages = { - PatientList: { - name: 'PatientList', - link: page.getByRole('link', { name: 'Patients' }), - verifyURL: 'clinic-workspace/patients', - verifyElement: page.getByRole('heading', { name: 'Patients' }), - }, - WorkspaceSettings: { - name: 'WorkspaceSettings', - link: page.getByRole('link', { name: 'Workspace Settings' }), - verifyURL: 'clinic-workspace/workspace/settings', - verifyElement: page.getByRole('heading', { name: 'Workspace Settings' }), - }, - AddPatient: { - name: 'AddPatient', - link: page.getByRole('button', { name: 'Add Patient' }), - verifyURL: 'clinic-workspace/patients/add', - verifyElement: page.getByRole('heading', { name: 'Add Patient' }), - }, - Profile: { - name: 'Profile', - link: page - .getByRole('button', { name: 'Patient Profile Profile' }) - .or(page.getByRole('tab', { name: 'Profile' })) - .or(page.getByRole('link', { name: 'Profile' })) - .or(page.getByRole('button', { name: 'Profile' })), - verifyURL: 'profile', - verifyElement: page - .getByRole('button', { name: 'Edit' }) - .or(page.getByRole('button', { name: 'Edit Profile' })), - }, - ProfileEdit: { - name: 'ProfileEdit', - link: page - .getByRole('button', { name: 'Edit' }) - .or(page.getByRole('button', { name: 'Edit Profile' })), - verifyURL: 'profile', - verifyElement: page - .getByRole('button', { name: 'Save changes' }) - .or(page.getByRole('button', { name: 'Save Profile' })) - .or(page.getByRole('button', { name: 'Save' })), - }, - }; - } -} diff --git a/dist/page-objects/clinician/WorkspaceSettingsPage.d.ts b/dist/page-objects/clinician/WorkspaceSettingsPage.d.ts deleted file mode 100644 index 666bb9a..0000000 --- a/dist/page-objects/clinician/WorkspaceSettingsPage.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Locator, Page } from '@playwright/test'; -export default class ClinicAdminPage { - readonly clinicDetailsHeader: Locator; - readonly editDetailsButton: Locator; - readonly editClinicModal: Locator; - readonly editClinicModalTitle: Locator; - readonly addressInput: Locator; - readonly saveChangesButton: Locator; - readonly clinicDetailsSection: Locator; - url: string; - name: string; - page: Page; - constructor(page: Page); - /** - * Waits for essential elements of the Clinic Admin page to be loaded. - */ - waitForLoadState(): Promise; -} diff --git a/dist/page-objects/clinician/WorkspaceSettingsPage.js b/dist/page-objects/clinician/WorkspaceSettingsPage.js deleted file mode 100644 index aec2426..0000000 --- a/dist/page-objects/clinician/WorkspaceSettingsPage.js +++ /dev/null @@ -1,26 +0,0 @@ -export default class ClinicAdminPage { - constructor(page) { - this.url = '/clinic-admin'; - this.name = 'ClinicAdminPage'; // Added name for step decorator context - this.page = page; - this.clinicDetailsHeader = page.getByText('Workspace Settings'); - // Assuming the edit button is specifically associated with the details section - this.editDetailsButton = page.getByRole('button', { name: 'Edit' }); - this.editClinicModal = page.getByRole('dialog'); // General dialog selector - this.editClinicModalTitle = this.editClinicModal.getByRole('heading', { - name: 'Edit Workspace Details', - }); - this.addressInput = this.editClinicModal.getByLabel('Address', { exact: true }); // Use exact label match - this.saveChangesButton = this.editClinicModal.getByRole('button', { name: 'Save Changes' }); - // Assuming the details are within a specific container section related to the header - this.clinicDetailsSection = page.locator('div:has(> span:text-is("Workspace Settings")) + div'); - } - /** - * Waits for essential elements of the Clinic Admin page to be loaded. - */ - async waitForLoadState() { - await this.page.waitForLoadState(); // Wait for base elements like header/footer - await this.clinicDetailsHeader.waitFor({ state: 'visible', timeout: 40000 }); - await this.editDetailsButton.waitFor({ state: 'visible', timeout: 10000 }); - } -} diff --git a/dist/page-objects/clinician/WorkspacesPage.d.ts b/dist/page-objects/clinician/WorkspacesPage.d.ts deleted file mode 100644 index 44f2a64..0000000 --- a/dist/page-objects/clinician/WorkspacesPage.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Locator, Page } from '@playwright/test'; -export default class WorkspacesPage { - readonly page: Page; - readonly url: string; - readonly header: Locator; - readonly subHeader: Locator; - readonly createClinicButton: Locator; - constructor(page: Page); - goto(): Promise; - visitFirstClinic(): Promise; - /** - * Visit a clinic by name - * @param clinicName - The name of the clinic to visit - */ - visitClinic(clinicName: string): Promise; -} diff --git a/dist/page-objects/clinician/WorkspacesPage.js b/dist/page-objects/clinician/WorkspacesPage.js deleted file mode 100644 index 1c9cc60..0000000 --- a/dist/page-objects/clinician/WorkspacesPage.js +++ /dev/null @@ -1,30 +0,0 @@ -import env from '../../utilities/env'; -export default class WorkspacesPage { - constructor(page) { - this.url = `${env.BASE_URL}/workspaces`; - this.page = page; - this.header = page.getByRole('heading', { name: 'Clinic Workspace' }); - this.subHeader = page.getByRole('paragraph', { - name: 'View, share and manage patient data', - }); - this.createClinicButton = page.getByRole('button', { - name: 'Create a New Clinic', - }); - } - async goto() { - await this.page.goto(this.url); - } - async visitFirstClinic() { - await this.page.getByRole('button', { name: 'Go To Workspace' }).first().click(); - } - /** - * Visit a clinic by name - * @param clinicName - The name of the clinic to visit - */ - async visitClinic(clinicName) { - // find child element with text and filter by parent element with class - const child = this.page.getByText(clinicName); - const parent = this.page.locator('.workspace-item-clinic').filter({ has: child }); - await parent.getByRole('button', { name: 'Go To Workspace' }).first().click(); - } -} diff --git a/dist/page-objects/clinician/components/navigation-menu.section.d.ts b/dist/page-objects/clinician/components/navigation-menu.section.d.ts deleted file mode 100644 index 203acf6..0000000 --- a/dist/page-objects/clinician/components/navigation-menu.section.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Locator, Page } from '@playwright/test'; -export default class NavigationMenu { - page: Page; - container: Locator; - buttons: { - trigger: Locator; - menu: { - privateWorkspace: Locator; - accountSettings: Locator; - logout: Locator; - }; - }; - constructor(page: Page); - open(): Promise; - close(): Promise; -} diff --git a/dist/page-objects/clinician/components/navigation-menu.section.js b/dist/page-objects/clinician/components/navigation-menu.section.js deleted file mode 100644 index c999acd..0000000 --- a/dist/page-objects/clinician/components/navigation-menu.section.js +++ /dev/null @@ -1,24 +0,0 @@ -export default class NavigationMenu { - constructor(page) { - this.page = page; - this.container = page.locator('div#navigation-menu'); - this.buttons = { - trigger: this.container.locator('#navigation-menu-trigger'), - menu: { - privateWorkspace: this.container.getByRole('button', { - name: 'Private Workspace', - }), - accountSettings: this.container.getByRole('button', { - name: 'Account Settings', - }), - logout: this.container.getByRole('button', { name: 'Logout' }), - }, - }; - } - async open() { - await this.buttons.trigger.click(); - } - async close() { - await this.buttons.trigger.click(); - } -} diff --git a/dist/page-objects/clinician/components/navigation.section.d.ts b/dist/page-objects/clinician/components/navigation.section.d.ts deleted file mode 100644 index eea6afb..0000000 --- a/dist/page-objects/clinician/components/navigation.section.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Locator, Page } from '@playwright/test'; -import NavigationMenu from './navigation-menu.section'; -export default class NavigationSection { - page: Page; - container: Locator; - menu: NavigationMenu; - buttons: { - viewData: Locator; - patientProfile: Locator; - share: Locator; - uploadData: Locator; - }; - constructor(page: Page); -} diff --git a/dist/page-objects/clinician/components/navigation.section.js b/dist/page-objects/clinician/components/navigation.section.js deleted file mode 100644 index e75d2a6..0000000 --- a/dist/page-objects/clinician/components/navigation.section.js +++ /dev/null @@ -1,16 +0,0 @@ -import NavigationMenu from './navigation-menu.section'; -export default class NavigationSection { - constructor(page) { - this.page = page; - this.container = page.locator('div#navPatientHeader'); - this.menu = new NavigationMenu(page); - this.buttons = { - viewData: this.container.getByRole('button', { name: 'View Data' }), - patientProfile: this.container.getByRole('button', { - name: 'Patient Profile', - }), - share: this.container.getByRole('button', { name: 'Share' }), - uploadData: this.container.getByRole('button', { name: 'Upload Data' }), - }; - } -} diff --git a/dist/page-objects/patient/BasicsPage.d.ts b/dist/page-objects/patient/BasicsPage.d.ts deleted file mode 100644 index 009dbe7..0000000 --- a/dist/page-objects/patient/BasicsPage.d.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Locator, Page } from '@playwright/test'; -import PatientNav from '@pom/patient/PatientNavigation'; -import NavigationSection from '@components/navigation.section'; -interface CalendarSection { - container: Locator; - firstDayOfData: Locator; - calendarDayhover: { - el: Locator; - text(): Promise; - }; -} -interface Stat { - container: Locator; - header: Locator; - hoverBar: Locator; - hoverBarLabel: Locator; -} -interface StatsSidebar { - toggleContainer: Locator; - toggleTo(toState: 'BGM' | 'CGM'): Promise; - timeInRange: Stat; - readingsInRange: Stat; - averageGlucose: Stat; - totalInsulin: Stat; - carbs: Stat; - standardDev: Stat; - coefficientOfVariation: Stat; - sensorUsage: Stat; - glucoseManagementIndicator: Stat; - averageDailyDose: Stat; -} -interface TubingPrimeSection extends CalendarSection { - settings: Locator; - settingsOption: { - fillTubing: Locator; - fillCannula: Locator; - }; - tubingIcons: Locator; - cannulaIcons: Locator; - filledDay: Locator; -} -export default class PatientDataBasicsPage { - page: Page; - url: string; - emailInput: Locator; - navigationBar: NavigationSection; - navigationSubMenu: PatientNav; - headerBgReading: Locator; - headerBolusing: Locator; - statsSidebar: StatsSidebar; - bgReadingsSection: CalendarSection; - bolusingSection: CalendarSection; - tubingPrimeSection: TubingPrimeSection; - basalsSection: CalendarSection; - constructor(page: Page); - goto(): Promise; -} -export {}; diff --git a/dist/page-objects/patient/BasicsPage.js b/dist/page-objects/patient/BasicsPage.js deleted file mode 100644 index 067a865..0000000 --- a/dist/page-objects/patient/BasicsPage.js +++ /dev/null @@ -1,138 +0,0 @@ -var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) { - var useValue = arguments.length > 2; - for (var i = 0; i < initializers.length; i++) { - value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); - } - return useValue ? value : void 0; -}; -var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) { - function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; } - var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; - var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null; - var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); - var _, done = false; - for (var i = decorators.length - 1; i >= 0; i--) { - var context = {}; - for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; - for (var p in contextIn.access) context.access[p] = contextIn.access[p]; - context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); }; - var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context); - if (kind === "accessor") { - if (result === void 0) continue; - if (result === null || typeof result !== "object") throw new TypeError("Object expected"); - if (_ = accept(result.get)) descriptor.get = _; - if (_ = accept(result.set)) descriptor.set = _; - if (_ = accept(result.init)) initializers.unshift(_); - } - else if (_ = accept(result)) { - if (kind === "field") initializers.unshift(_); - else descriptor[key] = _; - } - } - if (target) Object.defineProperty(target, contextIn.name, descriptor); - done = true; -}; -import { step } from '@fixtures/base'; -import PatientNav from '@pom/patient/PatientNavigation'; -import NavigationSection from '@components/navigation.section'; -function createSection(page, selector) { - const parsedSelector = selector === 'tubing-primes' ? 'siteChanges' : selector; - const container = page.locator(`.Calendar-container-${parsedSelector}`); - return { - container, - firstDayOfData: container.locator(`.Calendar-day--${parsedSelector}.Calendar-day`).first(), - calendarDayhover: { - el: container.locator('.Calendar-day--HOVER'), - async text() { - return container.locator('.Calendar-day--HOVER').locator('.Calendar-weekday').textContent(); - }, - }, - }; -} -/** - * helper function to create a stat object with locators for the container, header, hoverBar, and hoverBarLabel - */ -function createStat(page, selector) { - const container = page.locator(`#Stat--${selector}`); - return { - container, - header: container.locator('[class^="Stat--chartTitleText"]'), - hoverBar: container.locator('.HoverBar'), - hoverBarLabel: container.locator('.HoverBarLabel'), - }; -} -// list of sections in the stats sidebar -const statsSideBarSection = [ - 'timeInRange', - 'readingsInRange', - 'averageGlucose', - 'totalInsulin', - 'carbs', - 'standardDev', - 'coefficientOfVariation', - 'sensorUsage', - 'glucoseManagementIndicator', - 'totalInsulin', - 'averageDailyDose', -]; -let PatientDataBasicsPage = (() => { - var _a; - let _instanceExtraInitializers = []; - let _goto_decorators; - return _a = class PatientDataBasicsPage { - constructor(page) { - this.page = __runInitializers(this, _instanceExtraInitializers); - this.page = page; - this.url = '/patients/data/basics'; - this.emailInput = page.getByRole('textbox', { name: 'Email' }); - this.navigationBar = new NavigationSection(page); - this.navigationSubMenu = new PatientNav(page); - this.headerBgReading = page.getByRole('heading', { name: 'BG readings' }); - this.headerBolusing = page.getByRole('heading', { name: 'Bolusing' }); - this.statsSidebar = { - toggleContainer: page.locator('.toggle-container'), - async toggleTo(toState) { - const activeToggleState = await page - .locator(".toggle-container span[class*='TwoOptionToggle--active']") - .innerText(); - if (activeToggleState === 'BGM' && toState === 'CGM') { - await this.toggleContainer.click(); - } - else if (activeToggleState === 'CGM' && toState === 'BGM') { - await this.toggleContainer.click(); - } - }, - ...Object.fromEntries(statsSideBarSection.map(stat => [stat, createStat(page, stat)])), - }; - // charts - this.bgReadingsSection = createSection(page, 'fingersticks'); - this.bolusingSection = createSection(page, 'boluses'); - this.tubingPrimeSection = { - ...createSection(page, 'tubing-primes'), - settings: page.locator('.SiteChangeSelector-option').first(), - settingsOption: { - fillTubing: page.getByLabel('Tubing Fill'), - fillCannula: page.getByLabel('Cannula Fill'), - }, - tubingIcons: page.locator('.Change--tubing').first(), - cannulaIcons: page.locator('.Change--cannula').first(), - filledDay: createSection(page, 'tubing-primes') - .container.locator('.Calendar-day') - .filter({ has: page.locator('.Change-daysSince-text') }) - .first(), - }; - this.basalsSection = createSection(page, 'basals'); - } - async goto() { - await this.page.goto(this.url); - } - }, - (() => { - const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0; - _goto_decorators = [step('Navigate to the basics page')]; - __esDecorate(_a, null, _goto_decorators, { kind: "method", name: "goto", static: false, private: false, access: { has: obj => "goto" in obj, get: obj => obj.goto }, metadata: _metadata }, null, _instanceExtraInitializers); - if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata }); - })(), - _a; -})(); -export default PatientDataBasicsPage; diff --git a/dist/page-objects/patient/DailyPage.d.ts b/dist/page-objects/patient/DailyPage.d.ts deleted file mode 100644 index fd4c533..0000000 --- a/dist/page-objects/patient/DailyPage.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Page } from '@playwright/test'; -import DailyChartSection from '@components/daily-chart.js'; -import PatientNav from '@pom/patient/PatientNavigation.js'; -import NavigationSection from '@components/navigation.section.js'; -export default class PatientDataDailyPage { - page: Page; - navigationBar: NavigationSection; - navigationSubMenu: PatientNav; - dailyChart: DailyChartSection; - constructor(page: Page); -} diff --git a/dist/page-objects/patient/DailyPage.js b/dist/page-objects/patient/DailyPage.js deleted file mode 100644 index 01a824e..0000000 --- a/dist/page-objects/patient/DailyPage.js +++ /dev/null @@ -1,11 +0,0 @@ -import DailyChartSection from '@components/daily-chart.js'; -import PatientNav from '@pom/patient/PatientNavigation.js'; -import NavigationSection from '@components/navigation.section.js'; -export default class PatientDataDailyPage { - constructor(page) { - this.page = page; - this.navigationBar = new NavigationSection(page); - this.navigationSubMenu = new PatientNav(page); - this.dailyChart = new DailyChartSection(page); - } -} diff --git a/dist/page-objects/patient/PatientNavigation.d.ts b/dist/page-objects/patient/PatientNavigation.d.ts deleted file mode 100644 index 6ee3791..0000000 --- a/dist/page-objects/patient/PatientNavigation.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Locator, Page } from '@playwright/test'; -export interface PageNavVerify { - name: string; - link: Locator; - verifyURL: string; - verifyElement: Locator; - closeButton?: Locator; -} -export default class PatientNav { - readonly page: Page; - readonly pages: Record<'ViewData' | 'Basics' | 'ChartDateRange' | 'Daily' | 'ChartDate' | 'BGLog' | 'Trends' | 'Devices' | 'Print' | 'Profile' | 'ProfileEdit' | 'Share' | 'ShareData' | 'UploadData', PageNavVerify>; - constructor(page: Page); -} diff --git a/dist/page-objects/patient/PatientNavigation.js b/dist/page-objects/patient/PatientNavigation.js deleted file mode 100644 index 0c536a5..0000000 --- a/dist/page-objects/patient/PatientNavigation.js +++ /dev/null @@ -1,97 +0,0 @@ -export default class PatientNav { - // currentDate: Locator; - constructor(page) { - this.page = page; - this.pages = { - ViewData: { - name: 'ViewData', - link: page.getByRole('button', { name: 'View Data View' }), - verifyURL: 'data', - verifyElement: page.locator('div.patient-data-subnav-inner'), - }, - Basics: { - name: 'Basics', - link: page.getByRole('link', { name: 'Basics' }), - verifyURL: 'data/basics', - verifyElement: page.locator('.js-basics.patient-data-subnav-tablink.patient-data-subnav-active'), - }, - ChartDateRange: { - name: 'ChartDateRange', - link: page - .locator('button svg, .css-15vjjnj svg, [aria-label*="calendar"], [title*="calendar"]') - .first(), // Calendar icon in blue navigation bar - verifyURL: '', - verifyElement: page.locator('#printDateRangePickerInner').locator('*').first(), // Any content inside the dialog - closeButton: page.getByRole('button', { name: 'close dialog' }), - }, - Daily: { - name: 'Daily', - link: page.getByRole('link', { name: 'Daily' }), - verifyURL: 'data/daily', - verifyElement: page.locator('.js-daily.patient-data-subnav-tablink.patient-data-subnav-active'), - }, - ChartDate: { - name: 'ChartDate', - link: page.locator('#tidelineLabel .css-15vjjnj svg'), // Using the same calendar icon selector - verifyURL: '', - verifyElement: page.getByRole('heading', { name: 'Chart Date' }), - closeButton: page.getByRole('button', { name: 'close dialog' }), - }, - BGLog: { - name: 'BGLog', - link: page.getByRole('link', { name: 'BG Log' }), - verifyURL: 'data/bglog', - verifyElement: page.locator('.js-bgLog.patient-data-subnav-tablink.patient-data-subnav-active'), - }, - Trends: { - name: 'Trends', - link: page.getByRole('link', { name: 'Trends' }), - verifyURL: 'data/trends', - verifyElement: page.locator('.js-trends.patient-data-subnav-tablink.patient-data-subnav-active'), - }, - Devices: { - name: 'Devices', - link: page.getByRole('link', { name: 'Devices' }), - verifyURL: 'data/devices', - verifyElement: page.locator('.js-settings.patient-data-subnav-tablink.patient-data-subnav-active'), - }, - Print: { - name: 'Print', - link: page.getByRole('link', { name: 'Print PDF report Print' }), // Print link from the snapshot - verifyURL: '', - verifyElement: page.getByRole('heading', { name: 'Print Report' }), // Assuming modal title - closeButton: page.getByRole('button', { name: 'close dialog' }), - }, - Profile: { - name: 'Profile', - link: page.getByRole('button', { name: 'Profile Profile' }), - verifyURL: '', - verifyElement: page.getByRole('button', { name: 'Edit' }), // Edit button is visible on profile page - }, - ProfileEdit: { - name: 'ProfileEdit', - link: page.getByRole('button', { name: 'Edit' }), - verifyURL: 'profile', - verifyElement: page.getByRole('button', { name: 'Save changes' }), // Save changes button appears when in edit mode - }, - Share: { - name: 'Share', - link: page.getByRole('button', { name: 'Share Share' }), - verifyURL: 'share', - verifyElement: page.getByRole('heading', { name: 'Access Management' }), - }, - ShareData: { - name: 'ShareData', - link: page.getByRole('button', { name: 'Share Data' }), - verifyURL: 'share/invite', - verifyElement: page.getByRole('heading', { name: 'Share your data' }), - }, - UploadData: { - name: 'UploadData', - link: page.getByRole('button', { name: 'Upload Data Upload' }), - verifyURL: 'upload', - verifyElement: page.getByRole('heading', { name: 'Upload Data' }), - }, - }; - } -} diff --git a/dist/page-objects/patient/ProfilePage.d.ts b/dist/page-objects/patient/ProfilePage.d.ts deleted file mode 100644 index f37a6f7..0000000 --- a/dist/page-objects/patient/ProfilePage.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Page } from '@playwright/test'; -export declare class ProfilePage { - readonly page: Page; - private fieldLocators; - constructor(page: Page); - fillField(field: keyof typeof this.fieldLocators, value: string): Promise; - selectDiagnosisType(index: number): Promise; - getCurrentDiagnosisIndex(): Promise; - fillFullName(name: string): Promise; - fillBirthDate(date: string): Promise; - fillMRN(mrn: string): Promise; - fillDiagnosisDate(date: string): Promise; - fillClinicalNotes(notes: string): Promise; - fillEmail(email: string): Promise; - saveProfile(): Promise; - /** - * Checks if the edit button is displayed and validates against expected state - * @param shouldBeVisible - Boolean indicating whether the edit button should be visible - * @throws Error if the actual visibility doesn't match the expected state - */ - editButtonDisplays(shouldBeVisible: boolean): Promise; -} diff --git a/dist/page-objects/patient/ProfilePage.js b/dist/page-objects/patient/ProfilePage.js deleted file mode 100644 index 87a80b0..0000000 --- a/dist/page-objects/patient/ProfilePage.js +++ /dev/null @@ -1,111 +0,0 @@ -export class ProfilePage { - constructor(page) { - this.page = page; - this.fieldLocators = { - fullName: this.page.getByRole('textbox', { name: 'Full name' }), - birthDate: this.page.getByRole('textbox', { name: 'Date of birth' }), - mrn: this.page.getByRole('textbox', { name: 'MRN' }), - diagnosisDate: this.page.getByRole('textbox', { name: 'Date of diagnosis' }), - clinicalNotes: this.page.getByRole('textbox', { name: 'Anything you would like to share' }), - email: this.page.getByRole('textbox', { name: /email/i }), - }; - } - // Generic fill method for text fields - async fillField(field, value) { - const locator = this.fieldLocators[field]; - if (!locator) - throw new Error(`No locator defined for field: ${field}`); - if (await locator.isVisible({ timeout: 3000 }).catch(() => false)) { - await locator.fill(value); - } - else { - throw new Error(`Field '${field}' not found or not visible`); - } - } - // Select a diagnosis type from the dropdown - async selectDiagnosisType(index) { - const diagnosisCombo = this.page.getByRole('combobox', { name: 'Diagnosed as' }); - if (await diagnosisCombo.isVisible({ timeout: 3000 })) { - await diagnosisCombo.selectOption({ index }); - } - } - // Get the current diagnosis index from the dropdown (needed for setting a new diagnosis) - async getCurrentDiagnosisIndex() { - const diagnosisCombo = this.page.getByRole('combobox', { name: 'Diagnosed as' }); - if (await diagnosisCombo.isVisible({ timeout: 3000 })) { - const currentValue = await diagnosisCombo.inputValue(); - const options = await diagnosisCombo.locator('option').all(); - // Find current index by checking option values - for (let i = 0; i < options.length; i++) { - const optionValue = await options[i].getAttribute('value'); - if (optionValue === currentValue) { - return i; - } - } - } - return 1; // Default to 1 if not found - } - // For backwards compatibility, keep these as wrappers (optional) - async fillFullName(name) { - return this.fillField('fullName', name); - } - async fillBirthDate(date) { - return this.fillField('birthDate', date); - } - async fillMRN(mrn) { - return this.fillField('mrn', mrn); - } - async fillDiagnosisDate(date) { - return this.fillField('diagnosisDate', date); - } - async fillClinicalNotes(notes) { - return this.fillField('clinicalNotes', notes); - } - async fillEmail(email) { - return this.fillField('email', email); - } - async saveProfile() { - // Save button locators - const saveButtons = [ - this.page.getByRole('button', { name: 'Save changes' }), - this.page.getByRole('button', { name: 'Save Profile' }), - this.page.getByRole('button', { name: 'Save' }), - ]; - // Wait for the PUT request to complete after clicking save - const saveProfilePromise = this.page.waitForResponse(response => response.url().includes('/metadata/') && - response.url().includes('/profile') && - response.request().method() === 'PUT'); - let clicked = false; - for (const btn of saveButtons) { - if (await btn.isVisible({ timeout: 5000 }).catch(() => false)) { - await btn.click(); - clicked = true; - break; - } - } - if (!clicked) - throw new Error('No save button found'); - // Wait for the PUT request to complete (with timeout) - try { - await saveProfilePromise; - } - catch (error) { - console.log('āš ļø PUT request timeout - continuing anyway'); - } - } - /** - * Checks if the edit button is displayed and validates against expected state - * @param shouldBeVisible - Boolean indicating whether the edit button should be visible - * @throws Error if the actual visibility doesn't match the expected state - */ - async editButtonDisplays(shouldBeVisible) { - const editButton = this.page.getByRole('button', { name: 'Edit' }); - const isEditButtonVisible = await editButton.isVisible({ timeout: 3000 }).catch(() => false); - if (shouldBeVisible && !isEditButtonVisible) { - throw new Error('Edit button should be visible but was not found'); - } - else if (!shouldBeVisible && isEditButtonVisible) { - throw new Error('Edit button should not be visible for this user - security violation!'); - } - } -} diff --git a/dist/page-objects/patient/components/daily-chart.d.ts b/dist/page-objects/patient/components/daily-chart.d.ts deleted file mode 100644 index 6e7de56..0000000 --- a/dist/page-objects/patient/components/daily-chart.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Locator, Page } from '@playwright/test'; -export default class DailyChartSection { - page: Page; - container: Locator; - dayLabel: Locator; - newNote: Locator; - buttons: { - refresh: Locator; - }; - constructor(page: Page); -} diff --git a/dist/page-objects/patient/components/daily-chart.js b/dist/page-objects/patient/components/daily-chart.js deleted file mode 100644 index 51c4f46..0000000 --- a/dist/page-objects/patient/components/daily-chart.js +++ /dev/null @@ -1,11 +0,0 @@ -export default class DailyChartSection { - constructor(page) { - this.page = page; - this.container = page.locator('div.patient-data-content'); - this.dayLabel = this.container.locator('text.d3-day-label').filter({ visible: true }); - this.newNote = this.container.locator('image.newNoteIcon'); - this.buttons = { - refresh: this.container.getByRole('button', { name: 'Refresh' }), - }; - } -} diff --git a/dist/playwright.config.d.ts b/dist/playwright.config.d.ts deleted file mode 100644 index 9c39b85..0000000 --- a/dist/playwright.config.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const _default: import("@playwright/test").PlaywrightTestConfig<{}, {}>; -export default _default; diff --git a/dist/playwright.config.js b/dist/playwright.config.js deleted file mode 100644 index 647a368..0000000 --- a/dist/playwright.config.js +++ /dev/null @@ -1,108 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; -import path from 'node:path'; -import env from './utilities/env'; -const xrayOptions = { - embedAnnotationsAsProperties: true, - textContentAnnotations: ['test_description', 'testrun_comment'], - embedAttachmentsAsProperty: 'testrun_evidence', - outputFile: 'test-output/test-results.xml', -}; -// Helper to detect BrowserStack run -const isBrowserStack = Boolean(process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESS_KEY); -function buildBrowserStackEndpoint(testName) { - const caps = { - browser: 'chrome', - browser_version: 'latest', - os: 'os x', - os_version: 'catalina', - name: testName, - build: process.env.CI_BUILD_NUMBER || 'local-run', - 'browserstack.username': process.env.BROWSERSTACK_USERNAME, - 'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY, - }; - return `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify(caps))}`; -} -export default defineConfig({ - testDir: './tests', - outputDir: './test-results', // Custom output directory - globalSetup: require.resolve(path.join(__dirname, 'tests/global-setup')), - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - timeout: 60000, - expect: { - toHaveScreenshot: { maxDiffPixelRatio: 0.2 }, - }, - reporter: [ - ['html', { open: 'never', outputFolder: 'playwright-report' }], - ['json', { outputFile: 'test-results/last-run.json' }], - ['junit', xrayOptions], - ['./utilities/xray-json-reporter.ts'], - ], - use: { - baseURL: env.BASE_URL, - trace: 'on-first-retry', - screenshot: 'only-on-failure', - video: 'retain-on-failure', - // Custom test attachment naming - testIdAttribute: 'data-testid', - }, - projects: [ - { - name: 'chromium-personal', - testMatch: '**/personal/**/*.spec.ts', - use: { - ...devices['Desktop Chrome'], - storageState: 'tests/.auth/personal.json', - headless: false, - }, - }, - { - name: 'chromium-claimed', - testMatch: '**/claimed/**/*.spec.ts', - use: { - ...devices['Desktop Chrome'], - storageState: 'tests/.auth/claimed.json', - headless: false, - }, - }, - { - name: 'chromium-clinician', - testMatch: '**/clinician/**/*.spec.ts', - use: { - ...devices['Desktop Chrome'], - storageState: 'tests/.auth/clinician.json', - headless: false, - }, - }, - ...(isBrowserStack - ? [ - { - name: 'bs-chrome-personal', - testMatch: '**/patient/**/*.spec.ts', - use: { - storageState: 'tests/.auth/personal.json', - connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Personal Patient Tests') }, - }, - }, - { - name: 'bs-chrome-claimed', - testMatch: '**/claimed/**/*.spec.ts', - use: { - storageState: 'tests/.auth/claimed.json', - connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Claimed Patient Tests') }, - }, - }, - { - name: 'bs-chrome-clinician', - testMatch: '**/clinician/**/*.spec.ts', - use: { - storageState: 'tests/.auth/clinician.json', - connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Clinician Tests') }, - }, - }, - ] - : []), - ], -}); diff --git a/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.d.ts b/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.d.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js b/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js deleted file mode 100644 index e95b07d..0000000 --- a/dist/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js +++ /dev/null @@ -1,146 +0,0 @@ -import { test } from '../../fixtures/base'; -import { test as patientTest } from '../../fixtures/patient-helpers'; -import { test as accountTest } from '../../fixtures/account-helpers'; -import { test as clinicTest } from '../../fixtures/clinic-helpers'; -import { createNetworkHelper } from '../../fixtures/network-helpers'; -import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; -import { AccountSettingsPage } from '../../../page-objects/account/AccountSettingsPage'; -import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; -const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; -const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; -test.describe('Claimed Account Settings edit (Full Name only) updates Profile endpoint and visually updates for user, clinic, and shared member', () => { - test.setTimeout(120000); // 2 minute timeout for multi-phase test - let api; - let putCapture; - let newName; // Declare at test level scope - test('should allow navigation to account settings, edit full name, and verify profile update for claimed, shared, and clinician users', { - tag: createValidatedTags([ - TEST_TAGS.PATIENT, - TEST_TAGS.CLINICIAN, // Added clinician tag - TEST_TAGS.CLAIMED, - TEST_TAGS.SHARED_MEMBER, // Added shared member tag - TEST_TAGS.API, - TEST_TAGS.UI, - TEST_TAGS.HIGH, - TEST_TAGS.API_PROFILE, - ]), - }, async ({ page }) => { - // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== - // Step 1: Log in to clinician account and setup network capture - await test.step('Given claimed account has been logged in', async () => { - api = createNetworkHelper(page); - await api.startCapture(); - await page.goto('/data'); - await patientTest.patient.setup(page); - }); - // Step 2: Navigate to account settings - await test.step('When user navigates to account settings', async () => { - await accountTest.account.navigateTo('AccountSettings', page); - }); - // Step 3: GET response is pulled and validated - await test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - // Create new acccount settings page for the following test - const accountSettingsPage = new AccountSettingsPage(page); - // Step 4: Change the Full Name field to a new value - await test.step('When user updates the Full Name field', async () => { - newName = `Claimed User Updated ${Math.floor(Math.random() * 10000)}`; // Remove let declaration - const nameInput = page.getByRole('textbox', { name: /full name/i }); - await nameInput.fill(newName); - }); - // Step 5: Tap the Save button - await test.step('When user taps the save button', async () => { - await accountSettingsPage.saveButton.click(); - }); - // Step 6: Confirm save changes message displays - await test.step('Then the save changes message displays', async () => { - await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); - }); - // Step 7: Validate PUT request and save value - await test.stepNoScreenshot('Then PUT request is validated and name is set to new value', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - putCapture = api - .getCaptures() - .find((req) => req.method === 'PUT' && req.url.includes('/profile')); - if (!putCapture) - throw new Error('No PUT /profile request captured'); - if (!putCapture.requestBody || - !putCapture.requestBody.fullName || - putCapture.requestBody.fullName !== newName) { - throw new Error(`PUT request did not set fullName to ${newName}`); - } - }); - // Step 8: Navigate to Profile page - await test.step('When user navigates to Profile page', async () => { - await patientTest.patient.navigateTo('Profile', page); - }); - // Step 9: Confirm GET request matches the saved PUT request - await test.stepNoScreenshot('Then GET request matches the saved PUT request', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - // Get all captures and find the LATEST GET request (after the PUT) - const allCaptures = api.getCaptures(); - const putIndex = allCaptures.findIndex(req => req === putCapture); - // Find GET requests that occurred AFTER the PUT request - const laterGetCaptures = allCaptures - .slice(putIndex + 1) - .filter((req) => req.method === 'GET' && req.url.includes('/profile')); - if (laterGetCaptures.length === 0) { - throw new Error('No GET /profile request captured after the PUT request'); - } - // Use the most recent GET request - const getCapture = laterGetCaptures[laterGetCaptures.length - 1]; - if (!getCapture.responseBody || - getCapture.responseBody.fullName !== putCapture.requestBody.fullName) { - console.log('GET response fullName:', getCapture.responseBody.fullName); - console.log('PUT request fullName:', putCapture.requestBody.fullName); - console.log('Total captures:', allCaptures.length); - console.log('PUT index:', putIndex); - console.log('Later GET captures found:', laterGetCaptures.length); - throw new Error('GET response fullName does not match PUT request fullName'); - } - }); - // ========== PHASE 2: SHARED USER VIEWS PROFILE ========== - // Step 10: Switch to shared user authentication and go directly to Profile - await test.step('When shared user views claimed user profile', async () => { - await accountTest.account.switchUser('shared', page); - await page.goto('/data'); - await patientTest.patient.setup(page); - // Wait a moment for the page to stabilize after user switch - await page.waitForTimeout(500); - // Navigate directly to Profile in the same step to avoid redundancy - await patientTest.patient.navigateTo('Profile', page); - }); - // Step 11: Verify Edit button is not present for shared users - await test.step('Then Edit button should not be present for shared patients', async () => { - const profilePage = new ProfilePage(page); - await profilePage.editButtonDisplays(false); - }); - // Step 12: Validate shared user sees updated profile data - await test.stepNoScreenshot('Then shared user sees view-only claimed profile data with matching data', async () => { - await api.compareEndpointResponse('profile-metadata-get', putCapture); - }); - // ========== PHASE 3: CLINICIAN VIEWS PROFILE ========== - // Step 13: Switch to clinician user authentication - await test.step('When clinician accesses patient workspace', async () => { - await accountTest.account.switchUser('clinician', page); - await page.goto('/'); - await clinicTest.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); - }); - // Step 14: Access the specific claimed patient that was modified by the producer test - await test.step('When user accesses the claimed patient modified by producer test', async () => { - await clinicTest.clinician.findAndAccessPatientByPartialName(CLAIMED_PATIENT_SEARCH, page); - // Navigate directly to Profile in the same step to avoid redundancy - await clinicTest.clinician.navigateTo('Profile', page); - }); - // Step 15: Verify Edit button is not present for claimed patients viewed by clinicians - await test.step('Then Edit button should not be present for claimed patients', async () => { - const profilePage = new ProfilePage(page); - await profilePage.editButtonDisplays(false); - }); - // Step 16: Validate clinician sees updated profile data - await test.stepNoScreenshot('Then clinician sees claimed profile data with matching data and no save access', async () => { - await api.compareEndpointResponse('profile-metadata-get', putCapture); - }); - }); -}); diff --git a/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.d.ts b/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.d.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js b/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js deleted file mode 100644 index 47da045..0000000 --- a/dist/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js +++ /dev/null @@ -1,124 +0,0 @@ -import { test } from '../../fixtures/base'; -import { test as patientTest } from '../../fixtures/patient-helpers'; -import { test as clinicTest } from '../../fixtures/clinic-helpers'; -import { test as accountTest } from '../../fixtures/account-helpers'; -import { createNetworkHelper } from '../../fixtures/network-helpers'; -import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; -import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; -const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; -const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; -test.describe('Comprehensive Profile Access Test: Edit as Claimed, View as Shared and Clinician', () => { - test('should edit claimed profile then verify view-only access for shared and clinician users', { - tag: createValidatedTags([ - TEST_TAGS.PATIENT, // User Type (required) - TEST_TAGS.CLINICIAN, // User Type (required) - TEST_TAGS.CLAIMED, - TEST_TAGS.SHARED_MEMBER, - TEST_TAGS.API, // Test Type (required) - TEST_TAGS.UI, // Test Type (required) - TEST_TAGS.HIGH, // Priority (required) - TEST_TAGS.API_PROFILE, // Feature (optional) - ]), - }, async ({ page }) => { - let api; - let producerPutCapture; - // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== - // Step 1: Claimed account has been logged in - await test.step('Given claimed account has been logged in', async () => { - api = createNetworkHelper(page); - await api.startCapture(); - await page.goto('/data'); - await patientTest.patient.setup(page); - }); - // Step 2: User navigates to Profile page - await test.step('When user navigates to Profile page', async () => { - await patientTest.patient.navigateTo('Profile', page); - }); - // Step 3: GET response is pulled and validated - await test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - // Step 4: Confirm edit button and click it - await test.step('When user selects Edit button', async () => { - await patientTest.patient.navigateTo('ProfileEdit', page); - }); - // Initialize ProfilePage for steps 4 and 5 - const profilePage = new ProfilePage(page); - // Step 5: Change profile fields (confirmed user access) - await test.step('When user updates profile fields', async () => { - const testRunId = Math.floor(Math.random() * 10000); - const updatedName = `Claimed User Updated ${testRunId}`; - const birthYear = 1985 + (testRunId % 10); - const diagnosisYear = birthYear + 20; - const birthDate = `01/15/${birthYear}`; - const diagnosisDate = `03/10/${diagnosisYear}`; - // Generate random 15-letter string for clinical notes - const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); - // Get current diagnosis index and calculate next one (1-7, wrapping) - const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); - let nextDiagnosisIndex = currentDiagnosisIndex + 1; - if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { - nextDiagnosisIndex = 1; - } - // Update fields using ProfilePage methods - await profilePage.fillFullName(updatedName); - await profilePage.fillBirthDate(birthDate); - await profilePage.fillDiagnosisDate(diagnosisDate); - await profilePage.selectDiagnosisType(nextDiagnosisIndex); - await profilePage.fillClinicalNotes(randomString); - }); - // Step 6: Save profile edit - await test.step('When user saves profile changes', async () => { - await profilePage.saveProfile(); - }); - // Step 7: PUT response is validated and saved for comparison - await test.stepNoScreenshot('Then profile endpoint responds with PUT request consistent with schema', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - const putSchema = await import('../../../endpoint-schema/profile-endpoints'); - const schema = putSchema.putProfileMetadataSchema; - producerPutCapture = api.getLatestCaptureMatching(schema.method, schema.url); - }); - //= ========= SHARED MEMEBER VIEWS PROFILE ========== - // Step 8: Switch to shared user authentication - await test.step('When shared user views claimed user profile', async () => { - await accountTest.account.switchUser('shared', page); - await page.goto('/data'); - await patientTest.patient.navigateTo('ViewData', page); - }); - // Step 9: Navigate to profile page - await test.step('When user navigates to Profile page', async () => { - await patientTest.patient.navigateTo('Profile', page); - }); - // Step 10: Confirm edit button is not present - await test.step('Then Edit button should not be present for shared patients', async () => { - await profilePage.editButtonDisplays(false); - }); - // Step 11: Validate GET response and compare it against the - await test.stepNoScreenshot('Then shared user sees view-only claimed profile data with matching data', async () => { - await api.compareEndpointResponse('profile-metadata-get', producerPutCapture); - }); - // ========== CLINICIAN VIEWS PROFILE ========== - // Step 12: Switch to clinician authentication and navigate to patient profile - await test.step('When clinician accesses patient workspace', async () => { - await accountTest.account.switchUser('clinician', page); - await page.goto('/'); - await clinicTest.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); - }); - // Step 13: Access the specific claimed patient that was modified by the producer test - await test.step('When user accesses the claimed patient modified by producer test', async () => { - await clinicTest.clinician.findAndAccessPatientByPartialName(CLAIMED_PATIENT_SEARCH, page); - }); - // Step 14: Navigate to profile - await test.step('When user navigates to Profile page', async () => { - await clinicTest.clinician.navigateTo('Profile', page); - }); - // Step 15: Confirm edit button is not present - await test.step('Then Edit button should not be present for claimed patients', async () => { - await profilePage.editButtonDisplays(false); - }); - // Step 16: Validate GET response and confirm appropriate permissions - await test.stepNoScreenshot('Then clinician sees claimed profile data with matching data and no save access', async () => { - await api.compareEndpointResponse('profile-metadata-get', producerPutCapture); - }); - }); -}); diff --git a/dist/tests/claimed/API-User/claimed-email-edit.spec.d.ts b/dist/tests/claimed/API-User/claimed-email-edit.spec.d.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/tests/claimed/API-User/claimed-email-edit.spec.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/tests/claimed/API-User/claimed-email-edit.spec.js b/dist/tests/claimed/API-User/claimed-email-edit.spec.js deleted file mode 100644 index 4b8ec83..0000000 --- a/dist/tests/claimed/API-User/claimed-email-edit.spec.js +++ /dev/null @@ -1,93 +0,0 @@ -import { test } from '../../fixtures/base'; -import { test as patientTest } from '../../fixtures/patient-helpers'; -import { test as accountTest } from '../../fixtures/account-helpers'; -import { createNetworkHelper } from '../../fixtures/network-helpers'; -import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; -import { AccountSettingsPage } from '../../../page-objects/account/AccountSettingsPage'; -test.describe('Clinician Account Settings Access', () => { - // API Test cases require this to capture network activity - let api; - test('should allow navigation to account settings and capture GET response', { - tag: createValidatedTags([ - TEST_TAGS.PATIENT, - TEST_TAGS.CLAIMED, - TEST_TAGS.API, - TEST_TAGS.UI, - TEST_TAGS.HIGH, - TEST_TAGS.API_USER, - ]), - }, async ({ page }) => { - // Step 1: Log in to clinician account and setup network capture - await test.step('Given clinician has been logged in', async () => { - api = createNetworkHelper(page); - await api.startCapture(); - await page.goto('/data'); - await patientTest.patient.setup(page); - }); - // Step 2: Navigate to account settings - await test.step('When user navigates to account settings', async () => { - await accountTest.account.navigateTo('AccountSettings', page); - }); - // Step 3: Validate profile GET response - await test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema ', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - // Setup for Account Settings page and previous email for reset - const accountSettingsPage = new AccountSettingsPage(page); - let originalEmail = ''; - // Step 4: Read and change email field to temporary value - await test.step('When user updates the email field', async () => { - originalEmail = await accountSettingsPage.emailInput.inputValue(); - await accountSettingsPage.emailInput.fill('qa+TempEdit@tidepool.org'); - }); - // Step 5: Tap the save button - await test.step('When user taps the save button', async () => { - await accountSettingsPage.saveButton.click(); - }); - // Step 6: Confirm save changes message displays - await test.step('Then the save changes message displays', async () => { - await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); - }); - // Step 7: Validate PUT request and email value - await test.stepNoScreenshot('Then PUT request is validated and email is set to new value', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - const putCapture = api - .getCaptures() - .find((req) => req.method === 'PUT' && req.url.includes('/profile')); - if (!putCapture) - throw new Error('No PUT /profile request captured'); - if (!putCapture.requestBody || - !putCapture.requestBody.email || - putCapture.requestBody.email !== 'qa+TempEdit@tidepool.org') { - throw new Error('PUT request did not set email to qa+TempEdit@tidepool.org'); - } - }); - // Step 8: Change email field to temporary value - await test.step('When user sets the email field to the previous value', async () => { - await accountSettingsPage.emailInput.fill(originalEmail); - }); - // Step 9: Tap the save button - await test.step('When user taps the save button', async () => { - await accountSettingsPage.saveButton.click(); - }); - // Step 10: Confirm save changes message displays - await test.step('Then the save changes message displays', async () => { - await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); - }); - // Step 7: Validate PUT request and email value - await test.stepNoScreenshot('Then PUT request is validated and email is set to new value', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - const putCapture = api - .getCaptures() - .find((req) => req.method === 'PUT' && req.url.includes('/profile')); - if (!putCapture) - throw new Error('No PUT /profile request captured'); - if (!putCapture.requestBody || - !putCapture.requestBody.email || - putCapture.requestBody.email !== originalEmail) { - throw new Error('PUT request did not set email to originalEmail'); - } - }); - await api.stopCapture(); - }); -}); diff --git a/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.d.ts b/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.d.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js b/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js deleted file mode 100644 index 5285fee..0000000 --- a/dist/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js +++ /dev/null @@ -1,89 +0,0 @@ -import { test } from '../../fixtures/clinic-helpers'; -import { createNetworkHelper } from '../../fixtures/network-helpers'; -import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; -import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; -test.describe('Custodial patients are allowed access and modification of profile details', () => { - // Define the workspace and patient at top level - const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; - const CUSTODIAL_PATIENT_SEARCH = 'Custodial Patient'; - // API Test cases require this to capture network activity - let api; - test('should allow navigation to profile details and edit profile fields', { - tag: createValidatedTags([ - TEST_TAGS.CLINICIAN, // User Type (required) - TEST_TAGS.API, // Test Type (required) - TEST_TAGS.UI, // Test Type (required) - TEST_TAGS.HIGH, // Priority (required) - TEST_TAGS.API_PROFILE, // Feature (optional) - ]), - }, async ({ page }, testInfo) => { - // Step 1: Log in to clinician account and setup network capture - await test.step('Given clinician has been logged in', async () => { - api = createNetworkHelper(page); - await api.startCapture(); - await test.clinician.setup(page); - }); - // Step 2: Navigate to workspace - await test.step('When user navigates to desired workspace', async () => { - await test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); - }); - // Step 3: Access custodial patient - await test.step('When user accesses a custodial patient summary', async () => { - await test.clinician.findAndAccessPatientByPartialName(CUSTODIAL_PATIENT_SEARCH, page); - }); - // Step 4: Navigate to profile - await test.step('When user navigates to Profile page', async () => { - await test.clinician.navigateTo('Profile', page); - }); - // Step 5: Capture GET response - await test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - // Step 6: Open Edit Profile - await test.step('When user selects Edit button', async () => { - await test.clinician.navigateTo('ProfileEdit', page); - }); - // Create Profile page for following steps - const profilePage = new ProfilePage(page); - // Step 7: Change profile fields (custodial access) - await test.step('When user updates profile fields', async () => { - // Generate completely unique values for this custodial test run - const randomSeed = Math.random(); - const randomId = Math.floor(randomSeed * 10000); - const updatedName = `Custodial Patient Updated ${Math.floor(randomId * 10000)}`; - const birthYear = 1980 + (randomId % 15); - const diagnosisYear = birthYear + 25; - const birthDate = `05/20/${birthYear}`; - const diagnosisDate = `08/15/${diagnosisYear}`; - // Generate random 15-digit MRN - const randomMRN = Array.from({ length: 15 }, () => Math.floor(Math.random() * 10).toString()).join(''); - // Generate random 15-letter string for clinical notes - const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); - // Generate unique email - const email = `webuiautomation+custodialEdit${randomId}@tidepool.org`; - // Get current diagnosis index and calculate next one (1-7, wrapping) - const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); - let nextDiagnosisIndex = currentDiagnosisIndex + 1; - if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { - nextDiagnosisIndex = 1; - } - // Update fields using ProfilePage methods - await profilePage.fillFullName(updatedName); - await profilePage.fillBirthDate(birthDate); - await profilePage.fillMRN(randomMRN); - await profilePage.fillDiagnosisDate(diagnosisDate); - await profilePage.selectDiagnosisType(nextDiagnosisIndex); - await profilePage.fillEmail(email); - await profilePage.fillClinicalNotes(randomString); - }); - // Step 8: Save profile edit - await test.step('When user saves profile changes', async () => { - await profilePage.saveProfile(); - }); - // Step 9: Check profile PUT response - await test.step('Then profile endpoint responds with PUT request consistent with schema [no-screenshot]', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - }); - await api.stopCapture(); - }); -}); diff --git a/dist/tests/clinician/add-patient.spec.d.ts b/dist/tests/clinician/add-patient.spec.d.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/tests/clinician/add-patient.spec.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/tests/clinician/add-patient.spec.js b/dist/tests/clinician/add-patient.spec.js deleted file mode 100644 index 65a9915..0000000 --- a/dist/tests/clinician/add-patient.spec.js +++ /dev/null @@ -1,33 +0,0 @@ -import { expect, test } from '@fixtures/base'; -import ClinicianDashboardPage from '@pom/clinician/ClinicianDashboardPage'; -import WorkspacesPage from '@pom/clinician/WorkspacesPage'; -test.describe('Add new patient', () => { - // Use a unique patient name for each test run to avoid collisions - const patientName = `Test Patient Playwright ${Date.now()}`; - const patientBirthdate = '01/01/1990'; - test.beforeEach(async () => { - await test.step('Given user has been logged in and navigated to base URL', async () => { }); - }); - test('should successfully add a new patient', async ({ page }) => { - const workspacesPage = new WorkspacesPage(page); - const clinicWorkspacePage = new ClinicianDashboardPage(page); - await test.step('Given the user is on the workspaces page', async () => { - await workspacesPage.goto(); - await workspacesPage.header.waitFor({ state: 'visible' }); - }); - await test.step('When user selects the first workspace', async () => { - await workspacesPage.visitFirstClinic(); - await clinicWorkspacePage.waitForLoadState(); // Wait for clinic page elements - }); - await test.step('When user adds a new patient via dialog', async () => { - await clinicWorkspacePage.openAndFillAddPatientDialog(patientName, patientBirthdate); - await clinicWorkspacePage.submitAddPatientDialog(); - await clinicWorkspacePage.closeBringDataDialog(); - }); - await test.step('Then the new patient should appear in the patient list', async () => { - await clinicWorkspacePage.searchForPatient(patientName); - const patientCell = clinicWorkspacePage.getPatientCellByName(patientName); - await expect(patientCell).toBeVisible(); - }); - }); -}); diff --git a/dist/tests/clinician/create-clinic-workspace.spec.d.ts b/dist/tests/clinician/create-clinic-workspace.spec.d.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/tests/clinician/create-clinic-workspace.spec.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/tests/clinician/create-clinic-workspace.spec.js b/dist/tests/clinician/create-clinic-workspace.spec.js deleted file mode 100644 index e1363cc..0000000 --- a/dist/tests/clinician/create-clinic-workspace.spec.js +++ /dev/null @@ -1,81 +0,0 @@ -import { expect, test } from '@fixtures/base'; -import ClinicCreationPage from '@pom/clinician/ClinicCreationPage'; -import WorkspacesPage from '@pom/clinician/WorkspacesPage'; -import { randomUUID } from 'node:crypto'; -test.describe('Create clinic workspace', () => { - const uniqueSuffix = randomUUID().substring(0, 8); - const clinicName = `Test Clinic ${uniqueSuffix}`; - let workspacesPage; - let clinicCreationPage; - test.beforeEach(async ({ page }) => { - workspacesPage = new WorkspacesPage(page); - clinicCreationPage = new ClinicCreationPage(page); - }); - test('should successfully create a new clinic workspace', async ({ page }) => { - await test.step('Given user is on the workspaces page', async () => { - await workspacesPage.goto(); - await expect(workspacesPage.header).toBeVisible(); - await expect(workspacesPage.createClinicButton).toBeVisible(); - }); - await test.step("When user clicks on the 'Create a New Clinic' button", async () => { - await workspacesPage.createClinicButton.click(); - // Wait for the clinic details page to load - await expect(page).toHaveURL(/clinic-details\/new/); - await expect(clinicCreationPage.pageHeader).toBeVisible(); - }); - await test.step('When user fills in all the required clinic information', async () => { - // Fill the clinic form with test data - await clinicCreationPage.fillClinicForm({ - clinicName, - teamType: 'Provider Practice', - state: 'California', - address: '123 Test Street', - city: 'Test City', - zipCode: '12345', - }); - // Verify blood glucose units (mg/dL is pre-selected) - await expect(clinicCreationPage.mgdlRadio).toBeChecked(); - // Verify the admin acknowledgment checkbox is checked - await expect(clinicCreationPage.adminAcknowledgeCheckbox).toBeChecked(); - // Verify Create Workspace button is enabled - await expect(clinicCreationPage.createWorkspaceButton).toBeEnabled(); - }); - await test.step("When user clicks on the 'Create Workspace' button", async () => { - await clinicCreationPage.createWorkspaceButton.click(); - // Wait for redirect to workspaces page - await expect(page).toHaveURL('/workspaces'); - }); - await test.step('Then user should see the new clinic in the list and a success message', async () => { - // Verify success message is shown - const successMessage = page.getByText(`"${clinicName}" clinic created`); - await expect(successMessage).toBeVisible(); - // Verify the new clinic appears in the list - const clinicHeaderLocator = page.getByRole('heading', { name: clinicName }); - await expect(clinicHeaderLocator).toBeVisible(); - // Verify the clinic has the necessary action buttons - const clinicContainer = page - .locator('.workspace-item-clinic') - .filter({ has: clinicHeaderLocator }); - await expect(clinicContainer.getByRole('button', { name: 'Leave Clinic' })).toBeVisible(); - await expect(clinicContainer.getByRole('button', { name: 'Go To Workspace' })).toBeVisible(); - }); - }); - test('should create a new clinic with the simplified createClinic method', async ({ page }) => { - // Navigate to the workspaces page - await page.goto('/workspaces'); - await expect(workspacesPage.header).toBeVisible(); - // Click the "Create a New Clinic" button - await workspacesPage.createClinicButton.click(); - await expect(page).toHaveURL(/clinic-details\/new/); - // Use the simplified method to create a clinic in one step - await clinicCreationPage.createClinic(clinicName); - // Verify we're back on the workspaces page - await expect(page).toHaveURL('/workspaces'); - // Verify the clinic was created - const successMessage = page.getByText(`"${clinicName}" clinic created`); - await expect(successMessage).toBeVisible(); - // Verify the clinic appears in the list - const clinicHeaderLocator = page.getByRole('heading', { name: clinicName }); - await expect(clinicHeaderLocator).toBeVisible(); - }); -}); diff --git a/dist/tests/clinician/edit-clinic-address.spec.d.ts b/dist/tests/clinician/edit-clinic-address.spec.d.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/tests/clinician/edit-clinic-address.spec.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/tests/clinician/edit-clinic-address.spec.js b/dist/tests/clinician/edit-clinic-address.spec.js deleted file mode 100644 index 936d9b5..0000000 --- a/dist/tests/clinician/edit-clinic-address.spec.js +++ /dev/null @@ -1,42 +0,0 @@ -import { expect, test } from '@fixtures/base'; -import ClinicAdminPage from '@pom/clinician/WorkspaceSettingsPage'; -import WorkspacesPage from '@pom/clinician/WorkspacesPage'; -test.describe('Edit clinic address', () => { - const newAddress = `123 Test Street ${Date.now()}`; // Unique address for test run - let clinicAdminPage; - let workspacesPage; - test.beforeEach(async ({ page }) => { - clinicAdminPage = new ClinicAdminPage(page); - workspacesPage = new WorkspacesPage(page); - await test.step('Given user has navigated to the Clinic Admin page', async () => { - await workspacesPage.goto(); - await workspacesPage.visitFirstClinic(); - await page.goto('/clinic-admin'); - await clinicAdminPage.waitForLoadState(); // Wait for clinic admin page elements - await clinicAdminPage.clinicDetailsHeader.waitFor({ state: 'visible' }); - }); - }); - test('should successfully edit the clinic address', async ({ page }) => { - await test.step('When user clicks the "Edit" button for workspace details', async () => { - await clinicAdminPage.editDetailsButton.click(); - await clinicAdminPage.editClinicModal.waitFor({ state: 'visible' }); - }); - await test.step('Then user sees the modal for Editing workspace details', async () => { - await expect(clinicAdminPage.editClinicModalTitle).toBeVisible(); - await expect(clinicAdminPage.addressInput).toBeVisible(); - }); - await test.step('When user changes the address', async () => { - await clinicAdminPage.addressInput.fill(newAddress); - }); - await test.step('When user clicks on "Save changes"', async () => { - await clinicAdminPage.saveChangesButton.click(); - await clinicAdminPage.editClinicModal.waitFor({ state: 'hidden' }); // Wait for modal to close - }); - await test.step('Then user sees the updated address on the page', async () => { - // Wait for the details section to potentially update - await page.waitForTimeout(1000); // Small wait for potential DOM update - const detailsText = clinicAdminPage.clinicDetailsSection; - await expect(detailsText).toContainText(newAddress); - }); - }); -}); diff --git a/dist/tests/clinician/filter-patient.spec.d.ts b/dist/tests/clinician/filter-patient.spec.d.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/tests/clinician/filter-patient.spec.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/tests/clinician/filter-patient.spec.js b/dist/tests/clinician/filter-patient.spec.js deleted file mode 100644 index e4abb1c..0000000 --- a/dist/tests/clinician/filter-patient.spec.js +++ /dev/null @@ -1,65 +0,0 @@ -import { expect, test } from '@fixtures/base'; -import ClinicianDashboardPage from '@pom/clinician/ClinicianDashboardPage'; -import WorkspacesPage from '@pom/clinician/WorkspacesPage'; -test.describe('Filter patients in clinic', () => { - // Use unique patient names for each test run - const timestamp = Date.now(); - const patientName1 = `Filter Patient A ${timestamp}`; - const patientName2 = `Filter Patient B ${timestamp}`; - const patientBirthdate = '01/01/1995'; // Shared birthdate for simplicity - let workspacesPage; - let clinicWorkspacePage; - test.beforeEach(async ({ page }) => { - workspacesPage = new WorkspacesPage(page); - clinicWorkspacePage = new ClinicianDashboardPage(page); - await test.step('Given user has been logged in and navigated to base URL', async () => { - await workspacesPage.goto(); - await page.waitForURL(workspacesPage.url); - await workspacesPage.header.waitFor({ state: 'visible' }); - }); - await test.step('Given the user is on the first clinic workspace', async () => { - await workspacesPage.visitFirstClinic(); - await clinicWorkspacePage.waitForLoadState(); // Wait for clinic page elements - }); - await test.step('Given two patients exist', async () => { - // Add first patient - await clinicWorkspacePage.openAndFillAddPatientDialog(patientName1, patientBirthdate); - await clinicWorkspacePage.submitAddPatientDialog(); - await clinicWorkspacePage.closeBringDataDialog(); - // Ensure the first patient is added before adding the second - await expect(clinicWorkspacePage.getPatientCellByName(patientName1)).toBeVisible({ - timeout: 10000, - }); - // Add second patient - await clinicWorkspacePage.openAndFillAddPatientDialog(patientName2, patientBirthdate); - await clinicWorkspacePage.submitAddPatientDialog(); - await clinicWorkspacePage.closeBringDataDialog(); - // Ensure the second patient is also added - await expect(clinicWorkspacePage.getPatientCellByName(patientName2)).toBeVisible({ - timeout: 10000, - }); - }); - }); - test('should successfully filter patients by name', async () => { - await test.step("When user filters by the first patient's name", async () => { - await clinicWorkspacePage.searchForPatient(patientName1); - }); - await test.step('Then only the first patient should be visible', async () => { - const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); - const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); - await expect(patientCell1).toBeVisible(); - await expect(patientCell2).not.toBeVisible(); - }); - await test.step('When user clears the filter', async () => { - // Assuming a method like clearPatientSearch exists or searchForPatient('') clears - await clinicWorkspacePage.searchForPatient(''); // Clear search by searching for empty string - // Or potentially: await clinicWorkspacePage.clearPatientSearch(); - }); - await test.step('Then both patients should be visible again', async () => { - const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); - const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); - await expect(patientCell1).toBeVisible(); - await expect(patientCell2).toBeVisible(); - }); - }); -}); diff --git a/dist/tests/fixtures/account-helpers.d.ts b/dist/tests/fixtures/account-helpers.d.ts deleted file mode 100644 index 21ab3cd..0000000 --- a/dist/tests/fixtures/account-helpers.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { test as base } from '@fixtures/base'; -import AccountNav from '@pom/account/AccountNavigation'; -import type { Page } from '@playwright/test'; -/** - * Switch user authentication context by loading different storageState - * @param userType - The user type corresponding to the storageState file (e.g., 'shared', 'clinician', 'claimed') - * @param page - The Playwright page instance - */ -declare function switchUser(userType: string, page: Page): Promise; -/** - * Core navigation function that handles account navigation consistently - */ -declare function navigateTo(targetPage: keyof AccountNav['pages'], page: Page): Promise; -declare const test: typeof base & { - account: { - navigateTo: typeof navigateTo; - switchUser: typeof switchUser; - }; -}; -export { test }; diff --git a/dist/tests/fixtures/account-helpers.js b/dist/tests/fixtures/account-helpers.js deleted file mode 100644 index 0e92578..0000000 --- a/dist/tests/fixtures/account-helpers.js +++ /dev/null @@ -1,84 +0,0 @@ -import { test as base } from '@fixtures/base'; -import AccountNav from '@pom/account/AccountNavigation'; -/** - * Switch user authentication context by loading different storageState - * @param userType - The user type corresponding to the storageState file (e.g., 'shared', 'clinician', 'claimed') - * @param page - The Playwright page instance - */ -async function switchUser(userType, page) { - try { - // Import fs dynamically - const fs = await import('node:fs'); - // Load the specified user's storage state - const storageStatePath = `tests/.auth/${userType}.json`; - const storageState = JSON.parse(fs.readFileSync(storageStatePath, 'utf-8')); - // Clear existing cookies first - await page.context().clearCookies(); - // Set cookies from the new user's storage state - if (storageState.cookies) { - await page.context().addCookies(storageState.cookies); - } - // Set localStorage from the new user's storage state - if (storageState.origins) { - for (const origin of storageState.origins) { - await page.addInitScript(originData => { - if (originData.localStorage) { - for (const item of originData.localStorage) { - localStorage.setItem(item.name, item.value); - } - } - }, origin); - } - } - console.log(`āœ… Successfully switched to ${userType} user authentication`); - } - catch (error) { - throw new Error(`Failed to switch to ${userType} user: ${error}`); - } -} -/** - * Core navigation function that handles account navigation consistently - */ -async function navigateTo(targetPage, page) { - const nav = new AccountNav(page); - const pageConfig = nav.pages[targetPage]; - try { - // Single page check at start - if (page.isClosed()) - return; - // Quick DOM ready check only - await page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => { }); - // Open navigation menu if needed (only for non-AccountNav targets) - if (targetPage !== 'AccountNav') { - const menuVisible = await nav.pages.AccountNav.verifyElement - .isVisible({ timeout: 1000 }) - .catch(() => false); - if (!menuVisible) { - await nav.pages.AccountNav.link.click(); - await nav.pages.AccountNav.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); - } - } - // Handle logout specially - if (targetPage === 'Logout') { - await pageConfig.link.click(); - await page - .waitForURL(/.*login.*/, { waitUntil: 'domcontentloaded', timeout: 5000 }) - .catch(() => { }); - } - else { - // Standard navigation - click and verify - await pageConfig.link.click(); - await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); - } - } - catch (error) { - if (!page.isClosed()) - throw error; - } -} -const test = base; -test.account = { - navigateTo, - switchUser, -}; -export { test }; diff --git a/dist/tests/fixtures/base.d.ts b/dist/tests/fixtures/base.d.ts deleted file mode 100644 index 2b00097..0000000 --- a/dist/tests/fixtures/base.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Page, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, TestType } from '@playwright/test'; -interface CustomFixtures { - timeLogger: Page; - timeStepLogger: Page; - stepTimer: Page; - stepScreenshoter: Page; - exceptionLogger: Page; -} -export declare const test: TestType; -export { expect } from '@playwright/test'; -/** - * Decorator function for wrapping POM methods in a test.step. - * - * Use it without a step name `@step()`. - * - * Or with a step name `@step("Search something")`. - * - * @param stepName - The name of the test step. - * @returns A decorator function that can be used to decorate test methods. - */ -export declare function step(stepName?: string): (target: any, context: ClassMethodDecoratorContext) => (this: { - name: string; -}, ...args: any[]) => Promise; diff --git a/dist/tests/fixtures/base.js b/dist/tests/fixtures/base.js deleted file mode 100644 index ccbeab6..0000000 --- a/dist/tests/fixtures/base.js +++ /dev/null @@ -1,219 +0,0 @@ -import { test as base, } from '@playwright/test'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -// Define the test type with custom fixtures -export const test = base.extend({ - page: async ({ page }, use, testInfo) => { - const modifiedTestInfo = testInfo; - modifiedTestInfo.snapshotSuffix = ''; - modifiedTestInfo.snapshotPath = name => `${testInfo.file}-snapshots/${name}`; - // Make testInfo globally available for network helpers - globalThis.testInfo = testInfo; - try { - await use(page); - } - finally { - // Clean up after test - delete globalThis.testInfo; - } - }, - timeLogger: [ - async ({ page }, use, testInfo) => { - testInfo.annotations.push({ - type: 'Start', - description: new Date().toISOString(), - }); - await use(page); - testInfo.annotations.push({ - type: 'End', - description: new Date().toISOString(), - }); - }, - { auto: true }, - ], - timeStepLogger: [ - async ({ page }, use, testInfo) => { - const startTime = Date.now(); - console.time(`[test] ${testInfo.title}`); - await use(page); - console.timeEnd(`[test] ${testInfo.title}`); - const endTime = Date.now(); - const duration = endTime - startTime; - testInfo.annotations.push({ - type: 'Duration', - description: `${duration}ms`, - }); - testInfo.annotations.push({ - type: 'End', - description: new Date().toISOString(), - }); - }, - { auto: true }, - ], - stepTimer: [ - async ({ page }, use, testInfo) => { - const originalStep = test.step; - const stepTimings = new Map(); - // Create a new step function with the same interface as the original - const newStep = function newStepWrapper(name, fn) { - return originalStep.call(this, name, async (stepInfo) => { - const startTime = Date.now(); - console.time(`[step] ${name}`); - const result = await fn(stepInfo); - console.timeEnd(`[step] ${name}`); - const endTime = Date.now(); - const duration = endTime - startTime; - stepTimings.set(name, duration); - testInfo.annotations.push({ - type: `Step Duration: ${name}`, - description: `${duration}ms`, - }); - return result; - }); - }; - // Add the skip method to match the original test.step interface - newStep.skip = function skipStep(name, fn) { - return originalStep.skip.call(this, name, fn); - }; - // Replace the original step with our enhanced version - test.step = newStep; - await use(page); - // Restore original test.step - test.step = originalStep; - }, - { auto: true }, - ], - stepScreenshoter: [ - async ({ page }, use, testInfo) => { - const originalStep = test.step; - let stepCounter = 0; - // Create a safe directory name based on test info - const testDirName = path.basename(testInfo.file, '.spec.ts').replace(/[^a-z0-9]/gi, '-'); - const screenshotDir = path.join('test-results', testDirName); - // Store current step name for network helpers - let currentStepName = ''; - // Make step counter accessible globally for network helper - globalThis.__stepCounter = { - get: () => stepCounter, - increment: () => ++stepCounter, - getDirectory: () => screenshotDir, - getCurrentStepName: () => currentStepName, - setCurrentStepName: (name) => { - currentStepName = name; - }, - }; - // Clean up existing screenshots from previous runs - try { - await fs.promises.access(screenshotDir); - await fs.promises.rm(screenshotDir, { recursive: true, force: true }); - } - catch { - // Directory doesn't exist, no need to clean up - } - // Create a new step function that takes screenshots after completion and attaches them to the report - const newStep = function newStepScreenshot(name, fn) { - return originalStep.call(this, name, async (stepInfo) => { - // Set current step name for network helpers (clean name without [no-screenshot]) - const stepCounterObj = globalThis.__stepCounter; - if (stepCounterObj) { - const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); - stepCounterObj.setCurrentStepName(cleanName); - } - const result = await fn(stepInfo); - // Skip screenshot if step name contains [no-screenshot] - if (name.includes('[no-screenshot]')) { - return result; - } - // Take screenshot after step completion - stepCounter += 1; - try { - if (!page.isClosed()) { - // Use clean name for filename (without [no-screenshot]) - const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); - const screenshotName = `step-${stepCounter.toString().padStart(2, '0')}-${cleanName.toLowerCase().replace(/[^a-z0-9]/g, '-')}.png`; - // Take screenshot directly to buffer (no local file) - const screenshot = await page.screenshot({ - fullPage: true, - }); - // Attach to Playwright report AND force test-results folder creation - if (testInfo && typeof testInfo.attach === 'function') { - await testInfo.attach(screenshotName, { - body: screenshot, - contentType: 'image/png', - }); - // Also save to test-results for organized viewing (single source) - const testResultsDir = path.join(testInfo.outputDir, 'attachments'); - await fs.promises.mkdir(testResultsDir, { recursive: true }); - const screenshotPath = path.join(testResultsDir, screenshotName); - await fs.promises.writeFile(screenshotPath, screenshot); - } - } - } - catch (error) { } - return result; - }); - }; - // Add the skip method to match the original test.step interface - newStep.skip = function skipStepScreenshot(name, fn) { - return originalStep.skip.call(this, name, fn); - }; - // Add a custom stepNoScreenshot function for API validation steps - const stepNoScreenshot = function stepNoScreenshot(name, fn) { - return originalStep.call(this, name, async (stepInfo) => { - // Set current step name for network helpers (clean name) - const stepCounterObj = globalThis.__stepCounter; - if (stepCounterObj) { - stepCounterObj.setCurrentStepName(name); - } - const result = await fn(stepInfo); - // No screenshot taken for this step type - // console.log(`ā­ļø API step completed without screenshot: ${name}`); - return result; - }); - }; - // Replace the original step with our enhanced version - test.step = newStep; - // Add the no-screenshot step function to the test object - test.stepNoScreenshot = stepNoScreenshot; - await use(page); - // Restore original test.step - test.step = originalStep; - }, - { auto: true }, - ], - exceptionLogger: [ - async ({ page }, use, testInfo) => { - const errors = []; - page.on('pageerror', (error) => { - errors.push(error); - }); - await use(page); - if (errors.length > 0) { - await testInfo.attach('frontend-exceptions', { - body: errors.map(error => `${error.message}\n${error.stack}`).join('\n---------\n'), - }); - throw new Error('Some frontend exceptions occurred'); - } - }, - { auto: true }, - ], -}); -export { expect } from '@playwright/test'; -/** - * Decorator function for wrapping POM methods in a test.step. - * - * Use it without a step name `@step()`. - * - * Or with a step name `@step("Search something")`. - * - * @param stepName - The name of the test step. - * @returns A decorator function that can be used to decorate test methods. - */ -export function step(stepName) { - return function decorator(target, context) { - return function replacementMethod(...args) { - const name = `${stepName || context.name} (${this.name})`; - return test.step(name, async () => await target.call(this, ...args)); - }; - }; -} diff --git a/dist/tests/fixtures/clinic-helpers.d.ts b/dist/tests/fixtures/clinic-helpers.d.ts deleted file mode 100644 index 170b58e..0000000 --- a/dist/tests/fixtures/clinic-helpers.d.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { test as base } from '@fixtures/base'; -import type { Page } from '@playwright/test'; -import ClinicianNav from '../../page-objects/clinician/ClinicianNavigation'; -export type WorkspaceKey = 'AdminClinicBase' | 'AdminClinicEnterprise' | 'MemberClinicBase' | 'MemberClinicEnterprise' | 'NonMemberClinicBase' | 'NonMemberClinicEnterprise' | 'PartnerClinicBase' | 'PartnerClinicEnterprise'; -export type PageKey = 'PatientList' | 'WorkspaceSettings' | 'AddPatient' | 'Profile' | 'ProfileEdit'; -/** - * Initialize clinician navigation helpers after login - */ -declare function setupClinicianSession(page: Page): Promise; -/** - * Navigate to workspace selection page - */ -declare function navigateToWorkspaceSelection(page: Page): Promise; -/** - * Navigate to a specific workspace using hardcoded workspace key - */ -declare function navigateToWorkspace(workspaceKey: WorkspaceKey, page: Page): Promise; -/** - * Core navigation function that handles workspace prerequisites and page navigation - */ -declare function navigateTo(targetPage: PageKey, page: Page, workspaceKey?: WorkspaceKey): Promise; -/** - * Execute test logic across multiple workspaces - */ -declare function executeAcrossWorkspaces(workspaceConfigs: { - workspaceKey: WorkspaceKey; -}[], action: (config: { - workspaceKey: WorkspaceKey; -}) => Promise, page: Page): Promise; -/** - * Find and access any patient whose name contains the search term (optimized version) - * @param searchTerm - Partial name to search for (e.g., "Custodial") - * @param page - The Playwright page object - * @returns The full name of the patient that was accessed - */ -declare function findAndAccessPatientByPartialName(searchTerm: string, page: Page): Promise; -/** - * Find and access any available patient (fastest option) - * @param page - The Playwright page object - * @returns The full name of the first patient that was accessed - */ -declare function findAndAccessAnyPatient(page: Page): Promise; -/** - * Access a specific patient by name and navigate to their summary page - * @param patientName - The name of the patient to access - * @param page - The Playwright page object - */ -declare function accessPatient(patientName: string, page: Page): Promise; -declare const test: typeof base & { - clinician: { - navigateTo: typeof navigateTo; - navigateToWorkspace: typeof navigateToWorkspace; - navigateToWorkspaceSelection: typeof navigateToWorkspaceSelection; - executeAcrossWorkspaces: typeof executeAcrossWorkspaces; - accessPatient: typeof accessPatient; - findAndAccessPatientByPartialName: typeof findAndAccessPatientByPartialName; - findAndAccessAnyPatient: typeof findAndAccessAnyPatient; - setup: typeof setupClinicianSession; - }; -}; -export { test }; diff --git a/dist/tests/fixtures/clinic-helpers.js b/dist/tests/fixtures/clinic-helpers.js deleted file mode 100644 index 31fd2d1..0000000 --- a/dist/tests/fixtures/clinic-helpers.js +++ /dev/null @@ -1,274 +0,0 @@ -import { test as base } from '@fixtures/base'; -import ClinicianNav from '../../page-objects/clinician/ClinicianNavigation'; -import ClinicianDashboardPage from '../../page-objects/clinician/ClinicianDashboardPage'; -import AccountNav from '../../page-objects/account/AccountNavigation'; -/** - * Initialize clinician navigation helpers after login - */ -async function setupClinicianSession(page) { - // Wait for clinician navigation to be available - const nav = new ClinicianNav(page); - // Navigate to login and setup clinic session if needed - if (!page.url().includes('clinic-workspace')) { - await page.goto('/login'); - // Add any necessary login steps here - } - console.log('šŸ„ Clinic session setup complete'); - return nav; -} -/** - * Navigate to workspace selection page - */ -async function navigateToWorkspaceSelection(page) { - const accountNav = new AccountNav(page); - // Open the account navigation menu first - await accountNav.pages.AccountNav.link.click(); - // Then click the ManageWorkspaces option - await accountNav.pages.ManageWorkspaces.link.click(); - // Verify we're on the workspace selection page using the known verification element - await accountNav.pages.ManageWorkspaces.verifyElement.waitFor({ - state: 'visible', - timeout: 5000, - }); - // console.log('āœ… Navigated to workspace selection page'); -} -/** - * Navigate to a specific workspace using hardcoded workspace key - */ -async function navigateToWorkspace(workspaceKey, page) { - const clinicianNav = new ClinicianNav(page); - // First navigate to workspace selection if not already there - if (!page.url().includes('workspaces')) { - await navigateToWorkspaceSelection(page); - } - // Click on the specific workspace using the page object locator - await clinicianNav.workspaces[workspaceKey].link.click(); - // Verify we're in the correct workspace using URL verification - await page.waitForURL(new RegExp(clinicianNav.workspaces[workspaceKey].verifyURL), { - timeout: 5000, - }); - // console.log(`āœ… Successfully navigated to workspace: ${clinicianNav.workspaces[workspaceKey].name}`); -} -/** - * Core navigation function that handles workspace prerequisites and page navigation - */ -async function navigateTo(targetPage, page, workspaceKey) { - const clinicianNav = new ClinicianNav(page); - const pageConfig = clinicianNav.pages[targetPage]; - // Ensure we're in a workspace context (but don't auto-switch if already in one) - const isInWorkspaceContext = page.url().includes('clinic-workspace') || - page.url().includes('/patients/') || - page.url().includes('/profile'); - if (!isInWorkspaceContext) { - const defaultWorkspace = workspaceKey || 'AdminClinicBase'; - await navigateToWorkspace(defaultWorkspace, page); - } - else if (workspaceKey) { - // Only switch if specifically requested and we can verify we're in wrong workspace - const currentUrl = page.url(); - const targetWorkspacePattern = clinicianNav.workspaces[workspaceKey].verifyURL; - if (!currentUrl.includes(targetWorkspacePattern)) { - await navigateToWorkspace(workspaceKey, page); - } - } - // Handle page-specific prerequisites - if (targetPage === 'AddPatient') { - // AddPatient might need to be on PatientList first - if (!page.url().includes('patients')) { - await clinicianNav.pages.PatientList.link.click(); - await clinicianNav.pages.PatientList.verifyElement.waitFor({ - state: 'visible', - timeout: 5000, - }); - } - } - // Perform the actual navigation - try { - await pageConfig.link.click(); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.log(`Failed to click ${targetPage}: ${errorMessage}`); - throw error; - } - // Verify navigation succeeded - try { - if (pageConfig.verifyURL) { - await page.waitForURL(`**/*${pageConfig.verifyURL}*`, { timeout: 5000 }); - } - if (pageConfig.verifyElement) { - await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); - } - // console.log(`āœ… Navigated to page: ${targetPage}`); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - // console.log(`Navigation verification failed for ${targetPage}: ${errorMessage}`); - } -} -/** - * Execute test logic across multiple workspaces - */ -async function executeAcrossWorkspaces(workspaceConfigs, action, page) { - for (const config of workspaceConfigs) { - console.log(`šŸ”„ Executing across workspace: ${config.workspaceKey}`); - // Navigate to the workspace - await navigateToWorkspace(config.workspaceKey, page); - // Execute the action - await action(config); - // Navigate back to workspace selection for next iteration - if (workspaceConfigs.indexOf(config) < workspaceConfigs.length - 1) { - await navigateToWorkspaceSelection(page); - } - } -} -/** - * Find and access any patient whose name contains the search term (optimized version) - * @param searchTerm - Partial name to search for (e.g., "Custodial") - * @param page - The Playwright page object - * @returns The full name of the patient that was accessed - */ -async function findAndAccessPatientByPartialName(searchTerm, page) { - const dashboard = new ClinicianDashboardPage(page); - // If empty search term, find any available patient - if (!searchTerm || searchTerm.trim() === '') { - return findAndAccessAnyPatient(page); - } - // Strategy 1: Fill search field THEN click Show All (proven fastest method) - try { - await dashboard.searchInput.fill(searchTerm); - await page.waitForTimeout(500); - const showAllButton = page - .getByRole('button', { name: 'Show All' }) - .or(page.getByRole('button', { name: 'Show all' })) - .or(page.getByText('Show All')) - .or(page.getByText('Show all')); - if (await showAllButton.isVisible({ timeout: 1000 })) { - await showAllButton.click(); - await page.waitForTimeout(1000); - const searchResultCells = await dashboard.patientListTable.getByRole('cell').all(); - if (searchResultCells.length > 0) { - for (const cell of searchResultCells) { - const cellText = await cell.textContent(); - if (cellText && cellText.toLowerCase().includes(searchTerm.toLowerCase())) { - await cell.click(); - await page.waitForTimeout(600); - return cellText.trim(); - } - } - } - } - else { - await dashboard.searchInput.press('Enter'); - await page.waitForTimeout(1000); - const searchResultCells = await dashboard.patientListTable.getByRole('cell').all(); - if (searchResultCells.length > 0) { - for (const cell of searchResultCells) { - const cellText = await cell.textContent(); - if (cellText && cellText.toLowerCase().includes(searchTerm.toLowerCase())) { - await cell.click(); - await page.waitForTimeout(600); - return cellText.trim(); - } - } - } - } - } - catch (error) { - // Silent fallback to any patient - } - // Strategy 2: Fallback to any available patient if specific search fails - try { - return await findAndAccessAnyPatient(page); - } - catch (fallbackError) { - throw new Error(`No patient found containing "${searchTerm}" and no fallback patients available`); - } -} -/** - * Find and access any available patient (fastest option) - * @param page - The Playwright page object - * @returns The full name of the first patient that was accessed - */ -async function findAndAccessAnyPatient(page) { - const dashboard = new ClinicianDashboardPage(page); - try { - // Clear search to show all patients - await dashboard.searchInput.click(); - await dashboard.searchInput.fill(' '); - await page.waitForTimeout(500); - await dashboard.searchInput.fill(''); - await page.waitForTimeout(1500); - let allCells = await dashboard.patientListTable.getByRole('cell').all(); - // If no cells, try pressing Enter on empty search - if (allCells.length === 0) { - await dashboard.searchInput.press('Enter'); - await page.waitForTimeout(1500); - allCells = await dashboard.patientListTable.getByRole('cell').all(); - } - // Find the first cell that looks like a patient name - for (const cell of allCells) { - const cellText = await cell.textContent(); - if (cellText && cellText.trim().length > 3 && cellText.includes(' ')) { - await cell.click(); - await page.waitForTimeout(800); - return cellText.trim(); - } - } - throw new Error('No patient names found in table'); - } - catch (error) { - throw new Error(`Failed to find any patient: ${error}`); - } -} -/** - * Access a specific patient by name and navigate to their summary page - * @param patientName - The name of the patient to access - * @param page - The Playwright page object - */ -async function accessPatient(patientName, page) { - const dashboard = new ClinicianDashboardPage(page); - console.log(`šŸ” Searching for patient: ${patientName}`); - // Try optimized search first - await dashboard.searchForPatient(patientName); - await page.waitForTimeout(1000); // Reduced wait time - // Check if search worked - const patientCell = dashboard.getPatientCellByName(patientName); - const isVisible = await patientCell.isVisible({ timeout: 2000 }); - if (isVisible) { - console.log(`šŸ‘¤ Found patient via search: ${patientName}`); - await patientCell.click(); - await page.waitForTimeout(1000); - console.log(`āœ… Successfully accessed patient summary for: ${patientName}`); - return; - } - // If search failed, fall back to show all + find - console.log(`šŸ”„ Search failed, trying show all approach...`); - const showAllButton = page.getByRole('button', { name: 'Show All' }); - if (await showAllButton.isVisible({ timeout: 1000 })) { - await showAllButton.click(); - await page.waitForTimeout(1500); - } - // Try again after showing all - const isVisibleAfterShowAll = await patientCell.isVisible({ timeout: 2000 }); - if (isVisibleAfterShowAll) { - await patientCell.click(); - await page.waitForTimeout(1000); - // console.log(`āœ… Successfully accessed patient summary for: ${patientName}`); - return; - } - // If still not found, throw error - throw new Error(`Patient "${patientName}" not found in current workspace`); -} -const test = base; -test.clinician = { - navigateTo, - navigateToWorkspace, - navigateToWorkspaceSelection, - executeAcrossWorkspaces, - accessPatient, - findAndAccessPatientByPartialName, - findAndAccessAnyPatient, - setup: setupClinicianSession, -}; -export { test }; diff --git a/dist/tests/fixtures/network-helpers.d.ts b/dist/tests/fixtures/network-helpers.d.ts deleted file mode 100644 index 78ad092..0000000 --- a/dist/tests/fixtures/network-helpers.d.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Page } from '@playwright/test'; -import { type EndpointName } from '../../endpoint-schema/endpoint-registry'; -export interface NetworkCapture { - url: string; - method: string; - requestBody?: any; - responseBody?: any; - statusCode?: number; - timestamp: number; -} -/** - * Simple network helper for API validation - */ -export declare class NetworkHelper { - private page; - private captures; - private isCapturing; - constructor(page: Page); - startCapture(): Promise; - stopCapture(): Promise; - waitForEndpoint(endpointName: string, method: string, timeout?: number): Promise; - getCaptures(): NetworkCapture[]; - /** - * Simple helper to validate endpoint requests by URL pattern and method - */ - validateEndpointRequests(urlPattern: string, method: string): NetworkCapture[]; - /** - * Save all captures to a JSON file - */ - saveCapturesTo(filename: string, testInfo?: import('@playwright/test').TestInfo): Promise; - /** - * Print a summary of all captures to console - */ - printCaptureSummary(): void; - /** - * Get captures filtered by status code - */ - getCapturesByStatus(statusCode: number): NetworkCapture[]; - /** - * Get the most recent capture matching method and URL pattern - */ - getLatestCaptureMatching(method: string, urlPattern: RegExp): NetworkCapture | null; - /** - * Get all captures for a specific endpoint - */ - getCapturesForEndpoint(endpointName: string): NetworkCapture[]; - /** - * Get all captures - */ - getAllCaptures(): NetworkCapture[]; - /** - * Save API response as JSON attachment and to organized test-results folder - */ - saveApiResponse(response: any, endpoint: string, method: string, fileName: string, testInfo?: import('@playwright/test').TestInfo): Promise; - /** - * Validate and save API response for any endpoint defined in the endpoint registry - * @param endpointName - The endpoint name from the registry (e.g., 'profile-metadata-get') - * @returns The captured network request or null if not found - */ - validateEndpointResponse(endpointName: EndpointName): Promise; - /** - * Save network capture for producer/consumer test patterns - * @param endpointName - The endpoint to save - * @param testName - Name of the test (used for file naming) - * @returns The saved network capture or null - */ - saveForDependentTests(endpointName: EndpointName, testName: string): Promise; - /** - * Load producer test data for consumer tests - * @param testName - Name of the producer test (used for file naming) - * @returns The loaded network capture or null - */ - loadFromProducerTest(testName: string): NetworkCapture | null; - /** - * Validate data consistency between producer and consumer responses - * @param producerCapture - The producer test network capture - * @param consumerCapture - The consumer test network capture - * @param fieldsToValidate - Array of field paths to validate (e.g., ['fullName', 'patient.birthday']) - * @param requiredFields - Array of fields that must exist and match (defaults to common required fields) - */ - validateDataConsistency(producerCapture: NetworkCapture, consumerCapture: NetworkCapture, fieldsToValidate?: string[], requiredFields?: string[]): void; - /** - * Helper method to get nested object values using dot notation - * @param obj - The object to search - * @param path - The dot-notation path (e.g., 'patient.birthday') - * @returns The value at the path or undefined - */ - private getNestedValue; - /** - * Validate producer-consumer data consistency for profile endpoints - * @param producerEndpointName - The PUT endpoint name (e.g., 'profile-metadata-put') - * @param consumerEndpointName - The GET endpoint name (e.g., 'profile-metadata-get') - * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) - * @throws Error if validation fails - */ - validateProducerConsumerData(producerEndpointName: EndpointName, consumerEndpointName: EndpointName, fieldsToValidate?: string[]): Promise; - /** - * Private method to validate endpoint response without generating JSON file - * @param endpointName - The endpoint name from the registry - * @returns The captured network request or null if not found - */ - private validateEndpointResponseSilent; - /** - * Complete validation workflow for a user viewing profile data - * Validates both API schema and data consistency in one call - * @param consumerEndpointName - The GET endpoint name - * @param producerCapture - The stored PUT capture from the producer - * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) - */ - compareEndpointResponse(consumerEndpointName: EndpointName, producerCapture: NetworkCapture, fieldsToValidate?: string[]): Promise; -} -export declare function createNetworkHelper(page: Page): NetworkHelper; diff --git a/dist/tests/fixtures/network-helpers.js b/dist/tests/fixtures/network-helpers.js deleted file mode 100644 index 09fb0cf..0000000 --- a/dist/tests/fixtures/network-helpers.js +++ /dev/null @@ -1,442 +0,0 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { getEndpointSchema, } from '../../endpoint-schema/endpoint-registry'; -const ENDPOINTS = { - profile: /\/data\/[^\/]+$/, // GET requests for patient data - profileUpdate: /\/data\/[^\/]+$/, // PUT requests for patient data updates - profileMetrics: /\/metrics\/thisuser\//, - profileMessage: /\/message\/notes\//, -}; -/** - * Simple network helper for API validation - */ -export class NetworkHelper { - constructor(page) { - this.captures = []; - this.isCapturing = false; - this.page = page; - } - async startCapture() { - if (this.isCapturing) - return; - // Only intercept API requests we care about to avoid interfering with other requests - const apiPatterns = [ - '**/data/**', - '**/metrics/**', - '**/message/**', - '**/auth/**', - '**/v1/**', - '**/metadata/**', - '**/user/**', - '**/users/**', - '**/profile/**', - ]; - for (const pattern of apiPatterns) { - await this.page.route(pattern, async (route) => { - const request = route.request(); - try { - const response = await route.fetch(); - let requestBody; - let responseBody; - try { - requestBody = request.postDataJSON(); - } - catch { - requestBody = request.postData(); - } - try { - responseBody = await response.json(); - } - catch { - responseBody = await response.text(); - } - this.captures.push({ - url: request.url(), - method: request.method(), - requestBody, - responseBody, - statusCode: response.status(), - timestamp: Date.now(), - }); - await route.fulfill({ response }); - } - catch (error) { - // If there's an error, continue the request without handling - try { - await route.continue(); - } - catch { - // Route might already be handled, ignore - } - } - }); - } - this.isCapturing = true; - } - async stopCapture() { - if (!this.isCapturing) - return; - // Remove all API route handlers - const apiPatterns = ['**/data/**', '**/metrics/**', '**/message/**', '**/auth/**', '**/v1/**']; - for (const pattern of apiPatterns) { - await this.page.unroute(pattern); - } - this.isCapturing = false; - } - async waitForEndpoint(endpointName, method, timeout = 30000) { - const pattern = ENDPOINTS[endpointName]; - if (!pattern) { - throw new Error(`Unknown endpoint: ${endpointName}`); - } - const startTime = Date.now(); - while (Date.now() - startTime < timeout) { - const matches = this.captures.filter(capture => pattern.test(capture.url) && capture.method.toLowerCase() === method.toLowerCase()); - if (matches.length > 0) { - return matches[matches.length - 1]; // Return latest match - } - await this.page.waitForTimeout(100); - } - throw new Error(`${method} request to ${endpointName} not found within ${timeout}ms`); - } - getCaptures() { - return [...this.captures]; - } - /** - * Simple helper to validate endpoint requests by URL pattern and method - */ - validateEndpointRequests(urlPattern, method) { - return this.captures.filter(c => c.url.includes(urlPattern) && c.method === method); - } - /** - * Save all captures to a JSON file - */ - async saveCapturesTo(filename, testInfo) { - const logDir = path.join(process.cwd(), 'log'); - if (!fs.existsSync(logDir)) { - fs.mkdirSync(logDir, { recursive: true }); - } - // Create capture data - const captureData = { - timestamp: new Date().toISOString(), - totalCaptures: this.captures.length, - captures: this.captures, - }; - // Use Playwright's automatic attachment instead of manual file writing - if (testInfo && typeof testInfo.attach === 'function') { - await testInfo.attach(filename, { - body: JSON.stringify(captureData, null, 2), - contentType: 'application/json', - }); - console.log(`šŸ“„ Network captures attached to Playwright report: ${filename}`); - } - else { - console.log(`šŸ“„ Network captures ready (${this.captures.length} captures)`); - } - } - /** - * Print a summary of all captures to console - */ - printCaptureSummary() { - console.log(`\nšŸ“Š Network Capture Summary (${this.captures.length} total requests):`); - console.log('='.repeat(60)); - this.captures.forEach((capture, index) => { - const timestamp = new Date(capture.timestamp).toLocaleTimeString(); - console.log(`${index + 1}. ${capture.method} ${capture.statusCode} - ${capture.url}`); - console.log(` Time: ${timestamp}`); - if (capture.requestBody) { - console.log(` Request: ${JSON.stringify(capture.requestBody).substring(0, 100)}...`); - } - console.log(''); - }); - } - /** - * Get captures filtered by status code - */ - getCapturesByStatus(statusCode) { - return this.captures.filter(c => c.statusCode === statusCode); - } - /** - * Get the most recent capture matching method and URL pattern - */ - getLatestCaptureMatching(method, urlPattern) { - const matches = this.captures - .filter(c => c.method === method && urlPattern.test(c.url)) - .sort((a, b) => b.timestamp - a.timestamp); - return matches.length > 0 ? matches[0] : null; - } - /** - * Get all captures for a specific endpoint - */ - getCapturesForEndpoint(endpointName) { - const pattern = ENDPOINTS[endpointName]; - if (!pattern) { - throw new Error(`Unknown endpoint: ${endpointName}`); - } - return this.captures.filter(c => pattern.test(c.url)); - } - /** - * Get all captures - */ - getAllCaptures() { - return [...this.captures]; - } - /** - * Save API response as JSON attachment and to organized test-results folder - */ - async saveApiResponse(response, endpoint, method, fileName, testInfo) { - const responseData = { - _request: { - method, - endpoint, - }, - ...response, - }; - const jsonContent = JSON.stringify(responseData, null, 2); - // Attach to Playwright report AND save to organized test-results folder - if (testInfo && typeof testInfo.attach === 'function') { - await testInfo.attach(fileName, { - body: jsonContent, - contentType: 'application/json', - }); - // Also save to test-results for organized viewing (like screenshots) - const testResultsDir = path.join(testInfo.outputDir, 'attachments'); - await fs.promises.mkdir(testResultsDir, { recursive: true }); - const jsonPath = path.join(testResultsDir, fileName); - await fs.promises.writeFile(jsonPath, jsonContent, 'utf8'); - } - } - /** - * Validate and save API response for any endpoint defined in the endpoint registry - * @param endpointName - The endpoint name from the registry (e.g., 'profile-metadata-get') - * @returns The captured network request or null if not found - */ - async validateEndpointResponse(endpointName) { - const schema = getEndpointSchema(endpointName); - const request = this.getLatestCaptureMatching(schema.method, schema.url); - if (request?.responseBody) { - // Access the shared step counter from the stepScreenshoter fixture - const stepCounterObj = globalThis.__stepCounter; - if (stepCounterObj) { - const stepNumber = stepCounterObj.increment(); - const currentStepName = stepCounterObj.getCurrentStepName(); - // Create consistent filename with step number and step name (like screenshots) - const stepNameForFile = currentStepName - ? currentStepName.toLowerCase().replace(/[^a-z0-9]/g, '-') - : endpointName.replace(/[^a-z0-9]/gi, '-'); - const fileName = `step-${stepNumber.toString().padStart(2, '0')}-${stepNameForFile}-response.json`; - await this.saveApiResponse(request.responseBody, request.url, schema.method, fileName, globalThis.testInfo); - } - } - return request; - } - /** - * Save network capture for producer/consumer test patterns - * @param endpointName - The endpoint to save - * @param testName - Name of the test (used for file naming) - * @returns The saved network capture or null - */ - async saveForDependentTests(endpointName, testName) { - const schema = getEndpointSchema(endpointName); - const capture = this.getLatestCaptureMatching(schema.method, schema.url); - if (capture) { - // Create step-based filename for better organization - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const stepName = testName.toLowerCase().replace(/[^a-z0-9]/g, '-'); - const fileName = `step-api-${stepName}-${endpointName.replace(/[^a-z0-9]/gi, '-')}-${timestamp}.json`; - console.log(`āœ… Saved ${endpointName} response for dependent tests`); - // Use Playwright's automatic attachment instead of file system - const { testInfo } = globalThis; - if (testInfo && typeof testInfo.attach === 'function') { - await testInfo.attach(fileName, { - body: JSON.stringify(capture, null, 2), - contentType: 'application/json', - }); - } - return capture; - } - return null; - } - /** - * Load producer test data for consumer tests - * @param testName - Name of the producer test (used for file naming) - * @returns The loaded network capture or null - */ - loadFromProducerTest(testName) { - const filePath = path.join(process.cwd(), 'log', 'test-data-pipeline', `${testName}-response.json`); - if (fs.existsSync(filePath)) { - const fileContent = fs.readFileSync(filePath, 'utf-8'); - const capture = JSON.parse(fileContent); - console.log(`āœ… Loaded ${testName} response from producer test`); - return capture; - } - throw new Error(`Producer test data not found at: ${filePath}. Please run ${testName} test first.`); - } - /** - * Validate data consistency between producer and consumer responses - * @param producerCapture - The producer test network capture - * @param consumerCapture - The consumer test network capture - * @param fieldsToValidate - Array of field paths to validate (e.g., ['fullName', 'patient.birthday']) - * @param requiredFields - Array of fields that must exist and match (defaults to common required fields) - */ - validateDataConsistency(producerCapture, consumerCapture, fieldsToValidate, requiredFields = ['fullName']) { - // Use provided fields or fall back to a basic set for backward compatibility - const defaultFields = ['fullName', 'patient.fullName', 'patient.birthday', 'email']; - const fieldsToCheck = fieldsToValidate || defaultFields; - const producerData = producerCapture.responseBody; - const consumerData = consumerCapture.responseBody; - if (!producerData || !consumerData) { - throw new Error('Missing response data for consistency validation'); - } - console.log('šŸ” Validating data consistency:'); - // Only log full data in development mode - if (process.env.VERBOSE_VALIDATION) { - console.log('Producer:', JSON.stringify(producerData, null, 2)); - console.log('Consumer:', JSON.stringify(consumerData, null, 2)); - } - else { - console.log('Producer fullName:', producerData.fullName); - console.log('Consumer fullName:', consumerData.fullName); - } - // Validate each specified field - for (const fieldPath of fieldsToCheck) { - const producerValue = this.getNestedValue(producerData, fieldPath); - const consumerValue = this.getNestedValue(consumerData, fieldPath); - // Check if this field is marked as required - const isRequired = requiredFields.includes(fieldPath); - if (isRequired) { - if (producerValue === undefined || producerValue === null) { - throw new Error(`Required field ${fieldPath} is missing in producer data`); - } - if (consumerValue === undefined || consumerValue === null) { - throw new Error(`Required field ${fieldPath} is missing in consumer data`); - } - } - // For optional fields: only validate if the field exists in producer data - // If it exists in producer, it must also exist in consumer with same value - if (producerValue !== undefined && producerValue !== null) { - // Handle array comparison - if (Array.isArray(producerValue) && Array.isArray(consumerValue)) { - if (JSON.stringify(producerValue) !== JSON.stringify(consumerValue)) { - throw new Error(`${fieldPath} mismatch - Expected: ${JSON.stringify(producerValue)}, Got: ${JSON.stringify(consumerValue)}`); - } - } - else if (producerValue !== consumerValue) { - throw new Error(`${fieldPath} mismatch - Expected: ${producerValue}, Got: ${consumerValue}`); - } - } - // If producer value doesn't exist, consumer doesn't need to have it either (optional field) - } - console.log('āœ… Data consistency validated: consumer data reflects producer changes'); - } - /** - * Helper method to get nested object values using dot notation - * @param obj - The object to search - * @param path - The dot-notation path (e.g., 'patient.birthday') - * @returns The value at the path or undefined - */ - getNestedValue(obj, path) { - return path.split('.').reduce((current, key) => current?.[key], obj); - } - /** - * Validate producer-consumer data consistency for profile endpoints - * @param producerEndpointName - The PUT endpoint name (e.g., 'profile-metadata-put') - * @param consumerEndpointName - The GET endpoint name (e.g., 'profile-metadata-get') - * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) - * @throws Error if validation fails - */ - async validateProducerConsumerData(producerEndpointName, consumerEndpointName, fieldsToValidate) { - const producerSchema = getEndpointSchema(producerEndpointName); - const consumerSchema = getEndpointSchema(consumerEndpointName); - // Use provided fields, or consumer endpoint validation fields, or producer endpoint validation fields - const validationFields = fieldsToValidate || - consumerSchema.validationFields || - producerSchema.validationFields || ['fullName', 'email']; - // Use consumer endpoint required fields, or producer endpoint required fields, or default - const requiredFields = consumerSchema.requiredFields || - producerSchema.requiredFields || ['fullName']; - const producerCapture = this.getLatestCaptureMatching(producerSchema.method, producerSchema.url); - const consumerCapture = this.getLatestCaptureMatching(consumerSchema.method, consumerSchema.url); - if (!producerCapture) { - throw new Error(`No ${producerEndpointName} capture found for producer validation`); - } - if (!consumerCapture) { - throw new Error(`No ${consumerEndpointName} capture found for consumer validation`); - } - this.validateDataConsistency(producerCapture, consumerCapture, validationFields, requiredFields); - } - /** - * Private method to validate endpoint response without generating JSON file - * @param endpointName - The endpoint name from the registry - * @returns The captured network request or null if not found - */ - validateEndpointResponseSilent(endpointName) { - const schema = getEndpointSchema(endpointName); - const request = this.getLatestCaptureMatching(schema.method, schema.url); - return request; - } - /** - * Complete validation workflow for a user viewing profile data - * Validates both API schema and data consistency in one call - * @param consumerEndpointName - The GET endpoint name - * @param producerCapture - The stored PUT capture from the producer - * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) - */ - async compareEndpointResponse(consumerEndpointName, producerCapture, fieldsToValidate) { - // Get the endpoint schema to determine validation fields - const consumerSchema = getEndpointSchema(consumerEndpointName); - // Use provided fields, or endpoint-specific fields, or fall back to basic fields - const validationFields = fieldsToValidate || - consumerSchema.validationFields || ['fullName', 'patient.fullName', 'email']; - // Use endpoint-specific required fields, or default to fullName for backward compatibility - const requiredFields = consumerSchema.requiredFields || ['fullName']; - // Validate GET response schema without generating JSON file - const consumerCapture = this.validateEndpointResponseSilent(consumerEndpointName); - if (!consumerCapture) { - throw new Error(`No compare endpoint found`); - } - if (!producerCapture) { - throw new Error('No base endpoint found'); - } - // Generate comparison JSON file similar to validateEndpointResponse - const stepCounterObj = globalThis.__stepCounter; - if (stepCounterObj) { - // Increment for JSON file naming (this is correct behavior) - const stepNumber = stepCounterObj.increment(); - const currentStepName = stepCounterObj.getCurrentStepName(); - // Create comparison data object - const comparisonData = { - _comparison: { - description: `Data consistency comparison for ${consumerEndpointName}`, - timestamp: new Date().toISOString(), - fieldsValidated: validationFields, - requiredFields, - }, - original: { - url: producerCapture.url, - method: producerCapture.method, - timestamp: producerCapture.timestamp, - responseBody: producerCapture.responseBody, - }, - new: { - url: consumerCapture.url, - method: consumerCapture.method, - timestamp: consumerCapture.timestamp, - responseBody: consumerCapture.responseBody, - }, - }; - // Create consistent filename with step number and step name (like screenshots) - const stepNameForFile = currentStepName - ? currentStepName.toLowerCase().replace(/[^a-z0-9]/g, '-') - : consumerEndpointName.replace(/[^a-z0-9]/gi, '-'); - const fileName = `step-${stepNumber.toString().padStart(2, '0')}-${stepNameForFile}-comparison.json`; - // Save the comparison data using the unified approach - const { testInfo } = globalThis; - await this.saveApiResponse(comparisonData, consumerCapture.url, consumerCapture.method, fileName, testInfo); - } - // Validate data consistency using the determined validation fields and required fields - this.validateDataConsistency(producerCapture, consumerCapture, validationFields, requiredFields); - } -} -export function createNetworkHelper(page) { - return new NetworkHelper(page); -} diff --git a/dist/tests/fixtures/patient-helpers.d.ts b/dist/tests/fixtures/patient-helpers.d.ts deleted file mode 100644 index 03cb4d8..0000000 --- a/dist/tests/fixtures/patient-helpers.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { test as base } from '@fixtures/base'; -import PatientNav from '@pom/patient/PatientNavigation'; -import type { Page } from '@playwright/test'; -/** - * Initialize patient navigation helpers after login - */ -declare function setupPatientSession(page: Page): Promise; -/** - * New scalable navigation function using state machine approach - */ -declare function navigateTo(targetPage: keyof PatientNav['pages'], page: Page): Promise; -declare const test: typeof base & { - patient: { - navigateTo: typeof navigateTo; - setup: typeof setupPatientSession; - }; -}; -export { test }; diff --git a/dist/tests/fixtures/patient-helpers.js b/dist/tests/fixtures/patient-helpers.js deleted file mode 100644 index 9e06284..0000000 --- a/dist/tests/fixtures/patient-helpers.js +++ /dev/null @@ -1,477 +0,0 @@ -import { test as base } from '@fixtures/base'; -import PatientNav from '@pom/patient/PatientNavigation'; -import env from '../../utilities/env'; -/** - * Initialize patient navigation helpers after login - */ -async function setupPatientSession(page) { - // Wait for patient navigation to be available - const nav = new PatientNav(page); - await Promise.all([ - nav.pages.ViewData.link.waitFor({ state: 'visible' }), - nav.pages.Profile.link.waitFor({ state: 'visible' }), - ]); - return nav; -} -/** - * Close any open modal dialogs that might block navigation - */ -async function closeOpenDialogs(page) { - try { - if (page.isClosed()) - return; - // Simple and fast: just press Escape twice to close any modals - await page.keyboard.press('Escape'); - await page.keyboard.press('Escape'); - } - catch (error) { - // Ignore errors in dialog closing - they're not critical - } -} -/** - * Check if we're in a context where patient navigation is supported - */ -async function isInPatientContext(nav, page) { - try { - // Check if any patient navigation elements are visible - const patientElements = [nav.pages.ViewData.link, nav.pages.Profile.link, nav.pages.Share.link]; - for (const element of patientElements) { - if (await element.isVisible({ timeout: 1000 })) { - return true; - } - } - return false; - } - catch { - return false; - } -} -/** - * Get current page state by checking URL and visible elements - */ -async function getCurrentPageState(nav, page) { - const url = page.url(); - // Check each page in order of specificity - for (const [pageName, pageConfig] of Object.entries(nav.pages)) { - try { - if (pageConfig.verifyURL && url.includes(pageConfig.verifyURL)) { - if (pageConfig.verifyElement && - (await pageConfig.verifyElement.isVisible({ timeout: 1000 }))) { - return pageName; - } - } - } - catch { - // Continue checking other pages - } - } - return 'unknown'; -} -/** - * Navigation strategies for different page types - */ -const navigationStrategies = { - // Basic page navigation - default: [ - { - name: 'close-dialogs', - action: async (state) => closeOpenDialogs(state.page), - }, - { - name: 'check-patient-context', - condition: async (state) => !(await isInPatientContext(state.nav, state.page)), - action: async (state) => { - console.log('Not in patient context, navigating to /data URL to reset'); - // Navigate to /data endpoint specifically, not just base URL - await state.page.goto(`${env.BASE_URL}/data`); - await state.page.waitForLoadState('domcontentloaded'); - // Wait for patient navigation to be available - await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); - console.log('Successfully reset to patient context via /data URL'); - }, - }, - { - name: 'wait-for-loading', - action: async (state) => { - const loading = state.page.getByText('Loading...', { exact: true }); - try { - await loading.waitFor({ state: 'hidden', timeout: 3000 }); - } - catch { - // Loading might not be visible - } - }, - }, - { - name: 'navigate-click', - action: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - await pageConfig.link.click({ timeout: 5000 }); - }, - }, - { - name: 'verify-navigation', - verify: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - if (pageConfig.verifyElement) { - try { - await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); - return true; - } - catch { - return false; - } - } - return true; - }, - }, - ], - // Profile page - handle account settings conflict - Profile: [ - { - name: 'close-dialogs', - action: async (state) => closeOpenDialogs(state.page), - }, - { - name: 'check-patient-context', - condition: async (state) => !(await isInPatientContext(state.nav, state.page)), - action: async (state) => { - console.log('Not in patient context, navigating to /data URL to reset'); - // Navigate to /data endpoint specifically, not just base URL - await state.page.goto(`${env.BASE_URL}/data`); - await state.page.waitForLoadState('domcontentloaded'); - // Wait for patient navigation to be available - await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); - console.log('Successfully reset to patient context via /data URL'); - }, - }, - { - name: 'handle-account-settings-conflict', - condition: async (state) => state.page.url().includes('/profile') && - (await state.page - .getByRole('heading', { name: /account/i }) - .or(state.page.getByText('Account Settings')) - .or(state.page.getByText('Account')) - .or(state.page.locator('.profile-subnav-title').getByText('Account')) - .isVisible() - .catch(() => false)), - action: async (state) => { - console.log('On account settings page, redirecting to base URL first'); - await state.page.goto(env.BASE_URL); - await state.page.waitForTimeout(500); - }, - }, - { - name: 'navigate-click', - action: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - await pageConfig.link.click({ timeout: 5000 }); - }, - }, - { - name: 'verify-navigation', - verify: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - if (pageConfig.verifyElement) { - try { - await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); - return true; - } - catch { - return false; - } - } - return true; - }, - }, - ], - // Modal dialogs - modal: [ - { - name: 'close-dialogs', - action: async (state) => closeOpenDialogs(state.page), - }, - { - name: 'navigate-click', - action: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - await pageConfig.link.click({ timeout: 5000 }); - }, - }, - { - name: 'wait-for-modal', - action: async (state) => { - await state.page.waitForTimeout(500); - }, - }, - ], - // Data pages that need ViewData prerequisite - 'data-page': [ - { - name: 'close-dialogs', - action: async (state) => closeOpenDialogs(state.page), - }, - { - name: 'ensure-data-view', - condition: async (state) => !state.page.url().includes('/data/'), - action: async (state) => { - await state.nav.pages.ViewData.link.click(); - await state.nav.pages.ViewData.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); - }, - }, - { - name: 'navigate-click', - action: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - await pageConfig.link.click({ timeout: 5000 }); - }, - }, - { - name: 'verify-navigation', - verify: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - if (pageConfig.verifyElement) { - try { - await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); - return true; - } - catch { - return false; - } - } - return true; - }, - }, - ], - // ShareData requires Share main page to be accessible first - ShareData: [ - { - name: 'close-dialogs', - action: async (state) => closeOpenDialogs(state.page), - }, - { - name: 'check-patient-context', - condition: async (state) => !(await isInPatientContext(state.nav, state.page)), - action: async (state) => { - console.log('Not in patient context, navigating to /data URL to reset'); - await state.page.goto(`${env.BASE_URL}/data`); - await state.page.waitForLoadState('domcontentloaded'); - await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); - console.log('Successfully reset to patient context via /data URL'); - }, - }, - { - name: 'ensure-share-dependency', - action: async (state) => { - // First ensure Share main page is accessible - try { - await state.nav.pages.Share.link.waitFor({ state: 'visible', timeout: 3000 }); - console.log('Share dependency met - Share button is accessible'); - } - catch { - console.log('Share dependency not met - performing URL reset to /data'); - await state.page.goto(`${env.BASE_URL}/data`); - await state.page.waitForLoadState('domcontentloaded'); - await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); - console.log('URL reset completed, Share dependency should now be available'); - } - }, - }, - { - name: 'navigate-to-share-first', - action: async (state) => { - // Navigate to Share main page first to establish context - try { - await state.nav.pages.Share.link.click({ timeout: 3000 }); - await state.nav.pages.Share.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); - console.log('Successfully navigated to Share main page'); - } - catch { - console.log('Could not reach Share main page, staying in current state'); - } - }, - }, - { - name: 'navigate-to-sharedata', - action: async (state) => { - // Now try to navigate to ShareData sub-page - try { - await state.nav.pages.ShareData.link.click({ timeout: 5000 }); - console.log('Successfully clicked ShareData button'); - } - catch { - console.log('ShareData button not available - this is expected and OK'); - } - }, - }, - { - name: 'verify-navigation', - verify: async (state) => { - // Try to verify ShareData first, fall back to Share if not available - try { - await state.nav.pages.ShareData.verifyElement.waitFor({ - state: 'visible', - timeout: 3000, - }); - console.log('āœ… ShareData page verified'); - return true; - } - catch { - try { - await state.nav.pages.Share.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); - console.log('āœ… Share main page verified (ShareData not available - this is OK)'); - return true; - } - catch { - console.log('Neither ShareData nor Share page could be verified'); - return false; - } - } - }, - }, - ], -}; -/** - * Page type mappings to determine which strategy to use - */ -const pageStrategies = { - ViewData: 'default', - Basics: 'data-page', - Daily: 'data-page', - BGLog: 'data-page', - Trends: 'data-page', - Devices: 'data-page', - Profile: 'Profile', - ProfileEdit: 'default', // TODO: Add prerequisite logic - Share: 'default', - ShareData: 'ShareData', // Uses dependency-aware strategy - UploadData: 'default', - ChartDateRange: 'modal', - ChartDate: 'modal', - Print: 'modal', -}; -/** - * Execute navigation strategy - */ -async function executeNavigationStrategy(state) { - const strategyName = pageStrategies[state.targetPage] || 'default'; - const strategy = navigationStrategies[strategyName]; - console.log(`Executing ${strategyName} strategy for ${state.targetPage}`); - for (const step of strategy) { - try { - // Check condition if present - if (step.condition && !(await step.condition(state))) { - console.log(`Skipping step ${step.name} - condition not met`); - continue; - } - console.log(`Executing step: ${step.name}`); - // Execute action if present - if (step.action) { - await step.action(state); - } - // Verify if present - if (step.verify && !(await step.verify(state))) { - console.log(`Step ${step.name} verification failed`); - return false; - } - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.log(`Step ${step.name} failed:`, errorMessage); - return false; - } - } - return true; -} -/** - * New scalable navigation function using state machine approach - */ -async function navigateTo(targetPage, page) { - if (page.isClosed()) { - console.log(`Page is closed, cannot navigate to ${targetPage}`); - return; - } - const nav = new PatientNav(page); - const currentPage = await getCurrentPageState(nav, page); - const state = { - currentPage, - targetPage, - nav, - page, - }; - console.log(`Navigating from ${currentPage} to ${targetPage}`); - // Execute primary navigation strategy - const success = await executeNavigationStrategy(state); - if (!success) { - console.log(`Primary navigation failed, trying fallback strategies`); - // Fallback strategy - go to base URL and try again - if (targetPage === 'Profile') { - try { - console.log('Profile fallback: going to base URL and trying again'); - await page.goto(env.BASE_URL); - await page.waitForTimeout(500); - await nav.pages[targetPage].link.click({ timeout: 3000 }); - console.log(`Successfully navigated to ${targetPage} via fallback`); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.log(`Profile fallback failed: ${errorMessage}`); - throw error; - } - } - else if (nav.pages[targetPage].verifyURL) { - // Generic URL fallback for pages with backup URLs - try { - let fallbackURL = env.BASE_URL; - // For sub-pages that might not be available, fall back to the main page - if (targetPage === 'ShareData') { - fallbackURL = `${env.BASE_URL}/share`; // Fall back to main Share page - } - else if (targetPage === 'ProfileEdit') { - fallbackURL = `${env.BASE_URL}/profile`; // Fall back to main Profile page - } - else if (['Basics', 'Daily', 'BGLog', 'Trends', 'Devices'].includes(targetPage)) { - fallbackURL = `${env.BASE_URL}/data`; // Fall back to main ViewData page - } - else if (nav.pages[targetPage].verifyURL) { - fallbackURL = `${env.BASE_URL}/${nav.pages[targetPage].verifyURL}`; - } - await page.goto(fallbackURL); - console.log(`Used backup URL for ${targetPage}: ${fallbackURL}`); - // For sub-pages that fall back to main pages, verify the main page elements - let { verifyElement } = nav.pages[targetPage]; - if (targetPage === 'ShareData') { - verifyElement = nav.pages.Share.verifyElement; // Verify main Share page instead - } - else if (targetPage === 'ProfileEdit') { - verifyElement = nav.pages.Profile.verifyElement; // Verify main Profile page instead - } - else if (['Basics', 'Daily', 'BGLog', 'Trends', 'Devices'].includes(targetPage)) { - verifyElement = nav.pages.ViewData.verifyElement; // Verify main ViewData page instead - } - // Wait for the fallback page to actually load and verify we're there - if (verifyElement) { - await verifyElement.waitFor({ - state: 'visible', - timeout: 10000, - }); - console.log(`āœ… Backup URL navigation to ${targetPage} verified successfully (using fallback verification)`); - } - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.log(`Backup URL failed: ${errorMessage}`); - throw error; - } - } - else { - throw new Error(`Navigation to ${targetPage} failed and no fallback available`); - } - } -} -const test = base; -test.patient = { - navigateTo, - setup: setupPatientSession, -}; -export { test }; diff --git a/dist/tests/fixtures/test-tags.d.ts b/dist/tests/fixtures/test-tags.d.ts deleted file mode 100644 index 8b9da8a..0000000 --- a/dist/tests/fixtures/test-tags.d.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Test Tags Fixture - * - * Simple tag definitions for test organization and Xray integration. - */ -export declare const TEST_TAGS: { - /** - * Generate a Jira-related tag for linking tests to Jira tickets. - * Usage: TEST_TAGS.RELATED('JIRA-1234') => '@jira(JIRA-1234)' - */ - RELATED: (jiraId: string) => string; - BACK_SHORELINE: string; - BACK_CLINIC: string; - BACK_HIGHWATER: string; - BACK_HYDROPHONE: string; - BACK_PLATFORM: string; - BACK_SEAGULL: string; - BACK_TIDEWHISPERER: string; - BACK_MESSAGEAPI: string; - BACK_JELLYFISH: string; - BACK_GATEKEEPER: string; - BACK_EXPORT: string; - BACK_KEYCLOAK: string; - PATIENT: string; - CLINICIAN: string; - CUSTODIAL: string; - SHARED_MEMBER: string; - PERSONAL: string; - CLAIMED: string; - API: string; - UI: string; - SMOKE: string; - REGRESSION: string; - CRITICAL: string; - HIGH: string; - MEDIUM: string; - LOW: string; - API_PROFILE: string; - API_USER: string; -}; -export declare const TAG_CATEGORIES: { - USER_TYPES: string[]; - TEST_TYPES: string[]; - PRIORITIES: string[]; -}; -/** - * Validates that tags include at least one from each required category - * @param tags Array of tags to validate - * @returns Object with validation results - */ -export declare function validateRequiredTags(tags: string[]): { - isValid: boolean; - missing: string[]; - message: string; -}; -/** - * Helper function to create tags with validation - * Throws error if required tags are missing - */ -export declare function createValidatedTags(tags: string[]): string[]; diff --git a/dist/tests/fixtures/test-tags.js b/dist/tests/fixtures/test-tags.js deleted file mode 100644 index 26b2aa7..0000000 --- a/dist/tests/fixtures/test-tags.js +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Test Tags Fixture - * - * Simple tag definitions for test organization and Xray integration. - */ -export const TEST_TAGS = { - /** - * Generate a Jira-related tag for linking tests to Jira tickets. - * Usage: TEST_TAGS.RELATED('JIRA-1234') => '@jira(JIRA-1234)' - */ - RELATED: (jiraId) => { - // Accepts formats like ABC-1234 or JIRA-1234 - const jiraPattern = /^[A-Z][A-Z0-9]+-\d+$/; - if (!jiraPattern.test(jiraId)) { - throw new Error(`Invalid Jira ID: ${jiraId}. Must match pattern ABC-1234.`); - } - return `@jira(${jiraId})`; - }, - // Backend Services - BACK_SHORELINE: '@back-shoreline', - BACK_CLINIC: '@back-clinic', - BACK_HIGHWATER: '@back-highwater', - BACK_HYDROPHONE: '@back-hydrophone', - BACK_PLATFORM: '@back-platform', - BACK_SEAGULL: '@back-seagull', - BACK_TIDEWHISPERER: '@back-tidewhisperer', - BACK_MESSAGEAPI: '@back-messageapi', - BACK_JELLYFISH: '@back-jellyfish', - BACK_GATEKEEPER: '@back-gatekeeper', - BACK_EXPORT: '@back-export', - BACK_KEYCLOAK: '@back-keycloak', - // User Types - PATIENT: '@patient', - CLINICIAN: '@clinician', - // User-Subtypes - CUSTODIAL: '@custodial', - SHARED_MEMBER: '@shared_member', - PERSONAL: '@personal', - CLAIMED: '@claimed', - // Test Types - API: '@api', - UI: '@ui', - SMOKE: '@smoke', - REGRESSION: '@regression', - // Priority - CRITICAL: '@critical', - HIGH: '@high', - MEDIUM: '@medium', - LOW: '@low', - // Endpoint API Testing - API_PROFILE: '@api_profile', - API_USER: '@api_user', -}; -// Tag Categories for Validation -export const TAG_CATEGORIES = { - USER_TYPES: [TEST_TAGS.PATIENT, TEST_TAGS.CLINICIAN], - TEST_TYPES: [TEST_TAGS.API, TEST_TAGS.UI, TEST_TAGS.SMOKE, TEST_TAGS.REGRESSION], - PRIORITIES: [TEST_TAGS.CRITICAL, TEST_TAGS.HIGH, TEST_TAGS.MEDIUM, TEST_TAGS.LOW], -}; -/** - * Validates that tags include at least one from each required category - * @param tags Array of tags to validate - * @returns Object with validation results - */ -export function validateRequiredTags(tags) { - const hasUserType = tags.some(tag => TAG_CATEGORIES.USER_TYPES.includes(tag)); - const hasTestType = tags.some(tag => TAG_CATEGORIES.TEST_TYPES.includes(tag)); - const hasPriority = tags.some(tag => TAG_CATEGORIES.PRIORITIES.includes(tag)); - const isValid = hasUserType && hasTestType && hasPriority; - const missing = []; - if (!hasUserType) - missing.push('User Type'); - if (!hasTestType) - missing.push('Test Type'); - if (!hasPriority) - missing.push('Priority'); - return { - isValid, - missing, - message: isValid ? 'All required tags present' : `Missing required tags: ${missing.join(', ')}`, - }; -} -/** - * Helper function to create tags with validation - * Throws error if required tags are missing - */ -export function createValidatedTags(tags) { - const validation = validateRequiredTags(tags); - if (!validation.isValid) { - throw new Error(`Test tags validation failed: ${validation.message}`); - } - return tags; -} diff --git a/dist/tests/global-setup.d.ts b/dist/tests/global-setup.d.ts deleted file mode 100644 index b9988ec..0000000 --- a/dist/tests/global-setup.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { FullConfig } from '@playwright/test'; -export default function globalSetup(_config: FullConfig): Promise; diff --git a/dist/tests/global-setup.js b/dist/tests/global-setup.js deleted file mode 100644 index 4cd1e80..0000000 --- a/dist/tests/global-setup.js +++ /dev/null @@ -1,41 +0,0 @@ -import { chromium } from '@playwright/test'; -import LoginPage from '@pom/LoginPage'; -import fs from 'node:fs'; -import path from 'node:path'; -import env from '../utilities/env'; -async function loginUserType(role) { - const browser = await chromium.launch(); - const context = await browser.newContext({ - baseURL: process.env.BASE_URL, - }); - const page = await context.newPage(); - await page.goto(env.BASE_URL); - const loginPage = new LoginPage(page); - if (role === 'personal') { - await loginPage.login(env.PERSONAL_USERNAME, env.PERSONAL_PASSWORD); - await page.waitForURL('**/data'); - } - else if (role === 'claimed') { - await loginPage.login(env.CLAIMED_USERNAME, env.CLAIMED_PASSWORD); - await page.waitForURL('**/data'); - } - else if (role === 'shared') { - await loginPage.login(env.SHARED_USERNAME, env.SHARED_PASSWORD); - await page.waitForURL('**/data'); - } - else { - await loginPage.login(env.CLINICIAN_USERNAME, env.CLINICIAN_PASSWORD); - await page.waitForURL('**/workspaces'); - } - const authDir = path.resolve(process.cwd(), 'tests', '.auth'); - await fs.promises.mkdir(authDir, { recursive: true }); - const filePath = path.join(authDir, `${role}.json`); - await context.storageState({ path: filePath }); - await browser.close(); -} -export default async function globalSetup(_config) { - await loginUserType('personal'); - await loginUserType('claimed'); - await loginUserType('shared'); - await loginUserType('clinician'); -} diff --git a/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.d.ts b/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.d.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.js b/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.js deleted file mode 100644 index 6027330..0000000 --- a/dist/tests/personal/AP-Profile/edit-personal-profile-API.spec.js +++ /dev/null @@ -1,73 +0,0 @@ -import { test } from '../../fixtures/patient-helpers'; -import { createNetworkHelper } from '../../fixtures/network-helpers'; -import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; -import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; -test.describe('Personal Accounts allow access and modification of profile details', () => { - // API Test cases require this to capture network activity - let api; - test('should allow navigation to profile details and edit profile fields', { - tag: createValidatedTags([ - TEST_TAGS.PATIENT, // User Type (required) - TEST_TAGS.PERSONAL, // User Subtype (required) - TEST_TAGS.API, // Test Type (required) - TEST_TAGS.UI, // Test Type (required) - TEST_TAGS.HIGH, // Priority (required) - TEST_TAGS.API_PROFILE, // Feature (optional) - ]), - }, async ({ page }) => { - // Step 1: Log in to personal account and setup network capture - await test.step('Given personal account has been logged in', async () => { - api = createNetworkHelper(page); - await api.startCapture(); - await page.goto('/data'); - await test.patient.setup(page); - // Step 2: Navigate to profile - await test.step('When user navigates to Profile page', async () => { - await test.patient.navigateTo('Profile', page); - }); - // Step 3: Check profile GET response - await test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - // Step 4: Open Edit Profile - await test.step('When user selects Edit button', async () => { - await test.patient.navigateTo('ProfileEdit', page); - }); - // Initialize ProfilePage for steps 4 and 5 - const profilePage = new ProfilePage(page); - // Step 5: Change profile fields (confirmed user access) - await test.step('When user updates profile fields', async () => { - // Generate completely unique values for this confirmed user test run - const testRunId = Math.floor(Math.random() * 10000); - const updatedName = `Personal Patient Updated ${testRunId}`; - const birthYear = 1985 + (testRunId % 10); - const diagnosisYear = birthYear + 20; - const birthDate = `01/15/${birthYear}`; - const diagnosisDate = `03/10/${diagnosisYear}`; - // Generate random 15-letter string for clinical notes - const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); - // Get current diagnosis index and calculate next one (1-7, wrapping) - const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); - let nextDiagnosisIndex = currentDiagnosisIndex + 1; - if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { - nextDiagnosisIndex = 1; - } - // Update fields using ProfilePage methods - await profilePage.fillFullName(updatedName); - await profilePage.fillBirthDate(birthDate); - await profilePage.fillDiagnosisDate(diagnosisDate); - await profilePage.selectDiagnosisType(nextDiagnosisIndex); - await profilePage.fillClinicalNotes(randomString); - }); - // Step 6: Save profile edit - await test.step('When user saves profile changes', async () => { - await profilePage.saveProfile(); - }); - // Step 7: Check profile PUT response - await test.stepNoScreenshot('Then profile endpoint responds with PUT request consistent with schema', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - }); - await api.stopCapture(); - }); - }); -}); diff --git a/dist/tests/personal/basic-functionality.spec.d.ts b/dist/tests/personal/basic-functionality.spec.d.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/tests/personal/basic-functionality.spec.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/tests/personal/basic-functionality.spec.js b/dist/tests/personal/basic-functionality.spec.js deleted file mode 100644 index 84da7d1..0000000 --- a/dist/tests/personal/basic-functionality.spec.js +++ /dev/null @@ -1,235 +0,0 @@ -// @ts-check -import { expect, test } from '@fixtures/base'; -import PatientDataBasicsPage from '@pom/patient/BasicsPage'; -import PatientDataDailyPage from '@pom/patient/DailyPage'; -test.describe('Patient Data Navigation and Visualization', () => { - test.beforeEach(async ({ page }) => { - await test.step('Given user has been logged in', async () => { - const basicsPage = new PatientDataBasicsPage(page); - await basicsPage.goto(); - // await page.getByText("Loading").waitFor({ state: "detached", timeout: 10000 }); - }); - }); - // BG readings dashboard functionality - test('should display daily chart when selecting a date from basics page', async ({ page }) => { - const basicsPage = new PatientDataBasicsPage(page); - const dailyPage = new PatientDataDailyPage(page); - let selectedDateText; - await test.step('When the navigation bar is visible', async () => { - await basicsPage.navigationBar.buttons.viewData.waitFor({ - state: 'visible', - }); - }); - await test.step('When the user clicks on the most recent day', async () => { - const recentDayElement = basicsPage.bgReadingsSection.firstDayOfData; - await recentDayElement.waitFor({ state: 'visible' }); - await recentDayElement.hover(); - selectedDateText = await basicsPage.bgReadingsSection.calendarDayhover.text(); - await basicsPage.bgReadingsSection.calendarDayhover.el.click(); - }); - await test.step('Then the daily chart is visible and correctly rendered', async () => { - const chartContainer = dailyPage.dailyChart.container; - await chartContainer.waitFor({ state: 'visible' }); - if (!selectedDateText) { - throw new Error('Selected date text is null'); - } - // Verify the selected date matches the displayed date - // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); - // Capture chart screenshot for visual regression - await expect(chartContainer).toHaveScreenshot('daily-chart-1.png'); - }); - }); - // Bolus dashboard functionality - test('should display bolus dashboard when selecting a date from basics page', async ({ page, }) => { - const basicsPage = new PatientDataBasicsPage(page); - const dailyPage = new PatientDataDailyPage(page); - let selectedDateText; - await test.step('When the navigation bar is visible', async () => { - await basicsPage.navigationBar.buttons.viewData.waitFor({ - state: 'visible', - }); - }); - await test.step('When the user clicks on the most recent day', async () => { - const recentDayElement = basicsPage.bolusingSection.firstDayOfData; - await recentDayElement.waitFor({ state: 'visible' }); - await recentDayElement.hover(); - selectedDateText = await basicsPage.bolusingSection.calendarDayhover.text(); - await basicsPage.bolusingSection.calendarDayhover.el.click(); - }); - await test.step('Then the daily chart is visible and correctly rendered', async () => { - const chartContainer = dailyPage.dailyChart.container; - await chartContainer.waitFor({ state: 'visible' }); - if (!selectedDateText) { - throw new Error('Selected date text is null'); - } - // Verify the selected date matches the displayed date - // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); - // Capture chart screenshot for visual regression - await expect(chartContainer).toHaveScreenshot('daily-chart-2.png'); - }); - }); - // Infusion Site Changes dashboard functionality - test('should display Infusion site changes dashboard when selecting a date from basics page', async ({ page, }) => { - const basicsPage = new PatientDataBasicsPage(page); - const dailyPage = new PatientDataDailyPage(page); - let selectedDateText; - await test.step('When the infusion site changes dashboard is visible', async () => { - // Verify dashboard title and initial state - // await expect(basicsPage.tubingPrimeSection.title).toBeVisible(); - // await expect(basicsPage.tubingPrimeSection.description).toHaveText( - // "We are using Fill Cannula to visualize your infusion site changes." - // ); - }); - await test.step('When testing Fill Cannula functionality', async () => { - // Verify radio button options - await basicsPage.tubingPrimeSection.settingsOption.fillCannula.waitFor({ - state: 'visible', - timeout: 60000, - }); - await expect(basicsPage.tubingPrimeSection.settingsOption.fillCannula).toBeVisible(); - await expect(basicsPage.tubingPrimeSection.settingsOption.fillTubing).toBeVisible(); - // Select Fill Cannula and verify highlighted days - await basicsPage.tubingPrimeSection.settingsOption.fillCannula.click(); - // // Verify duration indicator is visible - // await expect( - // basicsPage.tubingPrimeSection.durationIndicator - // ).toContainText("4 days"); - // Verify cannula icons are visible and tubing icons are not - await expect(basicsPage.tubingPrimeSection.cannulaIcons).toBeAttached(); - await expect(basicsPage.tubingPrimeSection.tubingIcons).not.toBeAttached(); - // Select a highlighted day - const highlightedDay = basicsPage.tubingPrimeSection.filledDay; - await highlightedDay.hover(); - selectedDateText = await basicsPage.tubingPrimeSection.calendarDayhover.text(); - await basicsPage.tubingPrimeSection.calendarDayhover.el.click(); - }); - await test.step('Then the daily chart shows correct cannula fill date', async () => { - const chartContainer = dailyPage.dailyChart.container; - await chartContainer.waitFor({ state: 'visible' }); - if (!selectedDateText) { - throw new Error('Selected date text is null'); - } - // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); - await expect(chartContainer).toHaveScreenshot('daily-chart-cannula.png'); - }); - // Return to basics page and test Fill Tubing Option - await test.step('When testing Fill Tubing functionality', async () => { - // Navigate back to basics - await test.step('When the navigation bar is visible', async () => { - await basicsPage.navigationBar.buttons.viewData.waitFor({ - state: 'visible', - }); - }); - // await basicsPage.navigationSubMenu.links.basics.click(); - await basicsPage.tubingPrimeSection.settings.waitFor({ - state: 'visible', - }); - // Click settings and select Fill Tubing - await basicsPage.tubingPrimeSection.settings.click(); - await basicsPage.tubingPrimeSection.settingsOption.fillTubing.click(); - // Verify filled tubing day is visible and cannula day is not - await expect(basicsPage.tubingPrimeSection.tubingIcons).toBeAttached(); - await expect(basicsPage.tubingPrimeSection.cannulaIcons).not.toBeAttached(); - // Click on the most recent day with tubing fill - const tubingDay = basicsPage.tubingPrimeSection.filledDay; - await tubingDay.hover(); - selectedDateText = await basicsPage.tubingPrimeSection.calendarDayhover.text(); - await basicsPage.tubingPrimeSection.calendarDayhover.el.click(); - }); - await test.step('Then the daily chart shows correct tubing fill date', async () => { - const chartContainer = dailyPage.dailyChart.container; - await chartContainer.waitFor({ state: 'visible' }); - if (!selectedDateText) { - throw new Error('Selected date text is null'); - } - // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); - await expect(chartContainer).toHaveScreenshot('daily-chart-tubing.png'); - }); - }); - // TODO: Previous test doesn't test values. Should we? :) - // Readings in range functionality - test('The hover over elements in sidebar shows correct values', async ({ page }) => { - // Stats for BGM - const expectedHeadersReadingInRange = [ - { header: 'Readings Below Range', value: 3 }, - { header: 'Readings Below Range', value: 0 }, - { header: 'Readings In Range', value: 71 }, - { header: 'Readings Above Range', value: 24 }, - { header: 'Readings Above Range', value: 2 }, - ]; - const basicsPage = new PatientDataBasicsPage(page); - await test.step('When the navigation bar is visible', async () => { - await basicsPage.navigationBar.buttons.viewData.waitFor({ - state: 'visible', - }); - }); - // Other BGM tooltip functionality - await basicsPage.statsSidebar.toggleTo('BGM'); - for (let i = 0; i < 5; i += 1) { - const bar = basicsPage.statsSidebar.readingsInRange.hoverBar.nth(i); - const barLabel = basicsPage.statsSidebar.readingsInRange.hoverBarLabel.nth(i); - await test.step('When the user hovers over the Avg. Daily Readings In Range chart', async () => { - await bar.hover(); - }); - await test.step('Then the correct header is visible', async () => { - await expect - .soft(basicsPage.statsSidebar.readingsInRange.header) - .toContainText(expectedHeadersReadingInRange[i].header); - }); - await test.step('Then the correct value is visible', async () => { - await expect - .soft(barLabel) - .toContainText(expectedHeadersReadingInRange[i].value.toString()); - }); - } - // Stats for CGM - // Time in range functionality - const expectedHeadersTimeInRange = [ - { header: 'Time Below Range', value: 0.1 }, - { header: 'Time Below Range', value: 1 }, - { header: 'Time In Range', value: 90 }, - { header: 'Time Above Range', value: 9 }, - { header: 'Time Above Range', value: 0.3 }, - ]; - await basicsPage.statsSidebar.toggleTo('CGM'); - for (let i = 0; i < expectedHeadersTimeInRange.length; i += 1) { - const bar = basicsPage.statsSidebar.timeInRange.hoverBar.nth(i); - const barLabel = basicsPage.statsSidebar.timeInRange.hoverBarLabel.nth(i); - await test.step('When the user hovers over the Avg. Daily Time In Range chart', async () => { - await bar.hover(); - }); - await test.step('Then the correct header is visible', async () => { - await expect - .soft(basicsPage.statsSidebar.timeInRange.header) - .toContainText(expectedHeadersTimeInRange[i].header); - }); - await test.step('Then the correct value is visible', async () => { - await expect.soft(barLabel).toContainText(expectedHeadersTimeInRange[i].value.toString()); - }); - } - }); - // Other CGM tooltip functionality - test('other CGM tooltip functionality', async ({ page }) => { - const basicsPage = new PatientDataBasicsPage(page); - await basicsPage.statsSidebar.toggleTo('CGM'); - const expectedHeadersTimeInRange = [ - { header: 'Basal Insulin', value: 14.7, percentage: 44 }, - { header: 'Bolus Insulin', value: 18.8, percentage: 56 }, - ]; - for (let i = 0; i < expectedHeadersTimeInRange.length; i += 1) { - const bar = basicsPage.statsSidebar.totalInsulin.hoverBar.nth(i); - const barLabel = basicsPage.statsSidebar.totalInsulin.hoverBarLabel.nth(i); - await test.step('When the user hovers over the Avg. Daily Total Insulin chart', async () => { - await bar.hover(); - }); - await test.step('Then the correct header is visible', async () => { - await expect - .soft(basicsPage.statsSidebar.timeInRange.header) - .toContainText(expectedHeadersTimeInRange[i].header); - }); - await test.step('Then the correct value is visible', async () => { - await expect.soft(barLabel).toContainText(expectedHeadersTimeInRange[i].value.toString()); - }); - } - }); -}); diff --git a/dist/tests/personal/login.spec.d.ts b/dist/tests/personal/login.spec.d.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/tests/personal/login.spec.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/tests/personal/login.spec.js b/dist/tests/personal/login.spec.js deleted file mode 100644 index c9ece3c..0000000 --- a/dist/tests/personal/login.spec.js +++ /dev/null @@ -1,61 +0,0 @@ -// @ts-check -import { expect, test } from '@fixtures/base'; -import LoginPage from 'page-objects/LoginPage'; -import WorkspacesPage from '@pom/clinician/WorkspacesPage'; -import env from '../../utilities/env'; -// make sure we don't have any cookies or origins -test.use({ storageState: { cookies: [], origins: [] } }); -// Possible testcases: https://tidepool.atlassian.net/jira/software/c/projects/WEB/issues/?jql=project%20%3D%20%22WEB%22%20AND%20type%20%3D%20Test%20AND%20textfields%20~%20%22login%22%20ORDER%20BY%20created%20DESC -test.describe('Login into application', () => { - test('should work with valid credentials for clinician with multiple clinics', async ({ page, }) => { - const loginPage = new LoginPage(page); - await test.step('When user is logged into application', async () => { - await loginPage.goto(); - await loginPage.login(env.CLINICIAN_USERNAME, env.CLINICIAN_PASSWORD); - }); - await test.step('Then the user is redirected to workspaces page', async () => { - const workspacesPage = new WorkspacesPage(page); - await page.waitForURL(workspacesPage.url); - await expect(workspacesPage.header).toBeVisible(); - }); - }); - test('should show error message with invalid credentials', async ({ page }) => { - const loginPage = new LoginPage(page); - await test.step('When user attempts to login with invalid credentials', async () => { - await loginPage.goto(); - // Enter email - await page.fill('#username', 'invalid@email.com'); - await page.click('#kc-login'); - }); - await test.step('Then error message should be displayed', async () => { - // Wait for the error message to appear - await expect(page.locator('#input-error-username')).toBeVisible(); - await expect(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); - }); - }); - test('should validate email format', async ({ page }) => { - const loginPage = new LoginPage(page); - await test.step('When user attempts to login with invalid email format', async () => { - await loginPage.goto(); - // Enter invalid email format - await page.fill('#username', 'invalidemail'); - await page.click('#kc-login'); - }); - await test.step('Then email validation error should be displayed', async () => { - // Check for email validation error message - await expect(page.locator('#input-error-username')).toBeVisible(); - await expect(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); - }); - }); - test('should show error message with invalid credentials 1', async ({ page }) => { - const loginPage = new LoginPage(page); - await test.step('When user is logged into application', async () => { - await loginPage.goto(); - await loginPage.login(env.CLINICIAN_USERNAME, `${env.CLINICIAN_PASSWORD}1`); - }); - await test.step('Then error message should be displayed', async () => { - await expect(page.locator('#input-error')).toBeVisible(); - await expect(page.locator('#input-error')).toContainText('Invalid password.'); - }); - }); -}); diff --git a/dist/utilities/annotations.d.ts b/dist/utilities/annotations.d.ts deleted file mode 100644 index 915938f..0000000 --- a/dist/utilities/annotations.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { TestInfo } from '@playwright/test'; -/** - * Interface for test annotations used in JIRA integration - */ -interface TestAnnotations { - testKey: string; - testSummary: string; - requirements: string; - testDescription: string; -} -/** - * Add test annotations to the test info for JIRA integration - */ -export default function addTestAnnotations(testInfo: TestInfo, annotations: TestAnnotations): void; -export {}; diff --git a/dist/utilities/annotations.js b/dist/utilities/annotations.js deleted file mode 100644 index faf1f84..0000000 --- a/dist/utilities/annotations.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Add test annotations to the test info for JIRA integration - */ -export default function addTestAnnotations(testInfo, annotations) { - testInfo.annotations.push({ - type: 'test_key', - description: annotations.testKey, - }); - testInfo.annotations.push({ - type: 'test_summary', - description: annotations.testSummary, - }); - testInfo.annotations.push({ - type: 'requirements', - description: annotations.requirements, - }); - testInfo.annotations.push({ - type: 'test_description', - description: annotations.testDescription, - }); -} diff --git a/dist/utilities/env.d.ts b/dist/utilities/env.d.ts deleted file mode 100644 index 637f194..0000000 --- a/dist/utilities/env.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -declare const _default: { - BASE_URL: string; - PERSONAL_USERNAME: string; - PERSONAL_PASSWORD: string; - CLAIMED_USERNAME: string; - CLAIMED_PASSWORD: string; - SHARED_USERNAME: string; - SHARED_PASSWORD: string; - CLINICIAN_USERNAME: string; - CLINICIAN_PASSWORD: string; - TARGET_ENV: "qa1" | "qa2" | "qa3" | "qa4" | "qa5" | "production" | "prd" | "int"; - BROWSERSTACK_USERNAME?: string | undefined; - BROWSERSTACK_ACCESS_KEY?: string | undefined; - XRAY_CLIENT_ID?: string | undefined; - XRAY_CLIENT_SECRET?: string | undefined; -}; -export default _default; diff --git a/dist/utilities/env.js b/dist/utilities/env.js deleted file mode 100644 index 5c69186..0000000 --- a/dist/utilities/env.js +++ /dev/null @@ -1,37 +0,0 @@ -import dotenv from 'dotenv'; -import z from 'zod'; -dotenv.config(); -const envSchema = z.object({ - BROWSERSTACK_USERNAME: z.string().optional(), - BROWSERSTACK_ACCESS_KEY: z.string().optional(), - PERSONAL_USERNAME: z.string(), - PERSONAL_PASSWORD: z.string(), - CLAIMED_USERNAME: z.string(), - CLAIMED_PASSWORD: z.string(), - SHARED_USERNAME: z.string(), - SHARED_PASSWORD: z.string(), - CLINICIAN_USERNAME: z.string(), - CLINICIAN_PASSWORD: z.string(), - TARGET_ENV: z.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production', 'prd', 'int']), - XRAY_CLIENT_ID: z.string().optional(), - XRAY_CLIENT_SECRET: z.string().optional(), -}); -const env = envSchema.safeParse(process.env); -if (!env.success) { - console.error('āŒ Invalid environment variables:\n', env.error.format()); - throw new Error('Invalid environment variables. Check your .env file.'); -} -const URL_MAP = { - qa1: 'https://qa1.development.tidepool.org', - qa2: 'https://qa2.development.tidepool.org', - qa3: 'https://qa3.development.tidepool.org', - qa4: 'https://qa4.development.tidepool.org', - qa5: 'https://qa5.development.tidepool.org', - production: 'https://app.tidepool.org', - prd: 'https://app.tidepool.org', // Alias for production - int: 'https://int.development.tidepool.org', // Integration environment -}; -export default { - ...env.data, - BASE_URL: URL_MAP[env.data.TARGET_ENV], -}; diff --git a/dist/utilities/xray-json-reporter.d.ts b/dist/utilities/xray-json-reporter.d.ts deleted file mode 100644 index 2846c31..0000000 --- a/dist/utilities/xray-json-reporter.d.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { FullConfig, FullResult, Suite, TestCase, TestResult } from '@playwright/test/reporter'; -interface XrayTestStep { - action: string; - data?: string; - result?: string; - status: 'PASS' | 'FAIL' | 'PENDING'; - actualResult?: string; - evidences?: Array<{ - data: string; - filename: string; - contentType: string; - }>; -} -interface XrayTest { - testKey?: string; - testInfo: { - summary: string; - type: 'Manual' | 'Cucumber' | 'Generic'; - projectKey: string; - labels?: string[]; - }; - status: 'PASS' | 'FAIL' | 'PENDING' | 'EXECUTING'; - comment?: string; - evidences?: Array<{ - data: string; - filename: string; - contentType: string; - }>; - steps?: XrayTestStep[]; - examples?: string[]; -} -interface XrayExecutionResult { - info: { - summary: string; - description: string; - version?: string; - testPlanKey?: string; - testExecutionKey?: string; - startDate: string; - finishDate: string; - testEnvironments?: string[]; - }; - tests: XrayTest[]; -} -/** - * Unified Xray JSON Reporter for Playwright - * Maps rich Playwright test data to Xray's JSON format with step-by-step evidence - */ -declare class XrayJsonReporter { - private styles; - private startTime; - private endTime; - /** - * Authenticates with Xray API using client credentials - */ - authenticateWithXray(): Promise; - /** - * Converts file to base64 string for Xray evidence - */ - private fileToBase64; - /** - * Extracts step information from test annotations - */ - private extractSteps; - /** - * Maps Playwright test result to Xray test format - */ - private mapPlaywrightTestToXray; - /** - * Converts Playwright JSON results to Xray format - */ - convertPlaywrightJsonToXray(playwrightJsonPath: string): Promise; - /** - * Recursively processes test suites - */ - private processSuite; - /** - * Uploads Xray execution result to Xray - */ - uploadToXray(xrayResult: XrayExecutionResult): Promise; - /** - * Main method to process and upload results - */ - processAndUpload(playwrightJsonPath: string): Promise; - /** - * Reporter lifecycle methods for direct Playwright integration - */ - onBegin(_config: FullConfig, suite: Suite): void; - onTestBegin(test: TestCase, _result: TestResult): void; - onTestEnd(test: TestCase, result: TestResult): void; - onEnd(result: FullResult): Promise; -} -export default XrayJsonReporter; diff --git a/dist/utilities/xray-json-reporter.js b/dist/utilities/xray-json-reporter.js deleted file mode 100644 index a6094f1..0000000 --- a/dist/utilities/xray-json-reporter.js +++ /dev/null @@ -1,263 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import env from './env'; -/** - * Unified Xray JSON Reporter for Playwright - * Maps rich Playwright test data to Xray's JSON format with step-by-step evidence - */ -class XrayJsonReporter { - constructor() { - this.styles = { - success: 'āœ…', - error: 'āŒ', - info: 'ā„¹ļø', - warning: 'ā›”ļø', - upload: 'šŸš€', - test: '🧪', - separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - }; - this.startTime = ''; - this.endTime = ''; - } - /** - * Authenticates with Xray API using client credentials - */ - async authenticateWithXray() { - try { - console.log(`${this.styles.info} Authenticating with Xray...`); - const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - client_id: env.XRAY_CLIENT_ID, - client_secret: env.XRAY_CLIENT_SECRET, - }), - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); - } - const token = await response.text(); - console.log(`${this.styles.success} Successfully authenticated with Xray`); - return token.replace(/"/g, ''); // Remove quotes from token - } - catch (error) { - console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); - throw error; - } - } - /** - * Converts file to base64 string for Xray evidence - */ - async fileToBase64(filePath) { - try { - const fileBuffer = fs.readFileSync(filePath); - return fileBuffer.toString('base64'); - } - catch (error) { - console.warn(`${this.styles.warning} Could not read file ${filePath}:`, error); - return ''; - } - } - /** - * Extracts step information from test annotations - */ - extractSteps(annotations, attachments) { - const steps = []; - const stepAnnotations = annotations.filter(ann => ann.type.startsWith('Step Duration:')); - for (const stepAnn of stepAnnotations) { - const stepName = stepAnn.type.replace('Step Duration: ', ''); - const duration = stepAnn.description; - // Find associated step attachments - const stepAttachments = attachments.filter(att => att.name.toLowerCase().includes(stepName.toLowerCase().substring(0, 20))); - const step = { - action: stepName, - data: `Duration: ${duration}`, - result: stepName.includes('Then') ? stepName : undefined, - status: 'PASS', // Will be updated based on test result - evidences: [] - }; - // Add evidence for this step - for (const attachment of stepAttachments) { - if (attachment.path && fs.existsSync(attachment.path)) { - step.evidences?.push({ - data: await this.fileToBase64(attachment.path), - filename: path.basename(attachment.path), - contentType: attachment.contentType || 'application/octet-stream' - }); - } - } - steps.push(step); - } - return steps; - } - /** - * Maps Playwright test result to Xray test format - */ - async mapPlaywrightTestToXray(testCase, testResult) { - const tags = testCase.tags || []; - const annotations = testResult.annotations || []; - const attachments = testResult.attachments || []; - // Extract steps from annotations - const steps = await this.extractSteps(annotations, attachments); - // Mark failed steps if test failed - if (testResult.status !== 'passed' && steps.length > 0) { - steps[steps.length - 1].status = 'FAIL'; - steps[steps.length - 1].actualResult = testResult.error?.message || 'Test failed'; - } - // Collect test-level evidence (screenshots, videos) - const testEvidences = []; - for (const attachment of attachments) { - if (attachment.path && fs.existsSync(attachment.path)) { - // Add main test evidence (final screenshots, videos, etc.) - if (attachment.name.includes('screenshot') || attachment.name.includes('video')) { - testEvidences.push({ - data: await this.fileToBase64(attachment.path), - filename: attachment.name, - contentType: attachment.contentType || 'application/octet-stream' - }); - } - } - } - const xrayTest = { - testInfo: { - summary: testCase.title, - type: 'Generic', - projectKey: 'XT', // Could be made configurable - labels: tags - }, - status: testResult.status === 'passed' ? 'PASS' : - testResult.status === 'skipped' ? 'PENDING' : 'FAIL', - comment: testResult.error?.message, - evidences: testEvidences, - steps: steps.length > 0 ? steps : undefined - }; - return xrayTest; - } - /** - * Converts Playwright JSON results to Xray format - */ - async convertPlaywrightJsonToXray(playwrightJsonPath) { - const jsonContent = fs.readFileSync(playwrightJsonPath, 'utf8'); - const playwrightResult = JSON.parse(jsonContent); - const tests = []; - // Process all test suites - for (const suite of playwrightResult.suites || []) { - await this.processSuite(suite, tests); - } - const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; - const targetEnv = process.env.TARGET_ENV || 'qa1'; - const xrayResult = { - info: { - summary: `Playwright Test Execution - ${new Date().toISOString()}`, - description: `Automated test execution for ${targetEnv} environment`, - version: '1.0', - testExecutionKey: testExecKey !== 'none' ? testExecKey : undefined, - startDate: playwrightResult.stats?.startTime || new Date().toISOString(), - finishDate: new Date(new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + - (playwrightResult.stats?.duration || 0)).toISOString(), - testEnvironments: [targetEnv] - }, - tests - }; - return xrayResult; - } - /** - * Recursively processes test suites - */ - async processSuite(suite, tests) { - // Process specs in this suite - for (const spec of suite.specs || []) { - for (const test of spec.tests || []) { - for (const result of test.results || []) { - const xrayTest = await this.mapPlaywrightTestToXray(spec, result); - tests.push(xrayTest); - } - } - } - // Process nested suites - for (const nestedSuite of suite.suites || []) { - await this.processSuite(nestedSuite, tests); - } - } - /** - * Uploads Xray execution result to Xray - */ - async uploadToXray(xrayResult) { - try { - console.log(`${this.styles.info} Uploading test execution to Xray...`); - const token = await this.authenticateWithXray(); - const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(xrayResult), - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); - } - const result = await response.json(); - console.log(`${this.styles.success} Successfully uploaded to Xray. Execution Key: ${result.key}`); - } - catch (error) { - console.error(`${this.styles.error} Failed to upload to Xray:`, error); - throw error; - } - } - /** - * Main method to process and upload results - */ - async processAndUpload(playwrightJsonPath) { - if (!(env.XRAY_CLIENT_ID && env.XRAY_CLIENT_SECRET)) { - console.log(`${this.styles.warning} No Xray credentials found, skipping upload to JIRA Xray`); - return; - } - try { - console.log(`${this.styles.info} Processing Playwright results...`); - const xrayResult = await this.convertPlaywrightJsonToXray(playwrightJsonPath); - // Save converted result for debugging - fs.writeFileSync('test-results/xray-execution.json', JSON.stringify(xrayResult, null, 2)); - await this.uploadToXray(xrayResult); - console.log(`${this.styles.upload} Xray upload completed successfully`); - } - catch (error) { - console.error(`${this.styles.error} Failed to process and upload:`, error); - throw error; - } - } - /** - * Reporter lifecycle methods for direct Playwright integration - */ - onBegin(_config, suite) { - this.startTime = new Date().toISOString(); - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); - console.log(`${this.styles.separator}\n`); - } - onTestBegin(test, _result) { - console.log(`${this.styles.test} Starting: ${test.title}`); - } - onTestEnd(test, result) { - const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; - console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); - } - async onEnd(result) { - this.endTime = new Date().toISOString(); - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.info} Test Run Summary:`); - console.log(`Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`); - console.log(`Duration: ${result.duration}ms`); - console.log(`${this.styles.separator}\n`); - // Auto-upload if JSON results are available - const jsonPath = 'test-results/last-run.json'; - if (fs.existsSync(jsonPath)) { - await this.processAndUpload(jsonPath); - } - } -} -export default XrayJsonReporter; diff --git a/dist/utilities/xray-reporter.d.ts b/dist/utilities/xray-reporter.d.ts deleted file mode 100644 index a81cd71..0000000 --- a/dist/utilities/xray-reporter.d.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { FullConfig, FullResult, Suite, TestCase, TestResult } from '@playwright/test/reporter'; -/** - * Reporter class for uploading test results to Xray - */ -declare class XRayReporter { - private styles; - constructor(); - /** - * Authenticates with Xray API using client credentials - * @returns {Promise} The authentication token - * @throws {Error} If authentication fails - */ - authenticateWithXray(): Promise; - /** - * Uploads test results to Xray - * @param {string} token - The authentication token - * @param {string} xmlContent - The JUnit XML content to upload - * @returns {Promise} - * @throws {Error} If upload fails - */ - uploadTestResults(token: string, xmlContent: string): Promise; - /** - * Called when test run begins - * @param suite - Test suite object containing all tests - */ - onBegin(_config: FullConfig, suite: Suite): void; - /** - * Called when a test begins - * @param test - Test case object - */ - onTestBegin(test: TestCase, _result: TestResult): void; - /** - * Called when a test ends - * @param {Object} test - Test case object - * @param {Object} result - Test result object containing status and other details - */ - onTestEnd(test: TestCase, result: TestResult): void; - /** - * Called when all tests have finished - * @param result - Full test run result object containing status and duration - */ - onEnd(result: FullResult): Promise; -} -export default XRayReporter; diff --git a/dist/utilities/xray-reporter.js b/dist/utilities/xray-reporter.js deleted file mode 100644 index 523584c..0000000 --- a/dist/utilities/xray-reporter.js +++ /dev/null @@ -1,129 +0,0 @@ -import fs from 'node:fs'; -import env from './env'; -/** - * Reporter class for uploading test results to Xray - */ -class XRayReporter { - constructor() { - this.styles = { - success: 'āœ…', - error: 'āŒ', - info: 'ā„¹ļø', - warning: 'ā›”ļø', - upload: 'šŸš€', - test: '🧪', - separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - }; - } - /** - * Authenticates with Xray API using client credentials - * @returns {Promise} The authentication token - * @throws {Error} If authentication fails - */ - async authenticateWithXray() { - try { - console.log(`${this.styles.info} Authenticating with Xray...`); - const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - client_id: env.XRAY_CLIENT_ID, - client_secret: env.XRAY_CLIENT_SECRET, - }), - }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}, ${response.body}`); - } - const data = await response.json(); - console.log(`${this.styles.success} Successfully authenticated with Xray`); - return data.token; - } - catch (error) { - console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); - throw error; - } - } - /** - * Uploads test results to Xray - * @param {string} token - The authentication token - * @param {string} xmlContent - The JUnit XML content to upload - * @returns {Promise} - * @throws {Error} If upload fails - */ - async uploadTestResults(token, xmlContent) { - try { - console.log(`${this.styles.info} Uploading test results to Xray...`); - const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution/junit?projectKey=XT&testPlanKey=XT-380', { - method: 'POST', - headers: { - 'Content-Type': 'text/xml', - Authorization: `Bearer ${token}`, - }, - body: xmlContent, - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); - } - console.log(`${this.styles.success} Successfully uploaded test results to Xray`); - } - catch (error) { - console.error(`${this.styles.error} Failed to upload test results to Xray:`, error); - throw error; - } - } - /** - * Called when test run begins - * @param suite - Test suite object containing all tests - */ - onBegin(_config, suite) { - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); - console.log(`${this.styles.separator}\n`); - } - /** - * Called when a test begins - * @param test - Test case object - */ - onTestBegin(test, _result) { - console.log(`${this.styles.test} Starting: ${test.title}`); - } - /** - * Called when a test ends - * @param {Object} test - Test case object - * @param {Object} result - Test result object containing status and other details - */ - onTestEnd(test, result) { - const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; - console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); - } - /** - * Called when all tests have finished - * @param result - Full test run result object containing status and duration - */ - async onEnd(result) { - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.info} Test Run Summary:`); - console.log(`Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`); - console.log(`Duration: ${result.duration}ms`); - console.log(`${this.styles.separator}\n`); - if (!(env.XRAY_CLIENT_ID || env.XRAY_CLIENT_SECRET)) { - console.log(`${this.styles.warning} No Xray client ID or secret found, skipping upload to JIRA Xray`); - return; - } - try { - console.log(`${this.styles.info} Reading test results file...`); - const testResults = fs.readFileSync('./test-results/test-results.xml', 'utf8'); - const token = await this.authenticateWithXray(); - await this.uploadTestResults(token, testResults); - console.log(`${this.styles.upload} Successfully uploaded test results to Xray`); - } - catch (error) { - console.error(`${this.styles.error} Failed to process test results:`, error); - } - console.log(`${this.styles.separator}\n`); - } -} -export default XRayReporter; From cd6c0f828621d5e9bcc4f952648734a013d8569f Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Thu, 12 Feb 2026 16:11:12 -0500 Subject: [PATCH 25/60] Update CircleCI orb versions Bump CircleCI orbs to newer releases: node -> 6.3.0, browser-tools -> 1.5.3, slack -> 4.15.0. Keeps CI configuration up-to-date with upstream orb updates. --- .circleci/config.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 97101e1..6c7d26f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,8 +1,8 @@ version: 2.1 orbs: - node: circleci/node@6.1.0 - browser-tools: circleci/browser-tools@1.5.1 - slack: circleci/slack@4.1 + node: circleci/node@6.3.0 + browser-tools: circleci/browser-tools@1.5.3 + slack: circleci/slack@4.15.0 parameters: testEnvironment: type: string From 9325f668c40f02a991e23f08c42c1c664b90b3d3 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Thu, 12 Feb 2026 16:11:35 -0500 Subject: [PATCH 26/60] Add TEST_TAGS parsing to Playwright grep Add buildGrepFromTags() to convert a TEST_TAGS env var into a Playwright grep RegExp and wire it into the config. Supports single tags, OR (comma-separated), AND (space-separated), optional @ prefix, and is case-insensitive. Returns undefined when no tags provided so default behavior is preserved. --- playwright.config.ts | 34 ++++++++++++++++++++++++++++++++++ utilities/test-runner.js | 4 ++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 6bc3197..deb41b4 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -7,6 +7,39 @@ const isBrowserStack = Boolean( process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESS_KEY, ); +/** + * Convert TEST_TAGS env var to a Playwright grep RegExp. + * + * - Single tag: TEST_TAGS="smoke" → /@smoke/i + * - AND (spaces): TEST_TAGS="smoke ui" → /(?=.*@smoke)(?=.*@ui)/i + * - OR (commas): TEST_TAGS="smoke,api" → /@smoke|@api/i + * - Case-insensitive so Jira uppercase input matches lowercase tags. + * - Works with or without @ prefix. + */ +function buildGrepFromTags(): RegExp | undefined { + const testTags = process.env.TEST_TAGS?.trim(); + if (!testTags) return undefined; + + const hasCommas = testTags.includes(','); + const tagList = testTags + .split(hasCommas ? ',' : /\s+/) + .map(t => t.trim().toLowerCase()) + .filter(t => t.length > 0) + .map(t => (t.startsWith('@') ? t : `@${t}`)); + + if (tagList.length === 0) return undefined; + + if (tagList.length === 1) { + return new RegExp(tagList[0], 'i'); + } + + if (hasCommas) { + return new RegExp(tagList.join('|'), 'i'); + } + + return new RegExp(tagList.map(tag => `(?=.*${tag})`).join(''), 'i'); +} + function buildBrowserStackEndpoint(testName: string) { const caps = { browser: 'chrome', @@ -27,6 +60,7 @@ export default defineConfig({ globalSetup: require.resolve(path.join(__dirname, 'tests/global-setup')), fullyParallel: true, forbidOnly: !!process.env.CI, + grep: buildGrepFromTags(), retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, timeout: 60_000, diff --git a/utilities/test-runner.js b/utilities/test-runner.js index 9b8673e..3aeacf8 100644 --- a/utilities/test-runner.js +++ b/utilities/test-runner.js @@ -48,7 +48,7 @@ function buildGrepArgs(tags) { .split(/[\s,]+/) .map(tag => tag.trim()) .filter(tag => tag.length > 0) - .map(tag => (tag.startsWith('@') ? tag.slice(1) : tag)); + .map(tag => (tag.startsWith('@') ? tag.slice(1) : tag).toLowerCase()); if (tagList.length === 0) { return []; @@ -82,7 +82,7 @@ function buildPlaywrightCommand() { // Add sharding for CircleCI if available if (circleCINodeIndex !== undefined && circleCINodeTotal !== undefined) { - baseArgs.push(`--shard=${circleCINodeIndex}/${circleCINodeTotal}`); + baseArgs.push(`--shard=${Number(circleCINodeIndex) + 1}/${circleCINodeTotal}`); } // Add grep arguments for tags From 6bcdd4c5f4976109addb59f354eac296df0552b2 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Thu, 12 Feb 2026 16:11:58 -0500 Subject: [PATCH 27/60] Cleanup tests: remove duplicate steps & imports Removed redundant blank lines and adjusted indentation in claimed-profile test. In login.spec.ts removed a duplicated TEST_TAGS import and consolidated/removed duplicate test.step wrappers and repeated assertions to streamline the login tests. Changes are purely cleanup/refactor to improve readability and reduce redundant test steps; no intended functional changes. --- .../claimed-profile-edit-fullname.spec.ts | 70 +++++++++---------- tests/personal/login.spec.ts | 38 ---------- 2 files changed, 34 insertions(+), 74 deletions(-) diff --git a/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts b/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts index 679d208..51ed1b6 100644 --- a/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts +++ b/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts @@ -13,7 +13,6 @@ const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; test.describe('Claimed Account Settings edit (Full Name only) updates Profile endpoint and visually updates for user, clinic, and shared member', () => { test.setTimeout(120000); // 2 minute timeout for multi-phase test - let api: ReturnType; let putCapture: any; let newName: string; // Declare at test level scope @@ -61,7 +60,6 @@ test.describe('Claimed Account Settings edit (Full Name only) updates Profile en // Create new acccount settings page for the following test const accountSettingsPage = new AccountSettingsPage(page); - // Step 4: Change the Full Name field to a new value await test.step('When user updates the Full Name field', async () => { newName = `Claimed User Updated ${Math.floor(Math.random() * 10000)}`; // Remove let declaration @@ -146,40 +144,40 @@ test.describe('Claimed Account Settings edit (Full Name only) updates Profile en !getCapture.responseBody || getCapture.responseBody.fullName !== putCapture.requestBody.fullName ) { - await (test as any).stepNoScreenshot( - 'Then GET request matches the saved PUT request', - async () => { - await api.validateEndpointResponse('profile-metadata-get'); - - // Get all captures and find the LATEST GET request (after the PUT) - const allCaptures = api.getCaptures(); - const putIndex = allCaptures.findIndex(req => req === putCapture); - - // Find GET requests that occurred AFTER the PUT request - const laterGetCaptures = allCaptures - .slice(putIndex + 1) - .filter((req: any) => req.method === 'GET' && req.url.includes('/profile')); - - if (laterGetCaptures.length === 0) { - throw new Error('No GET /profile request captured after the PUT request'); - } - - // Use the most recent GET request - const getCapture = laterGetCaptures[laterGetCaptures.length - 1]; - - if ( - !getCapture.responseBody || - getCapture.responseBody.fullName !== putCapture.requestBody.fullName - ) { - console.log('GET response fullName:', getCapture.responseBody.fullName); - console.log('PUT request fullName:', putCapture.requestBody.fullName); - console.log('Total captures:', allCaptures.length); - console.log('PUT index:', putIndex); - console.log('Later GET captures found:', laterGetCaptures.length); - throw new Error('GET response fullName does not match PUT request fullName'); - } - }, - ); + await (test as any).stepNoScreenshot( + 'Then GET request matches the saved PUT request', + async () => { + await api.validateEndpointResponse('profile-metadata-get'); + + // Get all captures and find the LATEST GET request (after the PUT) + const allCaptures = api.getCaptures(); + const putIndex = allCaptures.findIndex(req => req === putCapture); + + // Find GET requests that occurred AFTER the PUT request + const laterGetCaptures = allCaptures + .slice(putIndex + 1) + .filter((req: any) => req.method === 'GET' && req.url.includes('/profile')); + + if (laterGetCaptures.length === 0) { + throw new Error('No GET /profile request captured after the PUT request'); + } + + // Use the most recent GET request + const getCapture = laterGetCaptures[laterGetCaptures.length - 1]; + + if ( + !getCapture.responseBody || + getCapture.responseBody.fullName !== putCapture.requestBody.fullName + ) { + console.log('GET response fullName:', getCapture.responseBody.fullName); + console.log('PUT request fullName:', putCapture.requestBody.fullName); + console.log('Total captures:', allCaptures.length); + console.log('PUT index:', putIndex); + console.log('Later GET captures found:', laterGetCaptures.length); + throw new Error('GET response fullName does not match PUT request fullName'); + } + }, + ); throw new Error('GET response fullName does not match PUT request fullName'); } }, diff --git a/tests/personal/login.spec.ts b/tests/personal/login.spec.ts index 81589b0..54a22c7 100644 --- a/tests/personal/login.spec.ts +++ b/tests/personal/login.spec.ts @@ -4,7 +4,6 @@ import LoginPage from 'page-objects/LoginPage'; import WorkspacesPage from '@pom/clinician/WorkspacesPage'; import { TEST_TAGS, createValidatedTags } from '@fixtures/test-tags'; import env from '../../utilities/env'; -import { TEST_TAGS, createValidatedTags } from '../fixtures/test-tags'; // make sure we don't have any cookies or origins test.use({ storageState: { cookies: [], origins: [] } }); @@ -18,19 +17,10 @@ test.describe('Login into application', () => { }, async ({ page }) => { const loginPage = new LoginPage(page); - - await test.step('When user is logged into application', async () => { - await loginPage.goto(); - await loginPage.login(env.CLINICIAN_USERNAME, env.CLINICIAN_PASSWORD); - }); await test.step('When user is logged into application', async () => { await loginPage.goto(); await loginPage.login(env.CLINICIAN_USERNAME, env.CLINICIAN_PASSWORD); }); - - await test.step('Then the user is redirected to workspaces page', async () => { - const workspacesPage = new WorkspacesPage(page); - await page.waitForURL(workspacesPage.url); await test.step('Then the user is redirected to workspaces page', async () => { const workspacesPage = new WorkspacesPage(page); await page.waitForURL(workspacesPage.url); @@ -39,10 +29,6 @@ test.describe('Login into application', () => { }); }, ); - await expect(workspacesPage.header).toBeVisible(); - }); - }, - ); test( 'should show error message with invalid credentials', @@ -59,11 +45,6 @@ test.describe('Login into application', () => { await page.fill('#username', 'invalid@email.com'); await page.click('#kc-login'); }); - // Enter email - await page.fill('#username', 'invalid@email.com'); - await page.click('#kc-login'); - }); - await test.step('Then error message should be displayed', async () => { // Wait for the error message to appear await expect(page.locator('#input-error-username')).toBeVisible(); @@ -99,15 +80,6 @@ test.describe('Login into application', () => { }); }, ); - await test.step('Then email validation error should be displayed', async () => { - // Check for email validation error message - await expect(page.locator('#input-error-username')).toBeVisible(); - await expect(page.locator('#input-error-username')).toContainText( - "This email doesn't belong to an account yet.", - ); - }); - }, - ); test( 'should show error message with invalid password', @@ -117,10 +89,6 @@ test.describe('Login into application', () => { async ({ page }) => { const loginPage = new LoginPage(page); - await test.step('When user is logged into application', async () => { - await loginPage.goto(); - await loginPage.login(env.CLINICIAN_USERNAME, `${env.CLINICIAN_PASSWORD}1`); - }); await test.step('When user is logged into application', async () => { await loginPage.goto(); await loginPage.login(env.CLINICIAN_USERNAME, `${env.CLINICIAN_PASSWORD}1`); @@ -132,10 +100,4 @@ test.describe('Login into application', () => { }); }, ); - await test.step('Then error message should be displayed', async () => { - await expect(page.locator('#input-error')).toBeVisible(); - await expect(page.locator('#input-error')).toContainText('Invalid password.'); - }); - }, - ); }); From 3b7076bdd5934e19ecfb929ea6e792862713751c Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Thu, 12 Feb 2026 16:52:32 -0500 Subject: [PATCH 28/60] debugging CI test tags need to debug test tags in circle ci and see why they aren't being passed --- playwright.config.ts | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index deb41b4..9a2150c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,7 +18,12 @@ const isBrowserStack = Boolean( */ function buildGrepFromTags(): RegExp | undefined { const testTags = process.env.TEST_TAGS?.trim(); - if (!testTags) return undefined; + console.log(`[DEBUG] TEST_TAGS env var: '${process.env.TEST_TAGS}' (trimmed: '${testTags}')`); + + if (!testTags) { + console.log('[DEBUG] No TEST_TAGS set, grep = undefined (running all tests)'); + return undefined; + } const hasCommas = testTags.includes(','); const tagList = testTags @@ -27,17 +32,22 @@ function buildGrepFromTags(): RegExp | undefined { .filter(t => t.length > 0) .map(t => (t.startsWith('@') ? t : `@${t}`)); - if (tagList.length === 0) return undefined; - - if (tagList.length === 1) { - return new RegExp(tagList[0], 'i'); + if (tagList.length === 0) { + console.log('[DEBUG] Tag list empty after parsing, grep = undefined'); + return undefined; } - if (hasCommas) { - return new RegExp(tagList.join('|'), 'i'); + let result: RegExp; + if (tagList.length === 1) { + result = new RegExp(tagList[0], 'i'); + } else if (hasCommas) { + result = new RegExp(tagList.join('|'), 'i'); + } else { + result = new RegExp(tagList.map(tag => `(?=.*${tag})`).join(''), 'i'); } - return new RegExp(tagList.map(tag => `(?=.*${tag})`).join(''), 'i'); + console.log(`[DEBUG] grep regex: ${result}`); + return result; } function buildBrowserStackEndpoint(testName: string) { From a6c4887251a830ac0d655ad7d2d6d8704e6f2803 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Thu, 12 Feb 2026 17:03:04 -0500 Subject: [PATCH 29/60] more debugging for CI test tags Still trying to figure out why playwright isn't using the test tags passed to the environment --- .circleci/config.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 6c7d26f..7e4711e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -68,6 +68,16 @@ jobs: name: Install Playwright Dependencies command: npx playwright install --with-deps + # Debug: list tests to verify grep matching + - run: + name: Debug - List tests with grep + command: | + echo "TEST_TAGS=$TEST_TAGS" + echo "--- All tests (first 20) ---" + npx playwright test --list 2>&1 | head -20 + echo "--- Tests matching --grep @patient (CLI) ---" + npx playwright test --list --grep @patient 2>&1 | head -20 + # Run tests with parallel execution - run: name: Run Playwright Tests From 8faf31cadf36c781a7c0c3d60a9d2115dc4d6d9f Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Thu, 12 Feb 2026 17:12:34 -0500 Subject: [PATCH 30/60] More debugging More ci debugging --- .circleci/config.yml | 10 +++++++--- playwright.config.ts | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7e4711e..cb7b0e8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -73,10 +73,14 @@ jobs: name: Debug - List tests with grep command: | echo "TEST_TAGS=$TEST_TAGS" - echo "--- All tests (first 20) ---" - npx playwright test --list 2>&1 | head -20 + echo "--- All tests (first 10) ---" + npx playwright test --list 2>&1 | head -10 + echo "" echo "--- Tests matching --grep @patient (CLI) ---" - npx playwright test --list --grep @patient 2>&1 | head -20 + npx playwright test --list --grep @patient 2>&1 | head -10 + echo "" + echo "--- Tests matching --grep patient (no @, CLI) ---" + npx playwright test --list --grep patient 2>&1 | head -10 # Run tests with parallel execution - run: diff --git a/playwright.config.ts b/playwright.config.ts index 9a2150c..c669e66 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -70,7 +70,7 @@ export default defineConfig({ globalSetup: require.resolve(path.join(__dirname, 'tests/global-setup')), fullyParallel: true, forbidOnly: !!process.env.CI, - grep: buildGrepFromTags(), + // grep: buildGrepFromTags(), // temporarily disabled for debugging retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, timeout: 60_000, From bb6318be332169f5dc1252cdf74c25739e4e01a7 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Thu, 12 Feb 2026 17:19:38 -0500 Subject: [PATCH 31/60] Where are the tests hiding... more CI debugging --- .circleci/config.yml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index cb7b0e8..381e4ee 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -68,19 +68,21 @@ jobs: name: Install Playwright Dependencies command: npx playwright install --with-deps - # Debug: list tests to verify grep matching + # Debug: investigate why 0 tests are discovered - run: - name: Debug - List tests with grep + name: Debug - Test discovery command: | - echo "TEST_TAGS=$TEST_TAGS" - echo "--- All tests (first 10) ---" - npx playwright test --list 2>&1 | head -10 + echo "=== Working directory ===" + pwd echo "" - echo "--- Tests matching --grep @patient (CLI) ---" - npx playwright test --list --grep @patient 2>&1 | head -10 + echo "=== Test files on disk ===" + find tests -name "*.spec.ts" | head -20 echo "" - echo "--- Tests matching --grep patient (no @, CLI) ---" - npx playwright test --list --grep patient 2>&1 | head -10 + echo "=== Playwright test --list (full output) ===" + npx playwright test --list 2>&1 || true + echo "" + echo "=== Try listing a specific file ===" + npx playwright test tests/personal/basic-functionality.spec.ts --list 2>&1 || true # Run tests with parallel execution - run: From 5d3559da32f533f7b3fe1aafdec7fc509ed17289 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Fri, 13 Feb 2026 11:58:24 -0500 Subject: [PATCH 32/60] Clean up Playwright config and CI debug steps Remove CircleCI debug run used for test discovery. Refactor buildGrepFromTags in playwright.config.ts by removing debug logging, simplifying control flow, and returning RegExp values directly. Re-enable grep via buildGrepFromTags(). Normalize project testMatch patterns (remove leading '**/' prefixes) and fix bs-chrome-personal testMatch from 'patient' to 'personal'. --- .circleci/config.yml | 16 ---------------- playwright.config.ts | 40 +++++++++++++++------------------------- 2 files changed, 15 insertions(+), 41 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 381e4ee..6c7d26f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -68,22 +68,6 @@ jobs: name: Install Playwright Dependencies command: npx playwright install --with-deps - # Debug: investigate why 0 tests are discovered - - run: - name: Debug - Test discovery - command: | - echo "=== Working directory ===" - pwd - echo "" - echo "=== Test files on disk ===" - find tests -name "*.spec.ts" | head -20 - echo "" - echo "=== Playwright test --list (full output) ===" - npx playwright test --list 2>&1 || true - echo "" - echo "=== Try listing a specific file ===" - npx playwright test tests/personal/basic-functionality.spec.ts --list 2>&1 || true - # Run tests with parallel execution - run: name: Run Playwright Tests diff --git a/playwright.config.ts b/playwright.config.ts index c669e66..19dbf87 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,12 +18,7 @@ const isBrowserStack = Boolean( */ function buildGrepFromTags(): RegExp | undefined { const testTags = process.env.TEST_TAGS?.trim(); - console.log(`[DEBUG] TEST_TAGS env var: '${process.env.TEST_TAGS}' (trimmed: '${testTags}')`); - - if (!testTags) { - console.log('[DEBUG] No TEST_TAGS set, grep = undefined (running all tests)'); - return undefined; - } + if (!testTags) return undefined; const hasCommas = testTags.includes(','); const tagList = testTags @@ -32,22 +27,17 @@ function buildGrepFromTags(): RegExp | undefined { .filter(t => t.length > 0) .map(t => (t.startsWith('@') ? t : `@${t}`)); - if (tagList.length === 0) { - console.log('[DEBUG] Tag list empty after parsing, grep = undefined'); - return undefined; - } + if (tagList.length === 0) return undefined; - let result: RegExp; if (tagList.length === 1) { - result = new RegExp(tagList[0], 'i'); - } else if (hasCommas) { - result = new RegExp(tagList.join('|'), 'i'); - } else { - result = new RegExp(tagList.map(tag => `(?=.*${tag})`).join(''), 'i'); + return new RegExp(tagList[0], 'i'); + } + + if (hasCommas) { + return new RegExp(tagList.join('|'), 'i'); } - console.log(`[DEBUG] grep regex: ${result}`); - return result; + return new RegExp(tagList.map(tag => `(?=.*${tag})`).join(''), 'i'); } function buildBrowserStackEndpoint(testName: string) { @@ -70,7 +60,7 @@ export default defineConfig({ globalSetup: require.resolve(path.join(__dirname, 'tests/global-setup')), fullyParallel: true, forbidOnly: !!process.env.CI, - // grep: buildGrepFromTags(), // temporarily disabled for debugging + grep: buildGrepFromTags(), retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, timeout: 60_000, @@ -97,7 +87,7 @@ export default defineConfig({ projects: [ { name: 'chromium-personal', - testMatch: '**/personal/**/*.spec.ts', + testMatch: 'personal/**/*.spec.ts', use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/personal.json', @@ -107,7 +97,7 @@ export default defineConfig({ { name: 'chromium-claimed', - testMatch: '**/claimed/**/*.spec.ts', + testMatch: 'claimed/**/*.spec.ts', use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/claimed.json', @@ -117,7 +107,7 @@ export default defineConfig({ { name: 'chromium-clinician', - testMatch: '**/clinician/**/*.spec.ts', + testMatch: 'clinician/**/*.spec.ts', use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/clinician.json', @@ -129,7 +119,7 @@ export default defineConfig({ ? [ { name: 'bs-chrome-personal', - testMatch: '**/patient/**/*.spec.ts', + testMatch: 'personal/**/*.spec.ts', use: { storageState: 'tests/.auth/personal.json', connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Personal Patient Tests') }, @@ -138,7 +128,7 @@ export default defineConfig({ { name: 'bs-chrome-claimed', - testMatch: '**/claimed/**/*.spec.ts', + testMatch: 'claimed/**/*.spec.ts', use: { storageState: 'tests/.auth/claimed.json', connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Claimed Patient Tests') }, @@ -147,7 +137,7 @@ export default defineConfig({ { name: 'bs-chrome-clinician', - testMatch: '**/clinician/**/*.spec.ts', + testMatch: 'clinician/**/*.spec.ts', use: { storageState: 'tests/.auth/clinician.json', connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Clinician Tests') }, From 7fbd80e8a9a0224ce08165d012a9524669224c5b Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Fri, 13 Feb 2026 12:12:34 -0500 Subject: [PATCH 33/60] Upgrade Playwright, add tag filtering, use regexes Bump Playwright Docker image to v1.57.0 in CircleCI jobs and add optional TEST_TAGS support to the test run command (normalizes to lowercase, ensures leading '@', and passes as --grep). Preserve parallel sharding. Also convert Playwright project testMatch values from glob strings to regular expressions for more precise matching. Files changed: .circleci/config.yml, playwright.config.ts. --- .circleci/config.yml | 26 ++++++++++++++++++++------ playwright.config.ts | 12 ++++++------ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 6c7d26f..99d1b56 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -39,7 +39,7 @@ jobs: test: working_directory: ~/tidepool-org/webuitests docker: - - image: mcr.microsoft.com/playwright:v1.54.1-noble + - image: mcr.microsoft.com/playwright:v1.57.0-noble parallelism: 4 steps: - checkout @@ -68,10 +68,17 @@ jobs: name: Install Playwright Dependencies command: npx playwright install --with-deps - # Run tests with parallel execution + # Run tests with parallel execution and optional tag filtering - run: name: Run Playwright Tests - command: npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL + command: | + GREP_ARG="" + if [ -n "$TEST_TAGS" ]; then + TAG=$(echo "$TEST_TAGS" | tr '[:upper:]' '[:lower:]') + [[ "$TAG" != @* ]] && TAG="@${TAG}" + GREP_ARG="--grep ${TAG}" + fi + npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL $GREP_ARG # Store test results and artifacts - store_artifacts: @@ -150,7 +157,7 @@ jobs: scheduled-test: working_directory: ~/tidepool-org/webuitests docker: - - image: mcr.microsoft.com/playwright:v1.54.1-noble + - image: mcr.microsoft.com/playwright:v1.57.0-noble parallelism: 4 steps: - checkout @@ -179,10 +186,17 @@ jobs: name: Install Playwright Dependencies command: npx playwright install --with-deps - # Run tests with parallel execution + # Run tests with parallel execution and optional tag filtering - run: name: Run Playwright Tests - command: npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL + command: | + GREP_ARG="" + if [ -n "$TEST_TAGS" ]; then + TAG=$(echo "$TEST_TAGS" | tr '[:upper:]' '[:lower:]') + [[ "$TAG" != @* ]] && TAG="@${TAG}" + GREP_ARG="--grep ${TAG}" + fi + npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL $GREP_ARG # Store test results and artifacts - store_artifacts: diff --git a/playwright.config.ts b/playwright.config.ts index 19dbf87..cdd1701 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -87,7 +87,7 @@ export default defineConfig({ projects: [ { name: 'chromium-personal', - testMatch: 'personal/**/*.spec.ts', + testMatch: /personal\/.*\.spec\.ts$/, use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/personal.json', @@ -97,7 +97,7 @@ export default defineConfig({ { name: 'chromium-claimed', - testMatch: 'claimed/**/*.spec.ts', + testMatch: /claimed\/.*\.spec\.ts$/, use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/claimed.json', @@ -107,7 +107,7 @@ export default defineConfig({ { name: 'chromium-clinician', - testMatch: 'clinician/**/*.spec.ts', + testMatch: /clinician\/.*\.spec\.ts$/, use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/clinician.json', @@ -119,7 +119,7 @@ export default defineConfig({ ? [ { name: 'bs-chrome-personal', - testMatch: 'personal/**/*.spec.ts', + testMatch: /personal\/.*\.spec\.ts$/, use: { storageState: 'tests/.auth/personal.json', connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Personal Patient Tests') }, @@ -128,7 +128,7 @@ export default defineConfig({ { name: 'bs-chrome-claimed', - testMatch: 'claimed/**/*.spec.ts', + testMatch: /claimed\/.*\.spec\.ts$/, use: { storageState: 'tests/.auth/claimed.json', connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Claimed Patient Tests') }, @@ -137,7 +137,7 @@ export default defineConfig({ { name: 'bs-chrome-clinician', - testMatch: 'clinician/**/*.spec.ts', + testMatch: /clinician\/.*\.spec\.ts$/, use: { storageState: 'tests/.auth/clinician.json', connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Clinician Tests') }, From 4f1f52e7dd6239a3b25389b90d26ca1d7bbf75f7 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Fri, 13 Feb 2026 12:49:26 -0500 Subject: [PATCH 34/60] Add CircleCI test-list debug and config tweaks Add a short debug step in CircleCI to list discovered Playwright tests before applying --grep (helps diagnose test discovery/grep issues). Quote the GREP_ARG to preserve tags with special chars/spaces. In playwright.config.ts remove the buildGrepFromTags helper and the config-level grep (move filtering to the CLI), and replace project testMatch regexes with explicit glob patterns because apparently we need to go back to that. --- .circleci/config.yml | 14 ++++++++++++-- playwright.config.ts | 46 ++++++-------------------------------------- 2 files changed, 18 insertions(+), 42 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 99d1b56..4a9eb8b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -68,6 +68,11 @@ jobs: name: Install Playwright Dependencies command: npx playwright install --with-deps + # Debug: verify test discovery before applying grep + - run: + name: List discovered tests (debug) + command: npx playwright test --list 2>&1 | head -20 || true + # Run tests with parallel execution and optional tag filtering - run: name: Run Playwright Tests @@ -76,7 +81,7 @@ jobs: if [ -n "$TEST_TAGS" ]; then TAG=$(echo "$TEST_TAGS" | tr '[:upper:]' '[:lower:]') [[ "$TAG" != @* ]] && TAG="@${TAG}" - GREP_ARG="--grep ${TAG}" + GREP_ARG="--grep \"${TAG}\"" fi npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL $GREP_ARG @@ -186,6 +191,11 @@ jobs: name: Install Playwright Dependencies command: npx playwright install --with-deps + # Debug: verify test discovery before applying grep + - run: + name: List discovered tests (debug) + command: npx playwright test --list 2>&1 | head -20 || true + # Run tests with parallel execution and optional tag filtering - run: name: Run Playwright Tests @@ -194,7 +204,7 @@ jobs: if [ -n "$TEST_TAGS" ]; then TAG=$(echo "$TEST_TAGS" | tr '[:upper:]' '[:lower:]') [[ "$TAG" != @* ]] && TAG="@${TAG}" - GREP_ARG="--grep ${TAG}" + GREP_ARG="--grep \"${TAG}\"" fi npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL $GREP_ARG diff --git a/playwright.config.ts b/playwright.config.ts index cdd1701..56bb48a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -7,39 +7,6 @@ const isBrowserStack = Boolean( process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESS_KEY, ); -/** - * Convert TEST_TAGS env var to a Playwright grep RegExp. - * - * - Single tag: TEST_TAGS="smoke" → /@smoke/i - * - AND (spaces): TEST_TAGS="smoke ui" → /(?=.*@smoke)(?=.*@ui)/i - * - OR (commas): TEST_TAGS="smoke,api" → /@smoke|@api/i - * - Case-insensitive so Jira uppercase input matches lowercase tags. - * - Works with or without @ prefix. - */ -function buildGrepFromTags(): RegExp | undefined { - const testTags = process.env.TEST_TAGS?.trim(); - if (!testTags) return undefined; - - const hasCommas = testTags.includes(','); - const tagList = testTags - .split(hasCommas ? ',' : /\s+/) - .map(t => t.trim().toLowerCase()) - .filter(t => t.length > 0) - .map(t => (t.startsWith('@') ? t : `@${t}`)); - - if (tagList.length === 0) return undefined; - - if (tagList.length === 1) { - return new RegExp(tagList[0], 'i'); - } - - if (hasCommas) { - return new RegExp(tagList.join('|'), 'i'); - } - - return new RegExp(tagList.map(tag => `(?=.*${tag})`).join(''), 'i'); -} - function buildBrowserStackEndpoint(testName: string) { const caps = { browser: 'chrome', @@ -60,7 +27,6 @@ export default defineConfig({ globalSetup: require.resolve(path.join(__dirname, 'tests/global-setup')), fullyParallel: true, forbidOnly: !!process.env.CI, - grep: buildGrepFromTags(), retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, timeout: 60_000, @@ -87,7 +53,7 @@ export default defineConfig({ projects: [ { name: 'chromium-personal', - testMatch: /personal\/.*\.spec\.ts$/, + testMatch: '**/personal/*.spec.ts', use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/personal.json', @@ -97,7 +63,7 @@ export default defineConfig({ { name: 'chromium-claimed', - testMatch: /claimed\/.*\.spec\.ts$/, + testMatch: '**/claimed/*.spec.ts', use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/claimed.json', @@ -107,7 +73,7 @@ export default defineConfig({ { name: 'chromium-clinician', - testMatch: /clinician\/.*\.spec\.ts$/, + testMatch: '**/clinician/*.spec.ts', use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/clinician.json', @@ -119,7 +85,7 @@ export default defineConfig({ ? [ { name: 'bs-chrome-personal', - testMatch: /personal\/.*\.spec\.ts$/, + testMatch: '**/personal/*.spec.ts', use: { storageState: 'tests/.auth/personal.json', connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Personal Patient Tests') }, @@ -128,7 +94,7 @@ export default defineConfig({ { name: 'bs-chrome-claimed', - testMatch: /claimed\/.*\.spec\.ts$/, + testMatch: '**/claimed/*.spec.ts', use: { storageState: 'tests/.auth/claimed.json', connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Claimed Patient Tests') }, @@ -137,7 +103,7 @@ export default defineConfig({ { name: 'bs-chrome-clinician', - testMatch: /clinician\/.*\.spec\.ts$/, + testMatch: '**/clinician/*.spec.ts', use: { storageState: 'tests/.auth/clinician.json', connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Clinician Tests') }, From 055daa190e95bfcadb7ab2279ddadb6f4732b3c7 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Fri, 13 Feb 2026 16:41:56 -0500 Subject: [PATCH 35/60] Include shard arg when running Playwright tests Replace the GREP_ARG pattern with an explicit SHARD_ARG and invoke npm test inside the if/else so --shard is always passed and --grep is only added when TEST_TAGS is present. Applied the change to both Playwright test job blocks to ensure correct CircleCI sharding and avoid quoting/argument concatenation issues. --- .circleci/config.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4a9eb8b..4270959 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -77,13 +77,14 @@ jobs: - run: name: Run Playwright Tests command: | - GREP_ARG="" + SHARD_ARG="--shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL" if [ -n "$TEST_TAGS" ]; then TAG=$(echo "$TEST_TAGS" | tr '[:upper:]' '[:lower:]') [[ "$TAG" != @* ]] && TAG="@${TAG}" - GREP_ARG="--grep \"${TAG}\"" + npm test -- $SHARD_ARG --grep "$TAG" + else + npm test -- $SHARD_ARG fi - npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL $GREP_ARG # Store test results and artifacts - store_artifacts: @@ -200,13 +201,14 @@ jobs: - run: name: Run Playwright Tests command: | - GREP_ARG="" + SHARD_ARG="--shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL" if [ -n "$TEST_TAGS" ]; then TAG=$(echo "$TEST_TAGS" | tr '[:upper:]' '[:lower:]') [[ "$TAG" != @* ]] && TAG="@${TAG}" - GREP_ARG="--grep \"${TAG}\"" + npm test -- $SHARD_ARG --grep "$TAG" + else + npm test -- $SHARD_ARG fi - npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL $GREP_ARG # Store test results and artifacts - store_artifacts: From d69cb3820b5abf4883231fb4a280f21f187ea536 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Fri, 13 Feb 2026 16:57:35 -0500 Subject: [PATCH 36/60] Remove Playwright test discovery debug steps Remove duplicated debug steps that listed discovered Playwright tests from .circleci/config.yml. The 'List discovered tests (debug)' run steps (running `npx playwright test --list 2>&1 | head -20 || true`) were redundant and noisy; removing them cleans up the CI config without affecting Playwright install or test execution. --- .circleci/config.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4270959..e6d6fe6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -68,11 +68,6 @@ jobs: name: Install Playwright Dependencies command: npx playwright install --with-deps - # Debug: verify test discovery before applying grep - - run: - name: List discovered tests (debug) - command: npx playwright test --list 2>&1 | head -20 || true - # Run tests with parallel execution and optional tag filtering - run: name: Run Playwright Tests @@ -192,11 +187,6 @@ jobs: name: Install Playwright Dependencies command: npx playwright install --with-deps - # Debug: verify test discovery before applying grep - - run: - name: List discovered tests (debug) - command: npx playwright test --list 2>&1 | head -20 || true - # Run tests with parallel execution and optional tag filtering - run: name: Run Playwright Tests From f8eb2210b4c5eacc003fed215fca74bd14621d53 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Fri, 13 Feb 2026 16:58:11 -0500 Subject: [PATCH 37/60] Set Playwright headless based on CI env Replace hardcoded headless: false with headless: !process.env.CI in playwright.config.ts for the personal, claimed, and clinician projects. This makes tests run headless in CI while keeping the headed behavior locally for debugging. --- playwright.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 56bb48a..3056a4e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -57,7 +57,7 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/personal.json', - headless: false, + headless: !process.env.CI, }, }, @@ -67,7 +67,7 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/claimed.json', - headless: false, + headless: !process.env.CI, }, }, @@ -77,7 +77,7 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/clinician.json', - headless: false, + headless: !process.env.CI, }, }, From 5f2f8ec441076c56c12db7a1045ace783db2aa63 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Fri, 13 Feb 2026 17:03:39 -0500 Subject: [PATCH 38/60] Try again to Run Playwright headless in CI Change headless flag to !!process.env.CI so Playwright runs headless when CI is set. This replaces the previous !process.env.CI logic that caused tests to run headed on CI; updated for personal/claimed/clinician storageState projects in playwright.config.ts for consistent CI behavior. --- playwright.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 3056a4e..51e8b9a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -57,7 +57,7 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/personal.json', - headless: !process.env.CI, + headless: !!process.env.CI, }, }, @@ -67,7 +67,7 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/claimed.json', - headless: !process.env.CI, + headless: !!process.env.CI, }, }, @@ -77,7 +77,7 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/clinician.json', - headless: !process.env.CI, + headless: !!process.env.CI, }, }, From ef8f9db29e309cbbdc5b1784bb611e14e31156bd Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Fri, 13 Feb 2026 17:29:58 -0500 Subject: [PATCH 39/60] Reduce Playwright CI retries from 2 to 1 Lower the number of test retries in CI from 2 to 1 in playwright.config.ts. This reduces repeated test reruns in CI while keeping local retries at 0. --- playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index 51e8b9a..128d0f0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -27,7 +27,7 @@ export default defineConfig({ globalSetup: require.resolve(path.join(__dirname, 'tests/global-setup')), fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, + retries: process.env.CI ? 1 : 0, workers: process.env.CI ? 1 : undefined, timeout: 60_000, From 7aafdebce6f6daff0062bf4645559c4b3c49ee56 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Fri, 13 Feb 2026 17:31:00 -0500 Subject: [PATCH 40/60] Use basename of attachment.path for filename Replace attachment.name with path.basename(attachment.path) when constructing test evidence so the filename is reliably derived from the attachment path. This should allow xray to recognize the correct attachment form. --- utilities/xray-json-reporter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utilities/xray-json-reporter.ts b/utilities/xray-json-reporter.ts index 1a5d60e..cd2dba2 100644 --- a/utilities/xray-json-reporter.ts +++ b/utilities/xray-json-reporter.ts @@ -310,7 +310,7 @@ class XrayJsonReporter { if (base64Data) { testEvidence.push({ data: base64Data, - filename: attachment.name, + filename: path.basename(attachment.path), contentType, }); } From 75ea1897106559e258f5763ea4cc520f9bf2bc9c Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Fri, 13 Feb 2026 17:31:19 -0500 Subject: [PATCH 41/60] Update README to Playwright test guide Replace legacy Nightwatch + BrowserStack README with a comprehensive Playwright-based testing guide. Adds Getting Started, environment setup, npm/playwright install steps, .env variables, test-running commands and tag filters, Jira-triggered test runs, integrated Xray reporter details, development guide (POM, fixtures, auth, import aliases), and CI/CD notes. Remove docs/XRAY_INTEGRATION.md since its Xray integration content has been consolidated into the updated README. --- README.md | 424 ++++++++++++++++++++++++++++++++++++--- docs/XRAY_INTEGRATION.md | 251 ----------------------- 2 files changed, 401 insertions(+), 274 deletions(-) delete mode 100644 docs/XRAY_INTEGRATION.md diff --git a/README.md b/README.md index cf0fa07..4c41af1 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,415 @@ -# Nightwatch + Browserstack UI Tests +# Tidepool Web UI Tests -The home of Tidepool UI testing with nightwatch/browser stack combination +Automated end-to-end testing suite for Tidepool's web application, built with [Playwright](https://playwright.dev). Playwright is a modern test framework from Microsoft that enables reliable cross-browser testing with auto-waiting, network interception, and built-in test isolation. It runs tests against real browser engines (Chromium, Firefox, WebKit) and supports features like screenshots, video recording, and tracing out of the box. -## Background Information +This suite covers clinician and patient user flows across multiple QA environments, with integrated reporting to Jira Xray. -### Nightwatch +## Table of Contents -Nightwatch is a Node.js end to end testing solution using the W3C Webdriver API. See the documentation here: https://nightwatchjs.org/# +- [Getting Started](#getting-started) +- [Running Tests](#running-tests) +- [Test Tags](#test-tags) +- [Running Tests via Jira](#running-tests-via-jira) +- [Xray Integration](#xray-integration) +- [Development Guide](#development-guide) +- [CI/CD Pipeline](#cicd-pipeline) -### Browserstack +--- -BrowserStack is a cloud web and mobile testing platform that enables developers to test their websites and mobile applications across on-demand browsers, operating systems and real mobile devices, without requiring users to install or maintain an internal lab of virtual machines, devices or emulators. Learn more here: https://www.browserstack.com/ +## Getting Started -### Page Objects +### Prerequisites -The tests here use page objects. This means that frequently used UI elements and actions are mapped out in an appropriately named page objects file and imported into the test files to run tests. This means the test themselves can remain relatively unchanged and, if there is a change to an element that's causing a test to fail (selector/text/ect), it can be updated in the corresponding page object file without having to navigate through and rewrite every single test that uses said element. If you'd like to learn more about page objects... https://martinfowler.com/bliki/PageObject.html +- Node.js (LTS) +- npm -## Testing Locally (with Browserstack) +### Installation -- Clone this Repo and install dev dependencies -- Create a .env file containing: +```bash +npm ci +npx playwright install --with-deps +``` - BROWSERSTACK_USER= - BROWSERSTACK_KEY= - DSA_USERNAME_TANDEM= - DSA_PASSWORD_TANDEM= +### Environment Setup -* run `npm testParallel` to test all setup environments (qa1, qa2, prd) on all browsers (currently only chrome on windows 10 for now) for tests that are eligible to be run in parallel (tests that don't change user state or access tidepool emails) - - run `npm run testSeq` to test tests that cannot be run in parallel on all browsers (currently only chrome on windows 10 for now) for a given environment (qa1, qa2, or prd). This is typically only used for CI. - - run `npm run testqa1Chrome` to test qa1 on all browsers (currently only chrome on windows 10 for now) - - run `npm run testqa2Chrome` to test qa2 on all browsers (currently only chrome on windows 10 for now) - - run `npm run testprdChrome` to test prd on all browsers (currently only chrome on windows 10 for now) +Create a `.env` file in the project root with the following variables: -## Testing Locally (without Browserstack) +```env +# Required: Test account credentials +PERSONAL_USERNAME= +PERSONAL_PASSWORD= +CLAIMED_USERNAME= +CLAIMED_PASSWORD= +SHARED_USERNAME= +SHARED_PASSWORD= +CLINICIAN_USERNAME= +CLINICIAN_PASSWORD= -The nightwatch tests can be run without using the browserstack service. You will, however, need to install selenium and the appropriate web driver dependencies for the browsers you wish to test as well as update the `nightwatch.conf.js` to use these dependencies instead of browserstack. There's really no reason to do this. +# Required: Target environment +TARGET_ENV=qa2 # Options: qa1, qa2, qa3, qa4, qa5, int, prd + +# Optional: Xray reporting (see Xray Integration section) +XRAY_CLIENT_ID= +XRAY_CLIENT_SECRET= +XRAY_PROJECT_KEY=SAND +TEST_EXECUTION_KEY=none + +# Optional: BrowserStack cloud testing +BROWSERSTACK_USERNAME= +BROWSERSTACK_ACCESS_KEY= +``` + +Each `TARGET_ENV` maps to a URL: + +| Environment | URL | +|---|---| +| `qa1` | `https://qa1.development.tidepool.org` | +| `qa2` | `https://qa2.development.tidepool.org` | +| `qa3`-`qa5` | `https://qa{n}.development.tidepool.org` | +| `int` | `https://int.development.tidepool.org` | +| `prd` / `production` | `https://app.tidepool.org` | + +--- + +## Running Tests + +### Basic Commands + +```bash +# Run all tests +npm test + +# Run a specific test file +npx playwright test tests/personal/basic-functionality.spec.ts + +# Debug mode (opens Playwright inspector) +npm run debug + +# View the HTML report from the last run +npx playwright show-report +``` + +### Filtering by Tag + +Run a subset of tests using tags: + +```bash +# Single tag +npx playwright test --grep "@smoke" + +# OR logic (tests matching ANY tag) +npx playwright test --grep "@smoke|@critical" + +# AND logic (tests matching ALL tags) +npx playwright test --grep "(?=.*@smoke)(?=.*@ui)" +``` + +Shorthand npm scripts are also available: + +```bash +npm run test:smoke +npm run test:critical +npm run test:api +npm run test:ui +npm run test:patient +npm run test:clinician +npm run test:regression +``` + +### Changing the Target Environment + +Set `TARGET_ENV` in your `.env` file or export it before running: + +```bash +TARGET_ENV=qa3 npm test +``` + +--- + +## Test Tags + +Tests are organized with a tagging system defined in `tests/fixtures/test-tags.ts`. Every test must include at least one tag from each required category. + +### Required Tag Categories + +| Category | Tags | +|---|---| +| **User Type** | `@patient`, `@clinician` | +| **Test Type** | `@ui`, `@api`, `@smoke`, `@regression` | +| **Priority** | `@critical`, `@high`, `@medium`, `@low` | + +### Additional Tags + +| Category | Tags | +|---|---| +| **User Subtype** | `@personal`, `@claimed`, `@shared_member`, `@custodial` | +| **Backend Service** | `@back-shoreline`, `@back-clinic`, `@back-keycloak`, `@back-hydrophone`, `@back-platform`, `@back-seagull`, `@back-tidewhisperer`, `@back-messageapi`, `@back-jellyfish`, `@back-gatekeeper`, `@back-export`, `@back-highwater` | +| **API Endpoint** | `@api_profile`, `@api_user` | +| **Jira Link** | `@jira(PROJ-1234)` - links a test to a Jira ticket | + +### How Tags are Applied + +Tags are added to each test using `createValidatedTags()`, which enforces that all required categories are present: + +```typescript +import { TEST_TAGS, createValidatedTags } from '@fixtures/test-tags'; + +test('should display patient data', { + tag: createValidatedTags([ + TEST_TAGS.PATIENT, + TEST_TAGS.UI, + TEST_TAGS.PRIORITY_HIGH, + ]), +}, async ({ page }) => { + // ... +}); +``` + +--- + +## Running Tests via Jira + +Tests can be triggered directly from Jira, which sends a request to CircleCI to run the tests and automatically attaches the results back to the Jira issue. + +### Steps + +1. Go to the **WEB**, **BACKEND**, or **UPLOAD** testing project in Jira +2. Open the Story or Bug issue you want to attach test results to +3. Click the **Jira Automation** button (lightning bolt icon) +4. Select **"Trigger Automated Web UI Test Cases"** +5. Fill in the fields: + - **Environment** - the QA environment to test against (`qa1`, `qa2`, `qa3`, `qa4`, `qa5`, `int`, or `prd`) + - **Test Tags** *(optional)* - filter which tests to run (e.g., `patient`, `smoke`, `clinician`). See [available tags](#test-tags) above. Leave blank to run all tests. +6. Submit + +Jira automation will send the request to CircleCI, which runs the tests and uploads results to Xray. A Test Execution will be created and linked to the Jira issue automatically. Results include pass/fail status, step-level details, screenshots on failure, and video recordings of failed tests. + +--- + +## Xray Integration + +The test suite includes a custom Playwright reporter (`utilities/xray-json-reporter.ts`) that automatically uploads test results to [Xray](https://www.getxray.app/) (Jira's test management tool) after each run. + +### What Gets Uploaded + +Each test result includes: + +| Data | When Included | +|---|---| +| Test summary and status (PASSED/FAILED) | Always | +| Step-by-step results (Given/When/Then) | Always | +| Step durations | Always | +| Error messages | On failure | +| Screenshots | On failure | +| Video recordings | On failure | + +### Running Locally with Xray Upload + +To upload results to Xray from a local run, set these environment variables in your `.env` file: + +```env +XRAY_CLIENT_ID= +XRAY_CLIENT_SECRET= +XRAY_PROJECT_KEY=SAND # Jira project key +TEST_EXECUTION_KEY=SAND-1234 # Link to existing execution +``` + +Then run your tests as normal: + +```bash +npm test +``` + +The reporter will authenticate with Xray, upload results, and print the Test Execution key. + +To **link results to an existing Test Execution**, set `TEST_EXECUTION_KEY` to the execution's Jira key (e.g., `QAE-643`). To **create a new execution**, set it to `none`. + +### Running Locally Without Xray Upload + +Simply omit `XRAY_CLIENT_ID` and `XRAY_CLIENT_SECRET` from your `.env` file (or leave them blank). The reporter will silently skip the upload and only generate local reports: + +- **HTML report**: `playwright-report/` (open with `npx playwright show-report`) +- **JSON report**: `test-results/last-run.json` + +### How It Works + +1. Playwright runs tests and generates `test-results/last-run.json` +2. The Xray reporter (`onEnd` hook) reads the JSON results +3. Maps Playwright's Given/When/Then steps to Xray test step definitions and results +4. Converts attachments (screenshots, videos) to base64 evidence +5. Authenticates with Xray Cloud API using client credentials +6. Uploads via Xray's JSON import endpoint (`/api/v2/import/execution`) +7. Test Execution is created or updated in Jira + +--- + +## Development Guide + +### Architecture Overview + +The project follows the **Page Object Model (POM)** pattern: + +``` +page-objects/ # Page classes that encapsulate UI interactions +ā”œā”€ā”€ LoginPage.ts # Authentication flow +ā”œā”€ā”€ patient/ # Patient-specific pages +│ ā”œā”€ā”€ PatientNavigation.ts +│ ā”œā”€ā”€ ProfilePage.ts +│ ā”œā”€ā”€ BasicsPage.ts +│ ā”œā”€ā”€ DailyPage.ts +│ └── components/ # Reusable patient UI components +│ └── daily-chart.ts +ā”œā”€ā”€ clinician/ # Clinician-specific pages +│ ā”œā”€ā”€ ClinicianDashboardPage.ts +│ ā”œā”€ā”€ ClinicianNavigation.ts +│ ā”œā”€ā”€ WorkspacesPage.ts +│ └── components/ # Reusable clinician UI components +└── account/ # Account management pages + ā”œā”€ā”€ AccountNavigation.ts + └── AccountSettingsPage.ts + +tests/ # Test suites organized by user auth state +ā”œā”€ā”€ global-setup.ts # Pre-authenticates all user types +ā”œā”€ā”€ fixtures/ # Custom fixtures and test helpers +│ ā”œā”€ā”€ base.ts # Core fixtures (timing, screenshots, exceptions) +│ ā”œā”€ā”€ test-tags.ts # Tag system with validation +│ ā”œā”€ā”€ clinic-helpers.ts # Clinician navigation helpers +│ ā”œā”€ā”€ patient-helpers.ts # Patient navigation helpers +│ └── network-helpers.ts # API network capture +ā”œā”€ā”€ personal/ # Tests for personal patient accounts +ā”œā”€ā”€ claimed/ # Tests for claimed patient accounts +└── clinician/ # Tests for clinician workflows + +utilities/ # Shared utilities +ā”œā”€ā”€ env.ts # Environment config (Zod validation + URL mapping) +ā”œā”€ā”€ xray-json-reporter.ts # Xray Cloud reporter +ā”œā”€ā”€ xray-types.ts # Xray TypeScript types +└── annotations.ts # Jira annotation helpers +``` + +### Authentication + +The `global-setup.ts` file runs once before all tests. It authenticates four user types (personal, claimed, shared, clinician) and saves their session state to `tests/.auth/*.json`. Each Playwright project uses `storageState` to load the appropriate session, so individual tests don't need to log in. + +### Import Aliases + +The project uses TypeScript path aliases (defined in `tsconfig.json`): + +```typescript +import { expect, test } from '@fixtures/base'; +import { TEST_TAGS } from '@fixtures/test-tags'; +import LoginPage from '@pom/LoginPage'; +import PatientDataBasicsPage from '@pom/patient/BasicsPage'; +``` + +| Alias | Resolves To | +|---|---| +| `@fixtures/*` | `tests/fixtures/*` | +| `@pom/*` | `page-objects/*` (including patient/ and clinician/ subdirs) | +| `@components/*` | `page-objects/*/components/*` | + +### Adding a New Test + +1. Create a `.spec.ts` file in the appropriate directory: + - `tests/personal/` for personal patient tests + - `tests/claimed/` for claimed patient tests + - `tests/clinician/` for clinician tests + +2. Import fixtures and page objects: + + ```typescript + import { expect, test } from '@fixtures/base'; + import { TEST_TAGS, createValidatedTags } from '@fixtures/test-tags'; + ``` + +3. Structure tests using Given/When/Then steps: + + ```typescript + test('should do something', { + tag: createValidatedTags([ + TEST_TAGS.PATIENT, + TEST_TAGS.UI, + TEST_TAGS.PRIORITY_HIGH, + ]), + }, async ({ page }) => { + await test.step('Given the user is on the basics page', async () => { + // setup + }); + + await test.step('When the user clicks a button', async () => { + // action + }); + + await test.step('Then the result is visible', async () => { + // assertion + }); + }); + ``` + +4. Tags are required. Include at least one **User Type**, one **Test Type**, and one **Priority** tag. + +### Creating a Page Object + +1. Create a new file in the appropriate `page-objects/` subdirectory +2. Use semantic Playwright locators (`getByRole`, `getByText`, `getByLabel`) over CSS selectors +3. Follow the established pattern: + + ```typescript + import { Locator, Page } from '@playwright/test'; + + export default class MyPage { + readonly page: Page; + readonly submitButton: Locator; + readonly nameInput: Locator; + + constructor(page: Page) { + this.page = page; + this.submitButton = page.getByRole('button', { name: 'Submit' }); + this.nameInput = page.getByRole('textbox', { name: 'Name' }); + } + + async goto(): Promise { + await this.page.goto('/my-page'); + } + + async fillName(name: string): Promise { + await this.nameInput.fill(name); + } + } + ``` + +### Code Quality + +```bash +npm run check # Lint + TypeScript check +npm run lint:fix # Auto-fix lint issues +npm run format # Format with Prettier +``` + +--- + +## CI/CD Pipeline + +Tests run on CircleCI with the following workflow: + +### Commit Workflow + +Every push triggers: + +1. **code-quality-check** - ESLint and TypeScript validation +2. **test** - Parallel Playwright test execution (4 shards) + +### Configuration + +The pipeline accepts parameters (set via Jira automation or CircleCI API): + +| Parameter | Default | Description | +|---|---|---| +| `testEnvironment` | `qa2` | Target environment | +| `testExecKey` | `none` | Xray Test Execution key to link results | +| `testTags` | *(empty)* | Tag filter (e.g., `patient`, `smoke`) | +| `xrayProjectKey` | `SAND` | Jira project for Xray | + +### Slack Notifications + +Automated Slack notifications are sent on test completion for the `main` and `develop` branches, reporting pass/fail status with a link to the build. diff --git a/docs/XRAY_INTEGRATION.md b/docs/XRAY_INTEGRATION.md deleted file mode 100644 index d72e063..0000000 --- a/docs/XRAY_INTEGRATION.md +++ /dev/null @@ -1,251 +0,0 @@ -# Xray Integration Documentation - -## Overview - -This project uses a JSON-based Xray integration that captures Playwright test data and uploads it to JIRA Xray Cloud with evidence handling including screenshots and videos (failed tests only). - -## Architecture - -### 1. **Playwright Configuration** ([playwright.config.ts](../playwright.config.ts)) - -- **JSON Reporter**: Generates `test-results/last-run.json` with complete test data -- **Xray JSON Reporter**: Custom reporter that automatically uploads to Xray Cloud - -```typescript -reporter: [ - ['html', { open: 'never', outputFolder: 'playwright-report' }], - ['json', { outputFile: 'test-results/last-run.json' }], - ['./utilities/xray-json-reporter.ts'], // Auto-upload to Xray -], -``` - -### 2. **Xray JSON Reporter** ([utilities/xray-json-reporter.ts](../utilities/xray-json-reporter.ts)) - -**Core Features:** - -- Maps Playwright test steps to Xray test steps with evidence -- Attaches screenshots per step (e.g., `step-01-given-clinician-has-been-logged-in.png`) -- Embeds video evidence for failed tests only -- Supports configurable project keys -- Supports test execution key parameter for linking to existing test executions - -**Evidence Handling:** - -- **Videos**: Only uploaded for failed tests (saves storage) -- **Screenshots**: Always included as base64-encoded inline evidence -- **JSON responses**: Always included inline -- Passing test videos are skipped entirely - -**Data Mapping:** - -- **Test Steps**: Extracts from `Step Duration:` annotations -- **Evidence**: Screenshots, videos, JSON responses per step -- **Status**: PASSED/FAILED/TODO with detailed failure messages - -### 3. **CircleCI Integration** ([.circleci/config.yml](../.circleci/config.yml)) - -The Xray reporter uploads automatically during `onEnd` — no separate CI step needed. - -**Pipeline Parameters:** - -- `testEnvironment` - Target environment (qa1, qa2, qa3, qa4, qa5, prd, int) -- `testExecKey` - Test Execution Key to link results to (or 'none' for auto-create) -- `testTags` - Filter tests by tags -- `xrayProjectKey` - Xray project key (default: 'SAND') - -## Usage - -### Local Development - -```bash -# Set required environment variables in .env -XRAY_CLIENT_ID=your_client_id -XRAY_CLIENT_SECRET=your_client_secret -XRAY_PROJECT_KEY=SAND # Optional, defaults to SAND -TARGET_ENV=qa1 -TEST_EXECUTION_KEY=SAND-1245 # Or 'none' for auto-create - -# Run tests — reporter auto-uploads to Xray if credentials are set -npm test -``` - -### CI/CD Pipeline - -Tests automatically upload to Xray when: - -- `XRAY_CLIENT_ID` and `XRAY_CLIENT_SECRET` are available in environment -- `TEST_EXECUTION_KEY` is set (and not 'none') -- JSON results file exists (`test-results/last-run.json`) - -**CircleCI Pipeline Triggers:** - -```bash -# Run tests on qa2 and link to existing test execution -curl -X POST \ - --url https://circleci.com/api/v2/project/github/your-org/your-repo/pipeline \ - -H "Circle-Token: $CIRCLE_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "parameters": { - "testEnvironment": "qa2", - "testExecKey": "SAND-123", - "xrayProjectKey": "SAND" - } - }' -``` - -## Xray JSON Format - -### Execution Structure - -```json -{ - "testExecutionKey": "SAND-1245", - "info": { - "summary": "Playwright Test Execution - 2025-08-22T19:50:15.680Z", - "description": "Automated test execution for qa1 environment\n\nResults: 45 passed, 2 failed, 1 skipped", - "startDate": "2025-08-22T19:50:15.680Z", - "finishDate": "2025-08-22T19:50:56.408Z" - }, - "tests": [...] -} -``` - -**Note:** `testExecutionKey` is at the root level. When linking to an existing execution, `testEnvironments` and `version` are omitted to avoid validation errors. - -### Individual Test Structure - -```json -{ - "testInfo": { - "summary": "should allow navigation to account settings", - "type": "Manual", - "projectKey": "SAND", - "steps": [ - { - "action": "When user navigates to settings", - "data": "Duration: 5193ms", - "result": "Then the settings page is displayed" - } - ] - }, - "status": "PASSED", - "evidence": [ - { - "data": "base64-encoded-screenshot", - "filename": "final-screenshot.png", - "contentType": "image/png" - } - ], - "steps": [ - { - "status": "PASSED", - "evidence": [ - { - "data": "base64-encoded-step-screenshot", - "filename": "step-01-screenshot.png", - "contentType": "image/png" - } - ] - } - ] -} -``` - -**Key details:** - -- `testInfo.steps` contains step **definitions** (action, data, result) -- `test.steps` contains step **execution results** (status, evidence, actualResult) -- Status values are `PASSED`, `FAILED`, `TODO`, `EXECUTING` (Xray Cloud format) -- Evidence field is singular `evidence` (not `evidences`) - -### Step Mapping Logic - -Given/When/Then steps are mapped as follows: - -- **Given** → Standalone step (action only) -- **When** → Step action; consecutive Then/And steps become its `result` -- **Then/And** → Combined as the result of the preceding When step - -**Example:** -``` -When user logs in → action: "When user logs in" -Then user sees dashboard result: "Then user sees dashboard\nAnd user sees welcome" -And user sees welcome -``` - -## Configuration Reference - -### Environment Variables - -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `XRAY_CLIENT_ID` | Yes | - | Xray Cloud API client ID | -| `XRAY_CLIENT_SECRET` | Yes | - | Xray Cloud API client secret | -| `XRAY_PROJECT_KEY` | No | `SAND` | Jira project key for Xray tests | -| `TARGET_ENV` | Yes | `qa1` | Test environment | -| `TEST_EXECUTION_KEY` | No | `none` | Link to existing test execution (or 'none' to auto-create) | -| `TEST_TAGS` | No | - | Filter tests by tags | - -### File Locations - -| File | Purpose | -|------|---------| -| `test-results/last-run.json` | Playwright JSON results (source data) | -| `test-results/xray-execution.json` | Converted Xray JSON format (debug) | -| `playwright-report/` | HTML test report | - -## Troubleshooting - -### Common Issues - -1. **No upload happening** - - Check `XRAY_CLIENT_ID` and `XRAY_CLIENT_SECRET` are set - - Verify `TEST_EXECUTION_KEY` is set and not 'none' - - Check console output for authentication errors - -2. **Tests not appearing in correct project** - - Verify `XRAY_PROJECT_KEY` is set to correct project - - Ensure project key is uppercase (e.g., 'SAND', not 'sand') - -3. **"Result is not valid Xray Format" error** - - Check `test-results/xray-execution.json` for the actual payload - - Verify `testExecutionKey` is at root level (not inside `info`) - - Ensure status values are `PASSED`/`FAILED` (not `PASS`/`FAIL`) - -4. **"environments dont exist" or "Version name not valid" errors** - - These occur when `testEnvironments` or `version` don't match Jira project config - - When linking to existing executions, these fields are automatically omitted - -5. **Steps showing as TODO instead of PASSED** - - Verify status values use Xray Cloud format: `PASSED`, `FAILED`, `TODO` - - Xray Server uses `PASS`/`FAIL` but Cloud uses `PASSED`/`FAILED` - -### Debug Information - -- Check console output during test run for upload status -- Review `test-results/xray-execution.json` for the converted payload -- Check CircleCI build logs for upload details - -## API Reference - -### Xray Cloud Endpoints Used - -1. **Authentication** - - Endpoint: `POST https://xray.cloud.getxray.app/api/v1/authenticate` - - Input: `{ client_id, client_secret }` - - Output: Token string - -2. **Import Execution Results** - - Endpoint: `POST https://xray.cloud.getxray.app/api/v2/import/execution` - - Auth: Bearer token - - Input: Xray JSON format - - Output: Test execution details - -## Support - -For issues or questions: -- Check this documentation first -- Review CircleCI build logs -- Inspect `test-results/xray-execution.json` for payload details -- Verify environment variables are set correctly From 9a3a300d6601eb4f721b6ff23d4667fdc6a68932 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Fri, 13 Feb 2026 17:33:19 -0500 Subject: [PATCH 42/60] Linting Fixes: Add retry naming and remove duplicate stepCounter Rename capture variables in claimed-profile-edit-fullname.spec.ts to use a `retry` prefix and update the step name to indicate a retry; adjust log output to reference the renamed variables. Remove a duplicate `stepCounterObj` declaration in network-helpers.ts. These edits improve clarity of the retry validation flow and eliminate a redundant variable declaration. --- .../claimed-profile-edit-fullname.spec.ts | 26 +++++++++---------- tests/fixtures/network-helpers.ts | 1 - 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts b/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts index 51ed1b6..6c68e70 100644 --- a/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts +++ b/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts @@ -145,35 +145,35 @@ test.describe('Claimed Account Settings edit (Full Name only) updates Profile en getCapture.responseBody.fullName !== putCapture.requestBody.fullName ) { await (test as any).stepNoScreenshot( - 'Then GET request matches the saved PUT request', + 'Then GET request matches the saved PUT request (retry)', async () => { await api.validateEndpointResponse('profile-metadata-get'); // Get all captures and find the LATEST GET request (after the PUT) - const allCaptures = api.getCaptures(); - const putIndex = allCaptures.findIndex(req => req === putCapture); + const retryAllCaptures = api.getCaptures(); + const retryPutIndex = retryAllCaptures.findIndex(req => req === putCapture); // Find GET requests that occurred AFTER the PUT request - const laterGetCaptures = allCaptures - .slice(putIndex + 1) + const retryGetCaptures = retryAllCaptures + .slice(retryPutIndex + 1) .filter((req: any) => req.method === 'GET' && req.url.includes('/profile')); - if (laterGetCaptures.length === 0) { + if (retryGetCaptures.length === 0) { throw new Error('No GET /profile request captured after the PUT request'); } // Use the most recent GET request - const getCapture = laterGetCaptures[laterGetCaptures.length - 1]; + const retryGetCapture = retryGetCaptures[retryGetCaptures.length - 1]; if ( - !getCapture.responseBody || - getCapture.responseBody.fullName !== putCapture.requestBody.fullName + !retryGetCapture.responseBody || + retryGetCapture.responseBody.fullName !== putCapture.requestBody.fullName ) { - console.log('GET response fullName:', getCapture.responseBody.fullName); + console.log('GET response fullName:', retryGetCapture.responseBody.fullName); console.log('PUT request fullName:', putCapture.requestBody.fullName); - console.log('Total captures:', allCaptures.length); - console.log('PUT index:', putIndex); - console.log('Later GET captures found:', laterGetCaptures.length); + console.log('Total captures:', retryAllCaptures.length); + console.log('PUT index:', retryPutIndex); + console.log('Later GET captures found:', retryGetCaptures.length); throw new Error('GET response fullName does not match PUT request fullName'); } }, diff --git a/tests/fixtures/network-helpers.ts b/tests/fixtures/network-helpers.ts index 4617f22..64b52b3 100644 --- a/tests/fixtures/network-helpers.ts +++ b/tests/fixtures/network-helpers.ts @@ -284,7 +284,6 @@ export class NetworkHelper { if (request?.responseBody) { // Access the shared step counter from the stepScreenshoter fixture const stepCounterObj = (globalThis as any).stepCounter; - const stepCounterObj = (globalThis as any).stepCounter; if (stepCounterObj) { const stepNumber = stepCounterObj.increment(); const currentStepName = stepCounterObj.getCurrentStepName(); From 96952d32c8ab6ef7393ad3dddd8f09da4be7c0e6 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Fri, 13 Feb 2026 17:57:03 -0500 Subject: [PATCH 43/60] Skip execution info when reusing testExecKey If an existing testExecutionKey is provided (e.g., for sharded CI runs), omit the info block to avoid overwriting the existing execution's summary/description and counts. When no key is present, preserve the previous behavior of populating summary, description, startDate, and finishDate. Added a clarifying comment for the intent. --- utilities/xray-json-reporter.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/utilities/xray-json-reporter.ts b/utilities/xray-json-reporter.ts index cd2dba2..44ec3fa 100644 --- a/utilities/xray-json-reporter.ts +++ b/utilities/xray-json-reporter.ts @@ -354,17 +354,21 @@ class XrayJsonReporter { const hasExistingExecution = testExecKey && testExecKey !== 'none' && testExecKey.trim() !== ''; + // When linking to an existing execution (e.g., sharded CI runs), skip info to avoid + // overwriting the execution description with partial per-shard counts. return { testExecutionKey: hasExistingExecution ? testExecKey : undefined, - info: { - summary: `Playwright Test Execution - ${new Date().toISOString()}`, - description: `Automated test execution for ${targetEnv} environment\n\nResults: ${passedCount} passed, ${failedCount} failed, ${todoCount} skipped`, - startDate: playwrightResult.stats?.startTime || new Date().toISOString(), - finishDate: new Date( - new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + - (playwrightResult.stats?.duration || 0), - ).toISOString(), - }, + info: hasExistingExecution + ? undefined + : { + summary: `Playwright Test Execution - ${new Date().toISOString()}`, + description: `Automated test execution for ${targetEnv} environment\n\nResults: ${passedCount} passed, ${failedCount} failed, ${todoCount} skipped`, + startDate: playwrightResult.stats?.startTime || new Date().toISOString(), + finishDate: new Date( + new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + + (playwrightResult.stats?.duration || 0), + ).toISOString(), + }, tests, }; } From e6c6a244b745a796786b0e4c3915d1d511527834 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 23 Feb 2026 13:40:18 -0500 Subject: [PATCH 44/60] Have automated tests upload to QAE project unless otherwise specified Have automated tests upload to QAE project unless otherwise specified in env --- .circleci/config.yml | 2 -- utilities/xray-json-reporter.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 97101e1..95b2ba8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -52,7 +52,6 @@ jobs: echo "export TARGET_ENV=\"${TARGET_ENV:-<< pipeline.parameters.testEnvironment >>}\"" >> $BASH_ENV echo "export TEST_EXECUTION_KEY=\"${TEST_EXECUTION_KEY:-<< pipeline.parameters.testExecKey >>}\"" >> $BASH_ENV echo "export TEST_TAGS=\"${TEST_TAGS:-<< pipeline.parameters.testTags >>}\"" >> $BASH_ENV - echo "export XRAY_PROJECT_KEY=\"${XRAY_PROJECT_KEY:-<< pipeline.parameters.xrayProjectKey >>}\"" >> $BASH_ENV - restore_cache: keys: - dependency-cache-{{ checksum "package.json" }} @@ -163,7 +162,6 @@ jobs: echo "export TARGET_ENV=\"${TARGET_ENV:-<< pipeline.parameters.testEnvironment >>}\"" >> $BASH_ENV echo "export TEST_EXECUTION_KEY=\"${TEST_EXECUTION_KEY:-<< pipeline.parameters.testExecKey >>}\"" >> $BASH_ENV echo "export TEST_TAGS=\"${TEST_TAGS:-<< pipeline.parameters.testTags >>}\"" >> $BASH_ENV - echo "export XRAY_PROJECT_KEY=\"${XRAY_PROJECT_KEY:-<< pipeline.parameters.xrayProjectKey >>}\"" >> $BASH_ENV - restore_cache: keys: - dependency-cache-{{ checksum "package.json" }} diff --git a/utilities/xray-json-reporter.ts b/utilities/xray-json-reporter.ts index 1a5d60e..09a4ab4 100644 --- a/utilities/xray-json-reporter.ts +++ b/utilities/xray-json-reporter.ts @@ -322,7 +322,7 @@ class XrayJsonReporter { testInfo: { summary: testCase.title, type: 'Manual', - projectKey: env.XRAY_PROJECT_KEY || 'SAND', + projectKey: env.XRAY_PROJECT_KEY || 'QAE', steps: stepDefinitions.length > 0 ? stepDefinitions : undefined, }, status: this.getTestStatus(testStatus), From d38ecd5ed339bca723aa0447b139d413941add0e Mon Sep 17 00:00:00 2001 From: Foscottl-TP Date: Tue, 24 Feb 2026 09:23:40 -0800 Subject: [PATCH 45/60] Remove build folder from tracking - now properly ignored --- build/endpoint-schema/auth-endpoints.js | 53 -- build/endpoint-schema/endpoint-registry.js | 52 -- .../endpoint-schema/patient-data-endpoints.js | 56 -- build/endpoint-schema/profile-endpoints.js | 107 ---- build/page-objects/LoginPage.js | 44 -- .../page-objects/account/AccountNavigation.js | 62 --- .../account/AccountSettingsPage.js | 13 - .../clinician/ClinicCreationPage.js | 84 --- .../clinician/ClinicianDashboardPage.js | 79 --- .../clinician/ClinicianNavigation.js | 119 ----- .../clinician/WorkspaceSettingsPage.js | 29 -- .../page-objects/clinician/WorkspacesPage.js | 36 -- .../components/navigation-menu.section.js | 27 - .../components/navigation.section.js | 22 - build/page-objects/patient/BasicsPage.js | 143 ------ build/page-objects/patient/DailyPage.js | 17 - .../page-objects/patient/PatientNavigation.js | 100 ---- build/page-objects/patient/ProfilePage.js | 115 ----- .../patient/components/daily-chart.js | 14 - build/playwright.config.js | 106 ---- .../claimed-profile-edit-fullname.spec.js | 148 ------ .../comprehensive-profile-access-test.spec.js | 159 ------ .../API-User/claimed-email-edit.spec.js | 95 ---- .../edit-custodial-profile-API.spec.js | 91 ---- build/tests/clinician/add-patient.spec.js | 38 -- .../clinician/create-clinic-workspace.spec.js | 86 ---- .../clinician/edit-clinic-address.spec.js | 47 -- build/tests/clinician/filter-patient.spec.js | 70 --- build/tests/fixtures/account-helpers.js | 123 ----- build/tests/fixtures/base.js | 262 ---------- build/tests/fixtures/clinic-helpers.js | 280 ---------- build/tests/fixtures/network-helpers.js | 480 ----------------- build/tests/fixtures/patient-helpers.js | 484 ------------------ build/tests/fixtures/test-tags.js | 98 ---- build/tests/global-setup.js | 47 -- .../edit-personal-profile-API.spec.js | 75 --- .../personal/basic-functionality.spec.js | 240 --------- build/tests/personal/login.spec.js | 95 ---- build/utilities/annotations.js | 24 - build/utilities/env.js | 46 -- build/utilities/xray-json-reporter.js | 473 ----------------- build/utilities/xray-reporter.js | 134 ----- 42 files changed, 4873 deletions(-) delete mode 100644 build/endpoint-schema/auth-endpoints.js delete mode 100644 build/endpoint-schema/endpoint-registry.js delete mode 100644 build/endpoint-schema/patient-data-endpoints.js delete mode 100644 build/endpoint-schema/profile-endpoints.js delete mode 100644 build/page-objects/LoginPage.js delete mode 100644 build/page-objects/account/AccountNavigation.js delete mode 100644 build/page-objects/account/AccountSettingsPage.js delete mode 100644 build/page-objects/clinician/ClinicCreationPage.js delete mode 100644 build/page-objects/clinician/ClinicianDashboardPage.js delete mode 100644 build/page-objects/clinician/ClinicianNavigation.js delete mode 100644 build/page-objects/clinician/WorkspaceSettingsPage.js delete mode 100644 build/page-objects/clinician/WorkspacesPage.js delete mode 100644 build/page-objects/clinician/components/navigation-menu.section.js delete mode 100644 build/page-objects/clinician/components/navigation.section.js delete mode 100644 build/page-objects/patient/BasicsPage.js delete mode 100644 build/page-objects/patient/DailyPage.js delete mode 100644 build/page-objects/patient/PatientNavigation.js delete mode 100644 build/page-objects/patient/ProfilePage.js delete mode 100644 build/page-objects/patient/components/daily-chart.js delete mode 100644 build/playwright.config.js delete mode 100644 build/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js delete mode 100644 build/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js delete mode 100644 build/tests/claimed/API-User/claimed-email-edit.spec.js delete mode 100644 build/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js delete mode 100644 build/tests/clinician/add-patient.spec.js delete mode 100644 build/tests/clinician/create-clinic-workspace.spec.js delete mode 100644 build/tests/clinician/edit-clinic-address.spec.js delete mode 100644 build/tests/clinician/filter-patient.spec.js delete mode 100644 build/tests/fixtures/account-helpers.js delete mode 100644 build/tests/fixtures/base.js delete mode 100644 build/tests/fixtures/clinic-helpers.js delete mode 100644 build/tests/fixtures/network-helpers.js delete mode 100644 build/tests/fixtures/patient-helpers.js delete mode 100644 build/tests/fixtures/test-tags.js delete mode 100644 build/tests/global-setup.js delete mode 100644 build/tests/personal/AP-Profile/edit-personal-profile-API.spec.js delete mode 100644 build/tests/personal/basic-functionality.spec.js delete mode 100644 build/tests/personal/login.spec.js delete mode 100644 build/utilities/annotations.js delete mode 100644 build/utilities/env.js delete mode 100644 build/utilities/xray-json-reporter.js delete mode 100644 build/utilities/xray-reporter.js diff --git a/build/endpoint-schema/auth-endpoints.js b/build/endpoint-schema/auth-endpoints.js deleted file mode 100644 index aa3c6ec..0000000 --- a/build/endpoint-schema/auth-endpoints.js +++ /dev/null @@ -1,53 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.refreshTokenSchema = exports.logoutSchema = exports.loginSchema = void 0; -/** - * Schema for user authentication login - */ -exports.loginSchema = { - url: /\/auth\/login$/, - method: 'POST', - expectedStatus: 200, - requestSchema: { - username: 'string', - password: 'string', - }, - responseSchema: { - userid: 'string', - username: 'string', - emails: 'object', - roles: 'object', - }, - validationFields: ['userid', 'username', 'emails', 'roles'], - requiredFields: [ - 'userid', // Auth endpoints require userid instead of fullName - 'username', // Username is also critical for auth - ], -}; -/** - * Schema for user logout - */ -exports.logoutSchema = { - url: /\/auth\/logout$/, - method: 'POST', - expectedStatus: 200, - validationFields: [ - // Logout typically doesn't return data to validate - ], -}; -/** - * Schema for token refresh - */ -exports.refreshTokenSchema = { - url: /\/auth\/token$/, - method: 'POST', - expectedStatus: 200, - responseSchema: { - userid: 'string', - username: 'string', - }, - validationFields: ['userid', 'username'], - requiredFields: [ - 'userid', // Token refresh must return userid - ], -}; diff --git a/build/endpoint-schema/endpoint-registry.js b/build/endpoint-schema/endpoint-registry.js deleted file mode 100644 index d608347..0000000 --- a/build/endpoint-schema/endpoint-registry.js +++ /dev/null @@ -1,52 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.ENDPOINT_REGISTRY = void 0; -exports.getEndpointSchema = getEndpointSchema; -const profile_endpoints_1 = require("./profile-endpoints"); -const patient_data_endpoints_1 = require("./patient-data-endpoints"); -const auth_endpoints_1 = require("./auth-endpoints"); -// Import other endpoint schemas as they're created -/** - * Centralized endpoint registry for all API validation - * This allows network helpers to work with any endpoint by name - * - * ADDING NEW ENDPOINTS: - * 1. Define the endpoint schema in the appropriate *-endpoints.ts file - * 2. Include validationFields array for data consistency checking - * 3. Add the endpoint to this registry - * 4. The validationFields will automatically be used by NetworkHelper methods - * - * VALIDATION FIELDS: - * - Use dot notation for nested fields (e.g., 'patient.fullName') - * - Include all fields that should be validated for data consistency - * - Different endpoints can have different validation requirements - * - Fields are endpoint-specific and stored in the schema definition - */ -exports.ENDPOINT_REGISTRY = { - // Profile endpoints - 'profile-metadata-get': profile_endpoints_1.getProfileMetadataSchema, - 'profile-metadata-put': profile_endpoints_1.putProfileMetadataSchema, - 'profile-patient-data-get': profile_endpoints_1.getPatientDataSchema, - 'profile-metrics-get': profile_endpoints_1.getMetricsSchema, - 'profile-message-notes-get': profile_endpoints_1.getMessageNotesSchema, - // Patient data endpoints - 'patient-data-get': patient_data_endpoints_1.getPatientDataSchema, - 'patient-data-upload': patient_data_endpoints_1.uploadPatientDataSchema, - // Auth endpoints - 'auth-login': auth_endpoints_1.loginSchema, - 'auth-logout': auth_endpoints_1.logoutSchema, - 'auth-refresh-token': auth_endpoints_1.refreshTokenSchema, - // Add more endpoints as needed... - // 'clinic-get': clinicGetSchema, - // 'clinic-update': clinicUpdateSchema, -}; -/** - * Get endpoint schema by name - */ -function getEndpointSchema(endpointName) { - const schema = exports.ENDPOINT_REGISTRY[endpointName]; - if (!schema) { - throw new Error(`Endpoint schema not found: ${endpointName}`); - } - return schema; -} diff --git a/build/endpoint-schema/patient-data-endpoints.js b/build/endpoint-schema/patient-data-endpoints.js deleted file mode 100644 index 2443fb0..0000000 --- a/build/endpoint-schema/patient-data-endpoints.js +++ /dev/null @@ -1,56 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.getPatientSettingsSchema = exports.uploadPatientDataSchema = exports.getPatientDataSchema = void 0; -/** - * Schema for patient data GET endpoint - */ -exports.getPatientDataSchema = { - url: /\/v1\/patients\/[^/]+\/data$/, - method: 'GET', - expectedStatus: 200, - responseSchema: { - data: 'object', - meta: { - count: 'number', - size: 'number', - }, - }, - validationFields: ['data', 'meta.count', 'meta.size'], -}; -/** - * Schema for uploading patient data - */ -exports.uploadPatientDataSchema = { - url: /\/v1\/patients\/[^/]+\/data$/, - method: 'POST', - expectedStatus: 201, - requestSchema: { - data: 'object', - deviceId: 'string', - uploadId: 'string', - }, - responseSchema: { - id: 'string', - success: 'boolean', - }, - validationFields: ['id', 'success'], -}; -/** - * Schema for getting patient settings - */ -exports.getPatientSettingsSchema = { - url: /\/v1\/patients\/[^/]+\/settings$/, - method: 'GET', - expectedStatus: 200, - responseSchema: { - bgTarget: { - low: 'number', - high: 'number', - }, - units: { - bg: 'string', - }, - siteChangeSource: 'string', - }, - validationFields: ['bgTarget.low', 'bgTarget.high', 'units.bg', 'siteChangeSource'], -}; diff --git a/build/endpoint-schema/profile-endpoints.js b/build/endpoint-schema/profile-endpoints.js deleted file mode 100644 index 0605a5b..0000000 --- a/build/endpoint-schema/profile-endpoints.js +++ /dev/null @@ -1,107 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.getMessageNotesSchema = exports.getMetricsSchema = exports.getPatientDataSchema = exports.putProfileMetadataSchema = exports.getProfileMetadataSchema = void 0; -/** - * Schema for profile metadata GET endpoint - */ -exports.getProfileMetadataSchema = { - url: /\/metadata\/.*\/profile$/, - method: 'GET', - expectedStatus: 200, - responseSchema: { - fullName: 'string', - patient: 'object', - }, - validationFields: [ - 'fullName', - 'patient.fullName', - 'patient.birthday', - 'patient.diagnosisDate', - 'patient.diagnosisType', - 'patient.targetDevices', - 'patient.targetTimezone', - 'patient.about', - 'patient.isOtherPerson', - 'patient.mrn', - 'patient.biologicalSex', - 'email', - 'patient.email', - 'patient.emails', - 'emails', - ], - requiredFields: [ - 'fullName', // Profile endpoint must have fullName - ], -}; -/** - * Schema for profile metadata PUT endpoint - */ -exports.putProfileMetadataSchema = { - url: /\/metadata\/.*\/profile$/, - method: 'PUT', - expectedStatus: 200, - requestSchema: { - fullName: 'string', - patient: 'object', - }, - responseSchema: { - fullName: 'string', - patient: 'object', - }, - validationFields: [ - 'fullName', - 'patient.fullName', - 'patient.birthday', - 'patient.diagnosisDate', - 'patient.diagnosisType', - 'patient.targetDevices', - 'patient.targetTimezone', - 'patient.about', - 'patient.isOtherPerson', - 'patient.mrn', - 'patient.biologicalSex', - 'email', - 'patient.email', - 'patient.emails', - 'emails', - ], - requiredFields: [ - 'fullName', // Profile endpoint must have fullName - ], -}; -/** - * Schema for patient data GET endpoint - */ -exports.getPatientDataSchema = { - url: /\/data\/[^/]+\?.*$/, - method: 'GET', - expectedStatus: 200, - responseSchema: { - // Patient data array - structure will vary - }, - validationFields: [ - // Data array validation fields would go here based on specific data types - ], -}; -/** - * Schema for metrics/analytics endpoint - */ -exports.getMetricsSchema = { - url: /\/metrics\/thisuser\/.*$/, - method: 'GET', - expectedStatus: 200, - validationFields: [ - // Metrics-specific validation fields would go here - ], -}; -/** - * Schema for message notes endpoint - */ -exports.getMessageNotesSchema = { - url: /\/message\/notes\/[^/]+\?.*$/, - method: 'GET', - expectedStatus: 200, // We'll handle 404 as acceptable in the validation logic - validationFields: [ - // Message notes validation fields would go here - ], -}; diff --git a/build/page-objects/LoginPage.js b/build/page-objects/LoginPage.js deleted file mode 100644 index bf30499..0000000 --- a/build/page-objects/LoginPage.js +++ /dev/null @@ -1,44 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -/** - * @class - * @property {Page} page - * @property {Locator} emailInput - * @property {Locator} nextButton - * @property {Locator} passwordInput - * @property {Locator} loginButton - */ -class LoginPage { - /** - * @param {Page} page - */ - constructor(page) { - this.page = page; - this.emailInput = page.getByRole('textbox', { name: 'Email' }); - this.nextButton = page.getByRole('button', { name: 'Next' }); - this.passwordInput = page.getByRole('textbox', { name: 'Password' }); - this.loginButton = page.getByRole('button', { name: 'Log In' }); - } - /** - * Navigate to the login page - * @returns {Promise} - */ - async goto() { - await this.page.goto(`/`); - } - /** - * Login to the application - * @param {string} email - User's email - * @param {string} password - User's password - * @returns {Promise} - */ - // @step("When the user logs in to the application") - async login(email, password) { - await this.emailInput.fill(email); - await this.nextButton.click(); - await this.passwordInput.fill(password); - await this.loginButton.click(); - await this.page.setViewportSize({ width: 1920, height: 1080 }); - } -} -exports.default = LoginPage; diff --git a/build/page-objects/account/AccountNavigation.js b/build/page-objects/account/AccountNavigation.js deleted file mode 100644 index bfc75bc..0000000 --- a/build/page-objects/account/AccountNavigation.js +++ /dev/null @@ -1,62 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -class AccountNav { - constructor(page) { - this.page = page; - this.pages = { - AccountNav: { - name: 'AccountNav', - link: page.locator('button#navigation-menu-trigger'), // Use exact ID to identify menu trigger - verifyURL: '', - verifyElement: page - .locator('button.navigation-menu-option') - .filter({ hasText: 'Private Workspace' }), - }, - PrivateWorkspace: { - name: 'PrivateWorkspace', - link: page - .locator('button.navigation-menu-option') - .filter({ hasText: 'Private Workspace' }), - verifyURL: 'workspaces', - verifyElement: page.getByText('View data for:'), - }, - AccountSettings: { - name: 'AccountSettings', - link: page - .locator('#navigationMenu button.navigation-menu-option') - .filter({ hasText: 'Account Settings' }), - verifyURL: 'account', - verifyElement: page.locator('.profile-subnav-title').getByText('Account'), // Target the specific Account title element - }, - ManageWorkspaces: { - name: 'ManageWorkspaces', - link: page - .locator('#navigationMenu button.navigation-menu-option') - .filter({ hasText: 'Manage Workspaces' }), - verifyURL: 'workspaces', - verifyElement: page.getByText('Welcome To Tidepool'), // Should land back on the workspace selection page - }, - Logout: { - name: 'Logout', - link: page - .locator('#navigationMenu button.navigation-menu-option') - .filter({ hasText: 'Logout' }), - verifyURL: 'login', - verifyElement: page.getByRole('heading', { name: 'Log in to Tidepool' }), - }, - }; - } - /** - * Navigate to a page in the account navigation menu by key. - * Example: await accountNav.navigateTo('AccountSettings'); - */ - async navigateTo(pageKey) { - // Always open the navigation menu first - await this.pages.AccountNav.link.click(); - // Then click the desired page - await this.pages[pageKey].link.click(); - // Wait for the verification element to appear - await this.pages[pageKey].verifyElement.waitFor({ state: 'visible', timeout: 5000 }); - } -} -exports.default = AccountNav; diff --git a/build/page-objects/account/AccountSettingsPage.js b/build/page-objects/account/AccountSettingsPage.js deleted file mode 100644 index a3d10e5..0000000 --- a/build/page-objects/account/AccountSettingsPage.js +++ /dev/null @@ -1,13 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.AccountSettingsPage = void 0; -class AccountSettingsPage { - constructor(page) { - this.page = page; - this.emailInput = page.getByRole('textbox', { name: 'Email' }); - this.saveButton = page.getByRole('button', { name: /save/i }); - this.saveConfirm = page.getByText(/All Changes Saved/i); - } -} -exports.AccountSettingsPage = AccountSettingsPage; -exports.default = AccountSettingsPage; diff --git a/build/page-objects/clinician/ClinicCreationPage.js b/build/page-objects/clinician/ClinicCreationPage.js deleted file mode 100644 index e162e1b..0000000 --- a/build/page-objects/clinician/ClinicCreationPage.js +++ /dev/null @@ -1,84 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -class ClinicCreationPage { - constructor(page) { - this.url = '/clinic-details/new'; - this.page = page; - // Page header elements - this.pageHeader = page.getByText('Create your Clinic Workspace'); - this.pageDescription = page.getByText('The information below will be displayed along with your name'); - // Form input fields - this.clinicNameInput = page.getByLabel('Clinic Name'); - this.teamTypeDropdown = page.getByRole('combobox', { name: 'What best describes your team?' }); - this.countryDropdown = page.getByRole('combobox', { name: 'Country' }); - this.stateDropdown = page.getByRole('combobox', { name: 'State' }); - this.addressInput = page.getByLabel('Address'); - this.cityInput = page.getByLabel('City'); - this.zipCodeInput = page.getByLabel('Zip code'); - this.websiteInput = page.getByLabel('Website (optional)'); - // Blood glucose units radio buttons - this.mgdlRadio = page.getByLabel('mg/dL'); - this.mmolRadio = page.getByLabel('mmol/L'); - // Acknowledgement checkbox - this.adminAcknowledgeCheckbox = page.getByRole('checkbox', { - name: 'By creating this clinic, your Tidepool account will become the default administrator', - }); - // Action buttons - this.backButton = page.getByRole('button', { name: 'Back' }); - this.createWorkspaceButton = page.getByRole('button', { name: 'Create Workspace' }); - } - /** - * Navigate to the clinic creation page - */ - async goto() { - await this.page.goto(this.url); - } - /** - * Fill the clinic creation form with required information - * @param clinicName - Name of the clinic - * @param teamType - Type of the team - * @param state - State (for US clinics) - * @param address - Street address - * @param city - City name - * @param zipCode - Zip/Postal code - * @param website - Optional website URL - */ - async fillClinicForm({ clinicName, teamType = 'Provider Practice', state = 'California', address = '123 Test Street', city = 'Test City', zipCode = '12345', website = '', }) { - // Fill in clinic name - await this.clinicNameInput.fill(clinicName); - // Select team type - await this.teamTypeDropdown.selectOption(teamType); - // Select state (US is selected by default) - await this.stateDropdown.selectOption(state); - // Fill in address details - await this.addressInput.fill(address); - await this.cityInput.fill(city); - await this.zipCodeInput.fill(zipCode); - // Fill in optional website if provided - if (website) { - await this.websiteInput.fill(website); - } - } - /** - * Select blood glucose units - * @param unit - "mg/dL" or "mmol/L" - */ - async selectBloodGlucoseUnit(unit) { - if (unit === 'mg/dL') { - await this.mgdlRadio.check(); - } - else { - await this.mmolRadio.check(); - } - } - /** - * Create a clinic by filling the form and submitting - * @param clinicName - Name of the clinic to create (required) - * @param formData - Optional form data (uses defaults if not provided) - */ - async createClinic(clinicName, formData) { - await this.fillClinicForm({ clinicName, ...formData }); - await this.createWorkspaceButton.click(); - } -} -exports.default = ClinicCreationPage; diff --git a/build/page-objects/clinician/ClinicianDashboardPage.js b/build/page-objects/clinician/ClinicianDashboardPage.js deleted file mode 100644 index 01edc05..0000000 --- a/build/page-objects/clinician/ClinicianDashboardPage.js +++ /dev/null @@ -1,79 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -class ClinicianDashboardPage { - constructor(page) { - this.url = '/clinic-workspace'; - this.name = 'ClinicianDashboardPage'; // Added name for step decorator context - this.page = page; - // Main page locators - this.addNewPatientButton = page.getByRole('button', { name: 'Add New Patient' }); - this.searchInput = page.getByRole('textbox', { name: 'Search' }); - this.patientListTable = page.getByRole('table', { name: 'peopletablelabel' }); - // Add Patient Dialog locators - this.addPatientDialog = page.getByRole('dialog', { name: /Add New Patient Account/i }); - this.addPatientDialog_fullNameInput = this.addPatientDialog.getByRole('textbox', { - name: 'Full Name', - }); - this.addPatientDialog_birthdateInput = this.addPatientDialog.getByRole('textbox', { - name: 'Birthdate', - }); - this.addPatientDialog_addButton = this.addPatientDialog.getByRole('button', { - name: 'Add Patient', - }); - // Bring Data Dialog locators - this.bringDataDialog = page.getByRole('dialog', { name: /Bring Data into Tidepool/i }); - this.bringDataDialog_doneButton = this.bringDataDialog.getByRole('button', { name: 'Done' }); - } - /** - * Opens the Add Patient dialog and fills in the patient details. - * @param name - The full name of the patient. - * @param birthdate - The birthdate of the patient (e.g., MM/DD/YYYY). - */ - async openAndFillAddPatientDialog(name, birthdate) { - await this.addNewPatientButton.click(); - await this.addPatientDialog.waitFor({ state: 'visible' }); - await this.addPatientDialog_fullNameInput.fill(name); - await this.addPatientDialog_birthdateInput.fill(birthdate); - } - /** - * Clicks the Add Patient button in the dialog to submit the new patient. - */ - async submitAddPatientDialog() { - await this.addPatientDialog_addButton.click(); - } - /** - * Closes the Bring Data into Tidepool dialog by clicking Done. - */ - async closeBringDataDialog() { - await this.bringDataDialog.waitFor({ state: 'visible' }); - await this.bringDataDialog_doneButton.click(); - await this.bringDataDialog.waitFor({ state: 'hidden' }); - } - /** - * Searches for a patient in the list. - * @param name - The name of the patient to search for. - */ - async searchForPatient(name) { - await this.searchInput.fill(name); - // Press Enter to trigger search - await this.searchInput.press('Enter'); - // Wait longer for search to process and results to load - await this.page.waitForTimeout(3000); - } - /** - * Gets the locator for a patient cell in the table by name. - * @param name - The name of the patient. - * @returns Locator for the table cell containing the patient's name. - */ - getPatientCellByName(name) { - // Use exact match to avoid multiple matches with similar names - return this.patientListTable.getByRole('cell', { name, exact: true }); - } - /** - * Waits for the main elements of the Clinic Workspace page to be visible. - */ - async waitForLoadState() { - await this.addNewPatientButton.waitFor({ state: 'visible' }); - } -} -exports.default = ClinicianDashboardPage; diff --git a/build/page-objects/clinician/ClinicianNavigation.js b/build/page-objects/clinician/ClinicianNavigation.js deleted file mode 100644 index 7cabb9b..0000000 --- a/build/page-objects/clinician/ClinicianNavigation.js +++ /dev/null @@ -1,119 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -class ClinicianNav { - constructor(page) { - this.page = page; - // Define hardcoded workspace configurations (matching PatientNavigation approach) - this.workspaces = { - AdminClinicBase: { - name: 'Admin Clinic (Base)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Admin Clinic (Base) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Admin Clinic (Base)' }), - }, - AdminClinicEnterprise: { - name: 'Admin Clinic (Enterprise)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Admin Clinic (Enterprise) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Admin Clinic (Enterprise)' }), - }, - MemberClinicBase: { - name: 'Member Clinic (Base)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Member Clinic (Base) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Member Clinic (Base)' }), - }, - MemberClinicEnterprise: { - name: 'Member Clinic (Enterprise)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Member Clinic (Enterprise) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Member Clinic (Enterprise)' }), - }, - NonMemberClinicBase: { - name: 'Non-Member Clinic (Base)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Non-Member Clinic (Base) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Non-Member Clinic (Base)' }), - }, - NonMemberClinicEnterprise: { - name: 'Non-Member Clinic (Enterprise)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Non-Member Clinic (Enterprise) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Non-Member Clinic (Enterprise)' }), - }, - PartnerClinicBase: { - name: 'Partner Clinic (Base)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Partner Clinic (Base) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Partner Clinic (Base)' }), - }, - PartnerClinicEnterprise: { - name: 'Partner Clinic (Enterprise)', - link: page - .locator('#navigationMenu button') - .filter({ hasText: 'Partner Clinic (Enterprise) Workspace' }), - verifyURL: 'clinic-workspace', - verifyElement: page.locator('h4').filter({ hasText: 'Partner Clinic (Enterprise)' }), - }, - }; - // Define clinician page navigation (matching PatientNavigation format) - this.pages = { - PatientList: { - name: 'PatientList', - link: page.getByRole('link', { name: 'Patients' }), - verifyURL: 'clinic-workspace/patients', - verifyElement: page.getByRole('heading', { name: 'Patients' }), - }, - WorkspaceSettings: { - name: 'WorkspaceSettings', - link: page.getByRole('link', { name: 'Workspace Settings' }), - verifyURL: 'clinic-workspace/workspace/settings', - verifyElement: page.getByRole('heading', { name: 'Workspace Settings' }), - }, - AddPatient: { - name: 'AddPatient', - link: page.getByRole('button', { name: 'Add Patient' }), - verifyURL: 'clinic-workspace/patients/add', - verifyElement: page.getByRole('heading', { name: 'Add Patient' }), - }, - Profile: { - name: 'Profile', - link: page - .getByRole('button', { name: 'Patient Profile Profile' }) - .or(page.getByRole('tab', { name: 'Profile' })) - .or(page.getByRole('link', { name: 'Profile' })) - .or(page.getByRole('button', { name: 'Profile' })), - verifyURL: 'profile', - verifyElement: page - .getByRole('button', { name: 'Edit' }) - .or(page.getByRole('button', { name: 'Edit Profile' })), - }, - ProfileEdit: { - name: 'ProfileEdit', - link: page - .getByRole('button', { name: 'Edit' }) - .or(page.getByRole('button', { name: 'Edit Profile' })), - verifyURL: 'profile', - verifyElement: page - .getByRole('button', { name: 'Save changes' }) - .or(page.getByRole('button', { name: 'Save Profile' })) - .or(page.getByRole('button', { name: 'Save' })), - }, - }; - } -} -exports.default = ClinicianNav; diff --git a/build/page-objects/clinician/WorkspaceSettingsPage.js b/build/page-objects/clinician/WorkspaceSettingsPage.js deleted file mode 100644 index 2dffe7a..0000000 --- a/build/page-objects/clinician/WorkspaceSettingsPage.js +++ /dev/null @@ -1,29 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -class ClinicAdminPage { - constructor(page) { - this.url = '/clinic-admin'; - this.name = 'ClinicAdminPage'; // Added name for step decorator context - this.page = page; - this.clinicDetailsHeader = page.getByText('Workspace Settings'); - // Assuming the edit button is specifically associated with the details section - this.editDetailsButton = page.getByRole('button', { name: 'Edit' }); - this.editClinicModal = page.getByRole('dialog'); // General dialog selector - this.editClinicModalTitle = this.editClinicModal.getByRole('heading', { - name: 'Edit Workspace Details', - }); - this.addressInput = this.editClinicModal.getByLabel('Address', { exact: true }); // Use exact label match - this.saveChangesButton = this.editClinicModal.getByRole('button', { name: 'Save Changes' }); - // Assuming the details are within a specific container section related to the header - this.clinicDetailsSection = page.locator('div:has(> span:text-is("Workspace Settings")) + div'); - } - /** - * Waits for essential elements of the Clinic Admin page to be loaded. - */ - async waitForLoadState() { - await this.page.waitForLoadState(); // Wait for base elements like header/footer - await this.clinicDetailsHeader.waitFor({ state: 'visible', timeout: 40000 }); - await this.editDetailsButton.waitFor({ state: 'visible', timeout: 10000 }); - } -} -exports.default = ClinicAdminPage; diff --git a/build/page-objects/clinician/WorkspacesPage.js b/build/page-objects/clinician/WorkspacesPage.js deleted file mode 100644 index 38f982f..0000000 --- a/build/page-objects/clinician/WorkspacesPage.js +++ /dev/null @@ -1,36 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const env_1 = __importDefault(require("../../utilities/env")); -class WorkspacesPage { - constructor(page) { - this.url = `${env_1.default.BASE_URL}/workspaces`; - this.page = page; - this.header = page.getByRole('heading', { name: 'Clinic Workspace' }); - this.subHeader = page.getByRole('paragraph', { - name: 'View, share and manage patient data', - }); - this.createClinicButton = page.getByRole('button', { - name: 'Create a New Clinic', - }); - } - async goto() { - await this.page.goto(this.url); - } - async visitFirstClinic() { - await this.page.getByRole('button', { name: 'Go To Workspace' }).first().click(); - } - /** - * Visit a clinic by name - * @param clinicName - The name of the clinic to visit - */ - async visitClinic(clinicName) { - // find child element with text and filter by parent element with class - const child = this.page.getByText(clinicName); - const parent = this.page.locator('.workspace-item-clinic').filter({ has: child }); - await parent.getByRole('button', { name: 'Go To Workspace' }).first().click(); - } -} -exports.default = WorkspacesPage; diff --git a/build/page-objects/clinician/components/navigation-menu.section.js b/build/page-objects/clinician/components/navigation-menu.section.js deleted file mode 100644 index 7aa1dda..0000000 --- a/build/page-objects/clinician/components/navigation-menu.section.js +++ /dev/null @@ -1,27 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -class NavigationMenu { - constructor(page) { - this.page = page; - this.container = page.locator('div#navigation-menu'); - this.buttons = { - trigger: this.container.locator('#navigation-menu-trigger'), - menu: { - privateWorkspace: this.container.getByRole('button', { - name: 'Private Workspace', - }), - accountSettings: this.container.getByRole('button', { - name: 'Account Settings', - }), - logout: this.container.getByRole('button', { name: 'Logout' }), - }, - }; - } - async open() { - await this.buttons.trigger.click(); - } - async close() { - await this.buttons.trigger.click(); - } -} -exports.default = NavigationMenu; diff --git a/build/page-objects/clinician/components/navigation.section.js b/build/page-objects/clinician/components/navigation.section.js deleted file mode 100644 index 176d5ff..0000000 --- a/build/page-objects/clinician/components/navigation.section.js +++ /dev/null @@ -1,22 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const navigation_menu_section_1 = __importDefault(require("./navigation-menu.section")); -class NavigationSection { - constructor(page) { - this.page = page; - this.container = page.locator('div#navPatientHeader'); - this.menu = new navigation_menu_section_1.default(page); - this.buttons = { - viewData: this.container.getByRole('button', { name: 'View Data' }), - patientProfile: this.container.getByRole('button', { - name: 'Patient Profile', - }), - share: this.container.getByRole('button', { name: 'Share' }), - uploadData: this.container.getByRole('button', { name: 'Upload Data' }), - }; - } -} -exports.default = NavigationSection; diff --git a/build/page-objects/patient/BasicsPage.js b/build/page-objects/patient/BasicsPage.js deleted file mode 100644 index 5977251..0000000 --- a/build/page-objects/patient/BasicsPage.js +++ /dev/null @@ -1,143 +0,0 @@ -"use strict"; -var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) { - var useValue = arguments.length > 2; - for (var i = 0; i < initializers.length; i++) { - value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); - } - return useValue ? value : void 0; -}; -var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) { - function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; } - var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; - var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null; - var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); - var _, done = false; - for (var i = decorators.length - 1; i >= 0; i--) { - var context = {}; - for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; - for (var p in contextIn.access) context.access[p] = contextIn.access[p]; - context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); }; - var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context); - if (kind === "accessor") { - if (result === void 0) continue; - if (result === null || typeof result !== "object") throw new TypeError("Object expected"); - if (_ = accept(result.get)) descriptor.get = _; - if (_ = accept(result.set)) descriptor.set = _; - if (_ = accept(result.init)) initializers.unshift(_); - } - else if (_ = accept(result)) { - if (kind === "field") initializers.unshift(_); - else descriptor[key] = _; - } - } - if (target) Object.defineProperty(target, contextIn.name, descriptor); - done = true; -}; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const base_1 = require("@fixtures/base"); -const PatientNavigation_1 = __importDefault(require("@pom/patient/PatientNavigation")); -const navigation_section_1 = __importDefault(require("@components/navigation.section")); -function createSection(page, selector) { - const parsedSelector = selector === 'tubing-primes' ? 'siteChanges' : selector; - const container = page.locator(`.Calendar-container-${parsedSelector}`); - return { - container, - firstDayOfData: container.locator(`.Calendar-day--${parsedSelector}.Calendar-day`).first(), - calendarDayhover: { - el: container.locator('.Calendar-day--HOVER'), - async text() { - return container.locator('.Calendar-day--HOVER').locator('.Calendar-weekday').textContent(); - }, - }, - }; -} -/** - * helper function to create a stat object with locators for the container, header, hoverBar, and hoverBarLabel - */ -function createStat(page, selector) { - const container = page.locator(`#Stat--${selector}`); - return { - container, - header: container.locator('[class^="Stat--chartTitleText"]'), - hoverBar: container.locator('.HoverBar'), - hoverBarLabel: container.locator('.HoverBarLabel'), - }; -} -// list of sections in the stats sidebar -const statsSideBarSection = [ - 'timeInRange', - 'readingsInRange', - 'averageGlucose', - 'totalInsulin', - 'carbs', - 'standardDev', - 'coefficientOfVariation', - 'sensorUsage', - 'glucoseManagementIndicator', - 'totalInsulin', - 'averageDailyDose', -]; -let PatientDataBasicsPage = (() => { - var _a; - let _instanceExtraInitializers = []; - let _goto_decorators; - return _a = class PatientDataBasicsPage { - constructor(page) { - this.page = __runInitializers(this, _instanceExtraInitializers); - this.page = page; - this.url = '/patients/data/basics'; - this.emailInput = page.getByRole('textbox', { name: 'Email' }); - this.navigationBar = new navigation_section_1.default(page); - this.navigationSubMenu = new PatientNavigation_1.default(page); - this.headerBgReading = page.getByRole('heading', { name: 'BG readings' }); - this.headerBolusing = page.getByRole('heading', { name: 'Bolusing' }); - this.statsSidebar = { - toggleContainer: page.locator('.toggle-container'), - async toggleTo(toState) { - const activeToggleState = await page - .locator(".toggle-container span[class*='TwoOptionToggle--active']") - .innerText(); - if (activeToggleState === 'BGM' && toState === 'CGM') { - await this.toggleContainer.click(); - } - else if (activeToggleState === 'CGM' && toState === 'BGM') { - await this.toggleContainer.click(); - } - }, - ...Object.fromEntries(statsSideBarSection.map(stat => [stat, createStat(page, stat)])), - }; - // charts - this.bgReadingsSection = createSection(page, 'fingersticks'); - this.bolusingSection = createSection(page, 'boluses'); - this.tubingPrimeSection = { - ...createSection(page, 'tubing-primes'), - settings: page.locator('.SiteChangeSelector-option').first(), - settingsOption: { - fillTubing: page.getByLabel('Tubing Fill'), - fillCannula: page.getByLabel('Cannula Fill'), - }, - tubingIcons: page.locator('.Change--tubing').first(), - cannulaIcons: page.locator('.Change--cannula').first(), - filledDay: createSection(page, 'tubing-primes') - .container.locator('.Calendar-day') - .filter({ has: page.locator('.Change-daysSince-text') }) - .first(), - }; - this.basalsSection = createSection(page, 'basals'); - } - async goto() { - await this.page.goto(this.url); - } - }, - (() => { - const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0; - _goto_decorators = [(0, base_1.step)('Navigate to the basics page')]; - __esDecorate(_a, null, _goto_decorators, { kind: "method", name: "goto", static: false, private: false, access: { has: obj => "goto" in obj, get: obj => obj.goto }, metadata: _metadata }, null, _instanceExtraInitializers); - if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata }); - })(), - _a; -})(); -exports.default = PatientDataBasicsPage; diff --git a/build/page-objects/patient/DailyPage.js b/build/page-objects/patient/DailyPage.js deleted file mode 100644 index eb0ad4e..0000000 --- a/build/page-objects/patient/DailyPage.js +++ /dev/null @@ -1,17 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const daily_chart_js_1 = __importDefault(require("@components/daily-chart.js")); -const PatientNavigation_js_1 = __importDefault(require("@pom/patient/PatientNavigation.js")); -const navigation_section_js_1 = __importDefault(require("@components/navigation.section.js")); -class PatientDataDailyPage { - constructor(page) { - this.page = page; - this.navigationBar = new navigation_section_js_1.default(page); - this.navigationSubMenu = new PatientNavigation_js_1.default(page); - this.dailyChart = new daily_chart_js_1.default(page); - } -} -exports.default = PatientDataDailyPage; diff --git a/build/page-objects/patient/PatientNavigation.js b/build/page-objects/patient/PatientNavigation.js deleted file mode 100644 index cec9e3c..0000000 --- a/build/page-objects/patient/PatientNavigation.js +++ /dev/null @@ -1,100 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -class PatientNav { - // currentDate: Locator; - constructor(page) { - this.page = page; - this.pages = { - ViewData: { - name: 'ViewData', - link: page.getByRole('button', { name: 'View Data View' }), - verifyURL: 'data', - verifyElement: page.locator('div.patient-data-subnav-inner'), - }, - Basics: { - name: 'Basics', - link: page.getByRole('link', { name: 'Basics' }), - verifyURL: 'data/basics', - verifyElement: page.locator('.js-basics.patient-data-subnav-tablink.patient-data-subnav-active'), - }, - ChartDateRange: { - name: 'ChartDateRange', - link: page - .locator('button svg, .css-15vjjnj svg, [aria-label*="calendar"], [title*="calendar"]') - .first(), // Calendar icon in blue navigation bar - verifyURL: '', - verifyElement: page.locator('#printDateRangePickerInner').locator('*').first(), // Any content inside the dialog - closeButton: page.getByRole('button', { name: 'close dialog' }), - }, - Daily: { - name: 'Daily', - link: page.getByRole('link', { name: 'Daily' }), - verifyURL: 'data/daily', - verifyElement: page.locator('.js-daily.patient-data-subnav-tablink.patient-data-subnav-active'), - }, - ChartDate: { - name: 'ChartDate', - link: page.locator('#tidelineLabel .css-15vjjnj svg'), // Using the same calendar icon selector - verifyURL: '', - verifyElement: page.getByRole('heading', { name: 'Chart Date' }), - closeButton: page.getByRole('button', { name: 'close dialog' }), - }, - BGLog: { - name: 'BGLog', - link: page.getByRole('link', { name: 'BG Log' }), - verifyURL: 'data/bglog', - verifyElement: page.locator('.js-bgLog.patient-data-subnav-tablink.patient-data-subnav-active'), - }, - Trends: { - name: 'Trends', - link: page.getByRole('link', { name: 'Trends' }), - verifyURL: 'data/trends', - verifyElement: page.locator('.js-trends.patient-data-subnav-tablink.patient-data-subnav-active'), - }, - Devices: { - name: 'Devices', - link: page.getByRole('link', { name: 'Devices' }), - verifyURL: 'data/devices', - verifyElement: page.locator('.js-settings.patient-data-subnav-tablink.patient-data-subnav-active'), - }, - Print: { - name: 'Print', - link: page.getByRole('link', { name: 'Print PDF report Print' }), // Print link from the snapshot - verifyURL: '', - verifyElement: page.getByRole('heading', { name: 'Print Report' }), // Assuming modal title - closeButton: page.getByRole('button', { name: 'close dialog' }), - }, - Profile: { - name: 'Profile', - link: page.getByRole('button', { name: 'Profile Profile' }), - verifyURL: '', - verifyElement: page.getByRole('button', { name: 'Edit' }), // Edit button is visible on profile page - }, - ProfileEdit: { - name: 'ProfileEdit', - link: page.getByRole('button', { name: 'Edit' }), - verifyURL: 'profile', - verifyElement: page.getByRole('button', { name: 'Save changes' }), // Save changes button appears when in edit mode - }, - Share: { - name: 'Share', - link: page.getByRole('button', { name: 'Share Share' }), - verifyURL: 'share', - verifyElement: page.getByRole('heading', { name: 'Access Management' }), - }, - ShareData: { - name: 'ShareData', - link: page.getByRole('button', { name: 'Share Data' }), - verifyURL: 'share/invite', - verifyElement: page.getByRole('heading', { name: 'Share your data' }), - }, - UploadData: { - name: 'UploadData', - link: page.getByRole('button', { name: 'Upload Data Upload' }), - verifyURL: 'upload', - verifyElement: page.getByRole('heading', { name: 'Upload Data' }), - }, - }; - } -} -exports.default = PatientNav; diff --git a/build/page-objects/patient/ProfilePage.js b/build/page-objects/patient/ProfilePage.js deleted file mode 100644 index 003f029..0000000 --- a/build/page-objects/patient/ProfilePage.js +++ /dev/null @@ -1,115 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.ProfilePage = void 0; -class ProfilePage { - constructor(page) { - this.page = page; - this.fieldLocators = { - fullName: this.page.getByRole('textbox', { name: 'Full name' }), - birthDate: this.page.getByRole('textbox', { name: 'Date of birth' }), - mrn: this.page.getByRole('textbox', { name: 'MRN' }), - diagnosisDate: this.page.getByRole('textbox', { name: 'Date of diagnosis' }), - clinicalNotes: this.page.getByRole('textbox', { name: 'Anything you would like to share' }), - email: this.page.getByRole('textbox', { name: /email/i }), - }; - } - // Generic fill method for text fields - async fillField(field, value) { - const locator = this.fieldLocators[field]; - if (!locator) - throw new Error(`No locator defined for field: ${field}`); - if (await locator.isVisible({ timeout: 3000 }).catch(() => false)) { - await locator.fill(value); - } - else { - throw new Error(`Field '${field}' not found or not visible`); - } - } - // Select a diagnosis type from the dropdown - async selectDiagnosisType(index) { - const diagnosisCombo = this.page.getByRole('combobox', { name: 'Diagnosed as' }); - if (await diagnosisCombo.isVisible({ timeout: 3000 })) { - await diagnosisCombo.selectOption({ index }); - } - } - // Get the current diagnosis index from the dropdown (needed for setting a new diagnosis) - async getCurrentDiagnosisIndex() { - const diagnosisCombo = this.page.getByRole('combobox', { name: 'Diagnosed as' }); - if (await diagnosisCombo.isVisible({ timeout: 3000 })) { - const currentValue = await diagnosisCombo.inputValue(); - const options = await diagnosisCombo.locator('option').all(); - // Find current index by checking option values - for (let i = 0; i < options.length; i += 1) { - const optionValue = await options[i].getAttribute('value'); - if (optionValue === currentValue) { - return i; - } - } - } - return 1; // Default to 1 if not found - } - // For backwards compatibility, keep these as wrappers (optional) - async fillFullName(name) { - return this.fillField('fullName', name); - } - async fillBirthDate(date) { - return this.fillField('birthDate', date); - } - async fillMRN(mrn) { - return this.fillField('mrn', mrn); - } - async fillDiagnosisDate(date) { - return this.fillField('diagnosisDate', date); - } - async fillClinicalNotes(notes) { - return this.fillField('clinicalNotes', notes); - } - async fillEmail(email) { - return this.fillField('email', email); - } - async saveProfile() { - // Save button locators - const saveButtons = [ - this.page.getByRole('button', { name: 'Save changes' }), - this.page.getByRole('button', { name: 'Save Profile' }), - this.page.getByRole('button', { name: 'Save' }), - ]; - // Wait for the PUT request to complete after clicking save - const saveProfilePromise = this.page.waitForResponse(response => response.url().includes('/metadata/') && - response.url().includes('/profile') && - response.request().method() === 'PUT'); - let clicked = false; - for (const btn of saveButtons) { - if (await btn.isVisible({ timeout: 5000 }).catch(() => false)) { - await btn.click(); - clicked = true; - break; - } - } - if (!clicked) - throw new Error('No save button found'); - // Wait for the PUT request to complete (with timeout) - try { - await saveProfilePromise; - } - catch (error) { - console.log('āš ļø PUT request timeout - continuing anyway'); - } - } - /** - * Checks if the edit button is displayed and validates against expected state - * @param shouldBeVisible - Boolean indicating whether the edit button should be visible - * @throws Error if the actual visibility doesn't match the expected state - */ - async editButtonDisplays(shouldBeVisible) { - const editButton = this.page.getByRole('button', { name: 'Edit' }); - const isEditButtonVisible = await editButton.isVisible({ timeout: 3000 }).catch(() => false); - if (shouldBeVisible && !isEditButtonVisible) { - throw new Error('Edit button should be visible but was not found'); - } - else if (!shouldBeVisible && isEditButtonVisible) { - throw new Error('Edit button should not be visible for this user - security violation!'); - } - } -} -exports.ProfilePage = ProfilePage; diff --git a/build/page-objects/patient/components/daily-chart.js b/build/page-objects/patient/components/daily-chart.js deleted file mode 100644 index 5eee722..0000000 --- a/build/page-objects/patient/components/daily-chart.js +++ /dev/null @@ -1,14 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -class DailyChartSection { - constructor(page) { - this.page = page; - this.container = page.locator('div.patient-data-content'); - this.dayLabel = this.container.locator('text.d3-day-label').filter({ visible: true }); - this.newNote = this.container.locator('image.newNoteIcon'); - this.buttons = { - refresh: this.container.getByRole('button', { name: 'Refresh' }), - }; - } -} -exports.default = DailyChartSection; diff --git a/build/playwright.config.js b/build/playwright.config.js deleted file mode 100644 index d6b290c..0000000 --- a/build/playwright.config.js +++ /dev/null @@ -1,106 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const test_1 = require("@playwright/test"); -const node_path_1 = __importDefault(require("node:path")); -const env_1 = __importDefault(require("./utilities/env")); -// Helper to detect BrowserStack run -const isBrowserStack = Boolean(process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESS_KEY); -function buildBrowserStackEndpoint(testName) { - const caps = { - browser: 'chrome', - browser_version: 'latest', - os: 'os x', - os_version: 'catalina', - name: testName, - build: process.env.CI_BUILD_NUMBER || 'local-run', - 'browserstack.username': process.env.BROWSERSTACK_USERNAME, - 'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY, - }; - return `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify(caps))}`; -} -exports.default = (0, test_1.defineConfig)({ - testDir: './tests', - outputDir: './test-results', // Custom output directory - globalSetup: require.resolve(node_path_1.default.join(__dirname, 'tests/global-setup')), - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - timeout: 60000, - expect: { - toHaveScreenshot: { maxDiffPixelRatio: 0.2 }, - }, - reporter: [ - ['html', { open: 'never', outputFolder: 'playwright-report' }], - ['json', { outputFile: 'test-results/last-run.json' }], - ['./utilities/xray-json-reporter.ts'], - ], - use: { - baseURL: env_1.default.BASE_URL, - trace: 'on-first-retry', - screenshot: 'only-on-failure', - video: 'retain-on-failure', - // Custom test attachment naming - testIdAttribute: 'data-testid', - }, - projects: [ - { - name: 'chromium-personal', - testMatch: '**/personal/**/*.spec.ts', - use: { - ...test_1.devices['Desktop Chrome'], - storageState: 'tests/.auth/personal.json', - headless: false, - }, - }, - { - name: 'chromium-claimed', - testMatch: '**/claimed/**/*.spec.ts', - use: { - ...test_1.devices['Desktop Chrome'], - storageState: 'tests/.auth/claimed.json', - headless: false, - }, - }, - { - name: 'chromium-clinician', - testMatch: '**/clinician/**/*.spec.ts', - use: { - ...test_1.devices['Desktop Chrome'], - storageState: 'tests/.auth/clinician.json', - headless: false, - }, - }, - ...(isBrowserStack - ? [ - { - name: 'bs-chrome-personal', - testMatch: '**/patient/**/*.spec.ts', - use: { - storageState: 'tests/.auth/personal.json', - connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Personal Patient Tests') }, - }, - }, - { - name: 'bs-chrome-claimed', - testMatch: '**/claimed/**/*.spec.ts', - use: { - storageState: 'tests/.auth/claimed.json', - connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Claimed Patient Tests') }, - }, - }, - { - name: 'bs-chrome-clinician', - testMatch: '**/clinician/**/*.spec.ts', - use: { - storageState: 'tests/.auth/clinician.json', - connectOptions: { wsEndpoint: buildBrowserStackEndpoint('Clinician Tests') }, - }, - }, - ] - : []), - ], -}); diff --git a/build/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js b/build/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js deleted file mode 100644 index ba00295..0000000 --- a/build/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.js +++ /dev/null @@ -1,148 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const base_1 = require("../../fixtures/base"); -const patient_helpers_1 = require("../../fixtures/patient-helpers"); -const account_helpers_1 = require("../../fixtures/account-helpers"); -const clinic_helpers_1 = require("../../fixtures/clinic-helpers"); -const network_helpers_1 = require("../../fixtures/network-helpers"); -const test_tags_1 = require("../../fixtures/test-tags"); -const AccountSettingsPage_1 = require("../../../page-objects/account/AccountSettingsPage"); -const ProfilePage_1 = require("../../../page-objects/patient/ProfilePage"); -const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; -const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; -base_1.test.describe('Claimed Account Settings edit (Full Name only) updates Profile endpoint and visually updates for user, clinic, and shared member', () => { - base_1.test.setTimeout(120000); // 2 minute timeout for multi-phase test - let api; - let putCapture; - let newName; // Declare at test level scope - (0, base_1.test)('should allow navigation to account settings, edit full name, and verify profile update for claimed, shared, and clinician users', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.PATIENT, - test_tags_1.TEST_TAGS.CLINICIAN, // Added clinician tag - test_tags_1.TEST_TAGS.CLAIMED, - test_tags_1.TEST_TAGS.SHARED_MEMBER, // Added shared member tag - test_tags_1.TEST_TAGS.API, - test_tags_1.TEST_TAGS.UI, - test_tags_1.TEST_TAGS.HIGH, - test_tags_1.TEST_TAGS.API_PROFILE, - ]), - }, async ({ page }) => { - // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== - // Step 1: Log in to clinician account and setup network capture - await base_1.test.step('Given claimed account has been logged in', async () => { - api = (0, network_helpers_1.createNetworkHelper)(page); - await api.startCapture(); - await page.goto('/data'); - await patient_helpers_1.test.patient.setup(page); - }); - // Step 2: Navigate to account settings - await base_1.test.step('When user navigates to account settings', async () => { - await account_helpers_1.test.account.navigateTo('AccountSettings', page); - }); - // Step 3: GET response is pulled and validated - await base_1.test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - // Create new acccount settings page for the following test - const accountSettingsPage = new AccountSettingsPage_1.AccountSettingsPage(page); - // Step 4: Change the Full Name field to a new value - await base_1.test.step('When user updates the Full Name field', async () => { - newName = `Claimed User Updated ${Math.floor(Math.random() * 10000)}`; // Remove let declaration - const nameInput = page.getByRole('textbox', { name: /full name/i }); - await nameInput.fill(newName); - }); - // Step 5: Tap the Save button - await base_1.test.step('When user taps the save button', async () => { - await accountSettingsPage.saveButton.click(); - }); - // Step 6: Confirm save changes message displays - await base_1.test.step('Then the save changes message displays', async () => { - await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); - }); - // Step 7: Validate PUT request and save value - await base_1.test.stepNoScreenshot('Then PUT request is validated and name is set to new value', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - putCapture = api - .getCaptures() - .find((req) => req.method === 'PUT' && req.url.includes('/profile')); - if (!putCapture) - throw new Error('No PUT /profile request captured'); - if (!putCapture.requestBody || - !putCapture.requestBody.fullName || - putCapture.requestBody.fullName !== newName) { - throw new Error(`PUT request did not set fullName to ${newName}`); - } - }); - // Step 8: Navigate to Profile page - await base_1.test.step('When user navigates to Profile page', async () => { - await patient_helpers_1.test.patient.navigateTo('Profile', page); - }); - // Step 9: Confirm GET request matches the saved PUT request - await base_1.test.stepNoScreenshot('Then GET request matches the saved PUT request', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - // Get all captures and find the LATEST GET request (after the PUT) - const allCaptures = api.getCaptures(); - const putIndex = allCaptures.findIndex(req => req === putCapture); - // Find GET requests that occurred AFTER the PUT request - const laterGetCaptures = allCaptures - .slice(putIndex + 1) - .filter((req) => req.method === 'GET' && req.url.includes('/profile')); - if (laterGetCaptures.length === 0) { - throw new Error('No GET /profile request captured after the PUT request'); - } - // Use the most recent GET request - const getCapture = laterGetCaptures[laterGetCaptures.length - 1]; - if (!getCapture.responseBody || - getCapture.responseBody.fullName !== putCapture.requestBody.fullName) { - console.log('GET response fullName:', getCapture.responseBody.fullName); - console.log('PUT request fullName:', putCapture.requestBody.fullName); - console.log('Total captures:', allCaptures.length); - console.log('PUT index:', putIndex); - console.log('Later GET captures found:', laterGetCaptures.length); - throw new Error('GET response fullName does not match PUT request fullName'); - } - }); - // ========== PHASE 2: SHARED USER VIEWS PROFILE ========== - // Step 10: Switch to shared user authentication and go directly to Profile - await base_1.test.step('When shared user views claimed user profile', async () => { - await account_helpers_1.test.account.switchUser('shared', page); - await page.goto('/data'); - await patient_helpers_1.test.patient.setup(page); - // Wait a moment for the page to stabilize after user switch - await page.waitForTimeout(500); - // Navigate directly to Profile in the same step to avoid redundancy - await patient_helpers_1.test.patient.navigateTo('Profile', page); - }); - // Step 11: Verify Edit button is not present for shared users - await base_1.test.step('Then Edit button should not be present for shared patients', async () => { - const profilePage = new ProfilePage_1.ProfilePage(page); - await profilePage.editButtonDisplays(false); - }); - // Step 12: Validate shared user sees updated profile data - await base_1.test.stepNoScreenshot('Then shared user sees view-only claimed profile data with matching data', async () => { - await api.compareEndpointResponse('profile-metadata-get', putCapture); - }); - // ========== PHASE 3: CLINICIAN VIEWS PROFILE ========== - // Step 13: Switch to clinician user authentication - await base_1.test.step('When clinician accesses patient workspace', async () => { - await account_helpers_1.test.account.switchUser('clinician', page); - await page.goto('/'); - await clinic_helpers_1.test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); - }); - // Step 14: Access the specific claimed patient that was modified by the producer test - await base_1.test.step('When user accesses the claimed patient modified by producer test', async () => { - await clinic_helpers_1.test.clinician.findAndAccessPatientByPartialName(CLAIMED_PATIENT_SEARCH, page); - // Navigate directly to Profile in the same step to avoid redundancy - await clinic_helpers_1.test.clinician.navigateTo('Profile', page); - }); - // Step 15: Verify Edit button is not present for claimed patients viewed by clinicians - await base_1.test.step('Then Edit button should not be present for claimed patients', async () => { - const profilePage = new ProfilePage_1.ProfilePage(page); - await profilePage.editButtonDisplays(false); - }); - // Step 16: Validate clinician sees updated profile data - await base_1.test.stepNoScreenshot('Then clinician sees claimed profile data with matching data and no save access', async () => { - await api.compareEndpointResponse('profile-metadata-get', putCapture); - }); - }); -}); diff --git a/build/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js b/build/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js deleted file mode 100644 index 7847f31..0000000 --- a/build/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.js +++ /dev/null @@ -1,159 +0,0 @@ -"use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -Object.defineProperty(exports, "__esModule", { value: true }); -const base_1 = require("../../fixtures/base"); -const patient_helpers_1 = require("../../fixtures/patient-helpers"); -const clinic_helpers_1 = require("../../fixtures/clinic-helpers"); -const account_helpers_1 = require("../../fixtures/account-helpers"); -const network_helpers_1 = require("../../fixtures/network-helpers"); -const test_tags_1 = require("../../fixtures/test-tags"); -const ProfilePage_1 = require("../../../page-objects/patient/ProfilePage"); -const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; -const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; -base_1.test.describe('Comprehensive Profile Access Test: Edit as Claimed, View as Shared and Clinician', () => { - (0, base_1.test)('should edit claimed profile then verify view-only access for shared and clinician users', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.PATIENT, // User Type (required) - test_tags_1.TEST_TAGS.CLINICIAN, // User Type (required) - test_tags_1.TEST_TAGS.CLAIMED, - test_tags_1.TEST_TAGS.SHARED_MEMBER, - test_tags_1.TEST_TAGS.API, // Test Type (required) - test_tags_1.TEST_TAGS.UI, // Test Type (required) - test_tags_1.TEST_TAGS.HIGH, // Priority (required) - test_tags_1.TEST_TAGS.API_PROFILE, // Feature (optional) - ]), - }, async ({ page }) => { - let api; - let producerPutCapture; - // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== - // Step 1: Claimed account has been logged in - await base_1.test.step('Given claimed account has been logged in', async () => { - api = (0, network_helpers_1.createNetworkHelper)(page); - await api.startCapture(); - await page.goto('/data'); - await patient_helpers_1.test.patient.setup(page); - }); - // Step 2: User navigates to Profile page - await base_1.test.step('When user navigates to Profile page', async () => { - await patient_helpers_1.test.patient.navigateTo('Profile', page); - }); - // Step 3: GET response is pulled and validated - await base_1.test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - // Step 4: Confirm edit button and click it - await base_1.test.step('When user selects Edit button', async () => { - await patient_helpers_1.test.patient.navigateTo('ProfileEdit', page); - }); - // Initialize ProfilePage for steps 4 and 5 - const profilePage = new ProfilePage_1.ProfilePage(page); - // Step 5: Change profile fields (confirmed user access) - await base_1.test.step('When user updates profile fields', async () => { - const testRunId = Math.floor(Math.random() * 10000); - const updatedName = `Claimed User Updated ${testRunId}`; - const birthYear = 1985 + (testRunId % 10); - const diagnosisYear = birthYear + 20; - const birthDate = `01/15/${birthYear}`; - const diagnosisDate = `03/10/${diagnosisYear}`; - // Generate random 15-letter string for clinical notes - const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); - // Get current diagnosis index and calculate next one (1-7, wrapping) - const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); - let nextDiagnosisIndex = currentDiagnosisIndex + 1; - if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { - nextDiagnosisIndex = 1; - } - // Update fields using ProfilePage methods - await profilePage.fillFullName(updatedName); - await profilePage.fillBirthDate(birthDate); - await profilePage.fillDiagnosisDate(diagnosisDate); - await profilePage.selectDiagnosisType(nextDiagnosisIndex); - await profilePage.fillClinicalNotes(randomString); - }); - // Step 6: Save profile edit - await base_1.test.step('When user saves profile changes', async () => { - await profilePage.saveProfile(); - }); - // Step 7: PUT response is validated and saved for comparison - await base_1.test.stepNoScreenshot('Then profile endpoint responds with PUT request consistent with schema', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - const putSchema = await Promise.resolve().then(() => __importStar(require('../../../endpoint-schema/profile-endpoints'))); - const schema = putSchema.putProfileMetadataSchema; - producerPutCapture = api.getLatestCaptureMatching(schema.method, schema.url); - }); - //= ========= SHARED MEMEBER VIEWS PROFILE ========== - // Step 8: Switch to shared user authentication - await base_1.test.step('When shared user views claimed user profile', async () => { - await account_helpers_1.test.account.switchUser('shared', page); - await page.goto('/data'); - await patient_helpers_1.test.patient.navigateTo('ViewData', page); - }); - // Step 9: Navigate to profile page - await base_1.test.step('When user navigates to Profile page', async () => { - await patient_helpers_1.test.patient.navigateTo('Profile', page); - }); - // Step 10: Confirm edit button is not present - await base_1.test.step('Then Edit button should not be present for shared patients', async () => { - await profilePage.editButtonDisplays(false); - }); - // Step 11: Validate GET response and compare it against the - await base_1.test.stepNoScreenshot('Then shared user sees view-only claimed profile data with matching data', async () => { - await api.compareEndpointResponse('profile-metadata-get', producerPutCapture); - }); - // ========== CLINICIAN VIEWS PROFILE ========== - // Step 12: Switch to clinician authentication and navigate to patient profile - await base_1.test.step('When clinician accesses patient workspace', async () => { - await account_helpers_1.test.account.switchUser('clinician', page); - await page.goto('/'); - await clinic_helpers_1.test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); - }); - // Step 13: Access the specific claimed patient that was modified by the producer test - await base_1.test.step('When user accesses the claimed patient modified by producer test', async () => { - await clinic_helpers_1.test.clinician.findAndAccessPatientByPartialName(CLAIMED_PATIENT_SEARCH, page); - }); - // Step 14: Navigate to profile - await base_1.test.step('When user navigates to Profile page', async () => { - await clinic_helpers_1.test.clinician.navigateTo('Profile', page); - }); - // Step 15: Confirm edit button is not present - await base_1.test.step('Then Edit button should not be present for claimed patients', async () => { - await profilePage.editButtonDisplays(false); - }); - // Step 16: Validate GET response and confirm appropriate permissions - await base_1.test.stepNoScreenshot('Then clinician sees claimed profile data with matching data and no save access', async () => { - await api.compareEndpointResponse('profile-metadata-get', producerPutCapture); - }); - }); -}); diff --git a/build/tests/claimed/API-User/claimed-email-edit.spec.js b/build/tests/claimed/API-User/claimed-email-edit.spec.js deleted file mode 100644 index 4076621..0000000 --- a/build/tests/claimed/API-User/claimed-email-edit.spec.js +++ /dev/null @@ -1,95 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const base_1 = require("../../fixtures/base"); -const patient_helpers_1 = require("../../fixtures/patient-helpers"); -const account_helpers_1 = require("../../fixtures/account-helpers"); -const network_helpers_1 = require("../../fixtures/network-helpers"); -const test_tags_1 = require("../../fixtures/test-tags"); -const AccountSettingsPage_1 = require("../../../page-objects/account/AccountSettingsPage"); -base_1.test.describe('Clinician Account Settings Access', () => { - // API Test cases require this to capture network activity - let api; - (0, base_1.test)('should allow navigation to account settings and capture GET response', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.PATIENT, - test_tags_1.TEST_TAGS.CLAIMED, - test_tags_1.TEST_TAGS.API, - test_tags_1.TEST_TAGS.UI, - test_tags_1.TEST_TAGS.HIGH, - test_tags_1.TEST_TAGS.API_USER, - ]), - }, async ({ page }) => { - // Step 1: Log in to clinician account and setup network capture - await base_1.test.step('Given clinician has been logged in', async () => { - api = (0, network_helpers_1.createNetworkHelper)(page); - await api.startCapture(); - await page.goto('/data'); - await patient_helpers_1.test.patient.setup(page); - }); - // Step 2: Navigate to account settings - await base_1.test.step('When user navigates to account settings', async () => { - await account_helpers_1.test.account.navigateTo('AccountSettings', page); - }); - // Step 3: Validate profile GET response - await base_1.test.stepNoScreenshot('Then profile endpoint responds with GET request consistent with schema ', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - // Setup for Account Settings page and previous email for reset - const accountSettingsPage = new AccountSettingsPage_1.AccountSettingsPage(page); - let originalEmail = ''; - // Step 4: Read and change email field to temporary value - await base_1.test.step('When user updates the email field', async () => { - originalEmail = await accountSettingsPage.emailInput.inputValue(); - await accountSettingsPage.emailInput.fill('qa+TempEdit@tidepool.org'); - }); - // Step 5: Tap the save button - await base_1.test.step('When user taps the save button', async () => { - await accountSettingsPage.saveButton.click(); - }); - // Step 6: Confirm save changes message displays - await base_1.test.step('Then the save changes message displays', async () => { - await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); - }); - // Step 7: Validate PUT request and email value - await base_1.test.stepNoScreenshot('Then PUT request is validated and email is set to new value', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - const putCapture = api - .getCaptures() - .find((req) => req.method === 'PUT' && req.url.includes('/profile')); - if (!putCapture) - throw new Error('No PUT /profile request captured'); - if (!putCapture.requestBody || - !putCapture.requestBody.email || - putCapture.requestBody.email !== 'qa+TempEdit@tidepool.org') { - throw new Error('PUT request did not set email to qa+TempEdit@tidepool.org'); - } - }); - // Step 8: Change email field to temporary value - await base_1.test.step('When user sets the email field to the previous value', async () => { - await accountSettingsPage.emailInput.fill(originalEmail); - }); - // Step 9: Tap the save button - await base_1.test.step('When user taps the save button', async () => { - await accountSettingsPage.saveButton.click(); - }); - // Step 10: Confirm save changes message displays - await base_1.test.step('Then the save changes message displays', async () => { - await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); - }); - // Step 7: Validate PUT request and email value - await base_1.test.stepNoScreenshot('Then PUT request is validated and email is set to new value', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - const putCapture = api - .getCaptures() - .find((req) => req.method === 'PUT' && req.url.includes('/profile')); - if (!putCapture) - throw new Error('No PUT /profile request captured'); - if (!putCapture.requestBody || - !putCapture.requestBody.email || - putCapture.requestBody.email !== originalEmail) { - throw new Error('PUT request did not set email to originalEmail'); - } - }); - await api.stopCapture(); - }); -}); diff --git a/build/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js b/build/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js deleted file mode 100644 index d6f79c7..0000000 --- a/build/tests/clinician/API-Profile/edit-custodial-profile-API.spec.js +++ /dev/null @@ -1,91 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const clinic_helpers_1 = require("../../fixtures/clinic-helpers"); -const network_helpers_1 = require("../../fixtures/network-helpers"); -const test_tags_1 = require("../../fixtures/test-tags"); -const ProfilePage_1 = require("../../../page-objects/patient/ProfilePage"); -clinic_helpers_1.test.describe('Custodial patients are allowed access and modification of profile details', () => { - // Define the workspace and patient at top level - const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; - const CUSTODIAL_PATIENT_SEARCH = 'Custodial Patient'; - // API Test cases require this to capture network activity - let api; - (0, clinic_helpers_1.test)('should allow navigation to profile details and edit profile fields', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.CLINICIAN, // User Type (required) - test_tags_1.TEST_TAGS.API, // Test Type (required) - test_tags_1.TEST_TAGS.UI, // Test Type (required) - test_tags_1.TEST_TAGS.HIGH, // Priority (required) - test_tags_1.TEST_TAGS.API_PROFILE, // Feature (optional) - ]), - }, async ({ page }, testInfo) => { - // Step 1: Log in to clinician account and setup network capture - await clinic_helpers_1.test.step('Given clinician has been logged in', async () => { - api = (0, network_helpers_1.createNetworkHelper)(page); - await api.startCapture(); - await clinic_helpers_1.test.clinician.setup(page); - }); - // Step 2: Navigate to workspace - await clinic_helpers_1.test.step('When user navigates to desired workspace', async () => { - await clinic_helpers_1.test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); - }); - // Step 3: Access custodial patient - await clinic_helpers_1.test.step('When user accesses a custodial patient summary', async () => { - await clinic_helpers_1.test.clinician.findAndAccessPatientByPartialName(CUSTODIAL_PATIENT_SEARCH, page); - }); - // Step 4: Navigate to profile - await clinic_helpers_1.test.step('When user navigates to Profile page', async () => { - await clinic_helpers_1.test.clinician.navigateTo('Profile', page); - }); - // Step 5: Capture GET response - await clinic_helpers_1.test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - // Step 6: Open Edit Profile - await clinic_helpers_1.test.step('When user selects Edit button', async () => { - await clinic_helpers_1.test.clinician.navigateTo('ProfileEdit', page); - }); - // Create Profile page for following steps - const profilePage = new ProfilePage_1.ProfilePage(page); - // Step 7: Change profile fields (custodial access) - await clinic_helpers_1.test.step('When user updates profile fields', async () => { - // Generate completely unique values for this custodial test run - const randomSeed = Math.random(); - const randomId = Math.floor(randomSeed * 10000); - const updatedName = `Custodial Patient Updated ${Math.floor(randomId * 10000)}`; - const birthYear = 1980 + (randomId % 15); - const diagnosisYear = birthYear + 25; - const birthDate = `05/20/${birthYear}`; - const diagnosisDate = `08/15/${diagnosisYear}`; - // Generate random 15-digit MRN - const randomMRN = Array.from({ length: 15 }, () => Math.floor(Math.random() * 10).toString()).join(''); - // Generate random 15-letter string for clinical notes - const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); - // Generate unique email - const email = `webuiautomation+custodialEdit${randomId}@tidepool.org`; - // Get current diagnosis index and calculate next one (1-7, wrapping) - const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); - let nextDiagnosisIndex = currentDiagnosisIndex + 1; - if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { - nextDiagnosisIndex = 1; - } - // Update fields using ProfilePage methods - await profilePage.fillFullName(updatedName); - await profilePage.fillBirthDate(birthDate); - await profilePage.fillMRN(randomMRN); - await profilePage.fillDiagnosisDate(diagnosisDate); - await profilePage.selectDiagnosisType(nextDiagnosisIndex); - await profilePage.fillEmail(email); - await profilePage.fillClinicalNotes(randomString); - }); - // Step 8: Save profile edit - await clinic_helpers_1.test.step('When user saves profile changes', async () => { - await profilePage.saveProfile(); - }); - // Step 9: Check profile PUT response - await clinic_helpers_1.test.step('Then profile endpoint responds with PUT request consistent with schema [no-screenshot]', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - }); - await api.stopCapture(); - }); -}); diff --git a/build/tests/clinician/add-patient.spec.js b/build/tests/clinician/add-patient.spec.js deleted file mode 100644 index 595caf8..0000000 --- a/build/tests/clinician/add-patient.spec.js +++ /dev/null @@ -1,38 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const base_1 = require("@fixtures/base"); -const ClinicianDashboardPage_1 = __importDefault(require("@pom/clinician/ClinicianDashboardPage")); -const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); -base_1.test.describe('Add new patient', () => { - // Use a unique patient name for each test run to avoid collisions - const patientName = `Test Patient Playwright ${Date.now()}`; - const patientBirthdate = '01/01/1990'; - base_1.test.beforeEach(async () => { - await base_1.test.step('Given user has been logged in and navigated to base URL', async () => { }); - }); - (0, base_1.test)('should successfully add a new patient', async ({ page }) => { - const workspacesPage = new WorkspacesPage_1.default(page); - const clinicWorkspacePage = new ClinicianDashboardPage_1.default(page); - await base_1.test.step('Given the user is on the workspaces page', async () => { - await workspacesPage.goto(); - await workspacesPage.header.waitFor({ state: 'visible' }); - }); - await base_1.test.step('When user selects the first workspace', async () => { - await workspacesPage.visitFirstClinic(); - await clinicWorkspacePage.waitForLoadState(); // Wait for clinic page elements - }); - await base_1.test.step('When user adds a new patient via dialog', async () => { - await clinicWorkspacePage.openAndFillAddPatientDialog(patientName, patientBirthdate); - await clinicWorkspacePage.submitAddPatientDialog(); - await clinicWorkspacePage.closeBringDataDialog(); - }); - await base_1.test.step('Then the new patient should appear in the patient list', async () => { - await clinicWorkspacePage.searchForPatient(patientName); - const patientCell = clinicWorkspacePage.getPatientCellByName(patientName); - await (0, base_1.expect)(patientCell).toBeVisible(); - }); - }); -}); diff --git a/build/tests/clinician/create-clinic-workspace.spec.js b/build/tests/clinician/create-clinic-workspace.spec.js deleted file mode 100644 index c6fd99f..0000000 --- a/build/tests/clinician/create-clinic-workspace.spec.js +++ /dev/null @@ -1,86 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const base_1 = require("@fixtures/base"); -const ClinicCreationPage_1 = __importDefault(require("@pom/clinician/ClinicCreationPage")); -const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); -const node_crypto_1 = require("node:crypto"); -base_1.test.describe('Create clinic workspace', () => { - const uniqueSuffix = (0, node_crypto_1.randomUUID)().substring(0, 8); - const clinicName = `Test Clinic ${uniqueSuffix}`; - let workspacesPage; - let clinicCreationPage; - base_1.test.beforeEach(async ({ page }) => { - workspacesPage = new WorkspacesPage_1.default(page); - clinicCreationPage = new ClinicCreationPage_1.default(page); - }); - (0, base_1.test)('should successfully create a new clinic workspace', async ({ page }) => { - await base_1.test.step('Given user is on the workspaces page', async () => { - await workspacesPage.goto(); - await (0, base_1.expect)(workspacesPage.header).toBeVisible(); - await (0, base_1.expect)(workspacesPage.createClinicButton).toBeVisible(); - }); - await base_1.test.step("When user clicks on the 'Create a New Clinic' button", async () => { - await workspacesPage.createClinicButton.click(); - // Wait for the clinic details page to load - await (0, base_1.expect)(page).toHaveURL(/clinic-details\/new/); - await (0, base_1.expect)(clinicCreationPage.pageHeader).toBeVisible(); - }); - await base_1.test.step('When user fills in all the required clinic information', async () => { - // Fill the clinic form with test data - await clinicCreationPage.fillClinicForm({ - clinicName, - teamType: 'Provider Practice', - state: 'California', - address: '123 Test Street', - city: 'Test City', - zipCode: '12345', - }); - // Verify blood glucose units (mg/dL is pre-selected) - await (0, base_1.expect)(clinicCreationPage.mgdlRadio).toBeChecked(); - // Verify the admin acknowledgment checkbox is checked - await (0, base_1.expect)(clinicCreationPage.adminAcknowledgeCheckbox).toBeChecked(); - // Verify Create Workspace button is enabled - await (0, base_1.expect)(clinicCreationPage.createWorkspaceButton).toBeEnabled(); - }); - await base_1.test.step("When user clicks on the 'Create Workspace' button", async () => { - await clinicCreationPage.createWorkspaceButton.click(); - // Wait for redirect to workspaces page - await (0, base_1.expect)(page).toHaveURL('/workspaces'); - }); - await base_1.test.step('Then user should see the new clinic in the list and a success message', async () => { - // Verify success message is shown - const successMessage = page.getByText(`"${clinicName}" clinic created`); - await (0, base_1.expect)(successMessage).toBeVisible(); - // Verify the new clinic appears in the list - const clinicHeaderLocator = page.getByRole('heading', { name: clinicName }); - await (0, base_1.expect)(clinicHeaderLocator).toBeVisible(); - // Verify the clinic has the necessary action buttons - const clinicContainer = page - .locator('.workspace-item-clinic') - .filter({ has: clinicHeaderLocator }); - await (0, base_1.expect)(clinicContainer.getByRole('button', { name: 'Leave Clinic' })).toBeVisible(); - await (0, base_1.expect)(clinicContainer.getByRole('button', { name: 'Go To Workspace' })).toBeVisible(); - }); - }); - (0, base_1.test)('should create a new clinic with the simplified createClinic method', async ({ page }) => { - // Navigate to the workspaces page - await page.goto('/workspaces'); - await (0, base_1.expect)(workspacesPage.header).toBeVisible(); - // Click the "Create a New Clinic" button - await workspacesPage.createClinicButton.click(); - await (0, base_1.expect)(page).toHaveURL(/clinic-details\/new/); - // Use the simplified method to create a clinic in one step - await clinicCreationPage.createClinic(clinicName); - // Verify we're back on the workspaces page - await (0, base_1.expect)(page).toHaveURL('/workspaces'); - // Verify the clinic was created - const successMessage = page.getByText(`"${clinicName}" clinic created`); - await (0, base_1.expect)(successMessage).toBeVisible(); - // Verify the clinic appears in the list - const clinicHeaderLocator = page.getByRole('heading', { name: clinicName }); - await (0, base_1.expect)(clinicHeaderLocator).toBeVisible(); - }); -}); diff --git a/build/tests/clinician/edit-clinic-address.spec.js b/build/tests/clinician/edit-clinic-address.spec.js deleted file mode 100644 index 0f038c1..0000000 --- a/build/tests/clinician/edit-clinic-address.spec.js +++ /dev/null @@ -1,47 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const base_1 = require("@fixtures/base"); -const WorkspaceSettingsPage_1 = __importDefault(require("@pom/clinician/WorkspaceSettingsPage")); -const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); -base_1.test.describe('Edit clinic address', () => { - const newAddress = `123 Test Street ${Date.now()}`; // Unique address for test run - let clinicAdminPage; - let workspacesPage; - base_1.test.beforeEach(async ({ page }) => { - clinicAdminPage = new WorkspaceSettingsPage_1.default(page); - workspacesPage = new WorkspacesPage_1.default(page); - await base_1.test.step('Given user has navigated to the Clinic Admin page', async () => { - await workspacesPage.goto(); - await workspacesPage.visitFirstClinic(); - await page.goto('/clinic-admin'); - await clinicAdminPage.waitForLoadState(); // Wait for clinic admin page elements - await clinicAdminPage.clinicDetailsHeader.waitFor({ state: 'visible' }); - }); - }); - (0, base_1.test)('should successfully edit the clinic address', async ({ page }) => { - await base_1.test.step('When user clicks the "Edit" button for workspace details', async () => { - await clinicAdminPage.editDetailsButton.click(); - await clinicAdminPage.editClinicModal.waitFor({ state: 'visible' }); - }); - await base_1.test.step('Then user sees the modal for Editing workspace details', async () => { - await (0, base_1.expect)(clinicAdminPage.editClinicModalTitle).toBeVisible(); - await (0, base_1.expect)(clinicAdminPage.addressInput).toBeVisible(); - }); - await base_1.test.step('When user changes the address', async () => { - await clinicAdminPage.addressInput.fill(newAddress); - }); - await base_1.test.step('When user clicks on "Save changes"', async () => { - await clinicAdminPage.saveChangesButton.click(); - await clinicAdminPage.editClinicModal.waitFor({ state: 'hidden' }); // Wait for modal to close - }); - await base_1.test.step('Then user sees the updated address on the page', async () => { - // Wait for the details section to potentially update - await page.waitForTimeout(1000); // Small wait for potential DOM update - const detailsText = clinicAdminPage.clinicDetailsSection; - await (0, base_1.expect)(detailsText).toContainText(newAddress); - }); - }); -}); diff --git a/build/tests/clinician/filter-patient.spec.js b/build/tests/clinician/filter-patient.spec.js deleted file mode 100644 index 5032ef2..0000000 --- a/build/tests/clinician/filter-patient.spec.js +++ /dev/null @@ -1,70 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const base_1 = require("@fixtures/base"); -const ClinicianDashboardPage_1 = __importDefault(require("@pom/clinician/ClinicianDashboardPage")); -const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); -base_1.test.describe('Filter patients in clinic', () => { - // Use unique patient names for each test run - const timestamp = Date.now(); - const patientName1 = `Filter Patient A ${timestamp}`; - const patientName2 = `Filter Patient B ${timestamp}`; - const patientBirthdate = '01/01/1995'; // Shared birthdate for simplicity - let workspacesPage; - let clinicWorkspacePage; - base_1.test.beforeEach(async ({ page }) => { - workspacesPage = new WorkspacesPage_1.default(page); - clinicWorkspacePage = new ClinicianDashboardPage_1.default(page); - await base_1.test.step('Given user has been logged in and navigated to base URL', async () => { - await workspacesPage.goto(); - await page.waitForURL(workspacesPage.url); - await workspacesPage.header.waitFor({ state: 'visible' }); - }); - await base_1.test.step('Given the user is on the first clinic workspace', async () => { - await workspacesPage.visitFirstClinic(); - await clinicWorkspacePage.waitForLoadState(); // Wait for clinic page elements - }); - await base_1.test.step('Given two patients exist', async () => { - // Add first patient - await clinicWorkspacePage.openAndFillAddPatientDialog(patientName1, patientBirthdate); - await clinicWorkspacePage.submitAddPatientDialog(); - await clinicWorkspacePage.closeBringDataDialog(); - // Ensure the first patient is added before adding the second - await (0, base_1.expect)(clinicWorkspacePage.getPatientCellByName(patientName1)).toBeVisible({ - timeout: 10000, - }); - // Add second patient - await clinicWorkspacePage.openAndFillAddPatientDialog(patientName2, patientBirthdate); - await clinicWorkspacePage.submitAddPatientDialog(); - await clinicWorkspacePage.closeBringDataDialog(); - // Ensure the second patient is also added - await (0, base_1.expect)(clinicWorkspacePage.getPatientCellByName(patientName2)).toBeVisible({ - timeout: 10000, - }); - }); - }); - (0, base_1.test)('should successfully filter patients by name', async () => { - await base_1.test.step("When user filters by the first patient's name", async () => { - await clinicWorkspacePage.searchForPatient(patientName1); - }); - await base_1.test.step('Then only the first patient should be visible', async () => { - const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); - const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); - await (0, base_1.expect)(patientCell1).toBeVisible(); - await (0, base_1.expect)(patientCell2).not.toBeVisible(); - }); - await base_1.test.step('When user clears the filter', async () => { - // Assuming a method like clearPatientSearch exists or searchForPatient('') clears - await clinicWorkspacePage.searchForPatient(''); // Clear search by searching for empty string - // Or potentially: await clinicWorkspacePage.clearPatientSearch(); - }); - await base_1.test.step('Then both patients should be visible again', async () => { - const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); - const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); - await (0, base_1.expect)(patientCell1).toBeVisible(); - await (0, base_1.expect)(patientCell2).toBeVisible(); - }); - }); -}); diff --git a/build/tests/fixtures/account-helpers.js b/build/tests/fixtures/account-helpers.js deleted file mode 100644 index 4532eef..0000000 --- a/build/tests/fixtures/account-helpers.js +++ /dev/null @@ -1,123 +0,0 @@ -"use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.test = void 0; -const base_1 = require("@fixtures/base"); -const AccountNavigation_1 = __importDefault(require("@pom/account/AccountNavigation")); -/** - * Switch user authentication context by loading different storageState - * @param userType - The user type corresponding to the storageState file (e.g., 'shared', 'clinician', 'claimed') - * @param page - The Playwright page instance - */ -async function switchUser(userType, page) { - try { - // Import fs dynamically - const fs = await Promise.resolve().then(() => __importStar(require('node:fs'))); - // Load the specified user's storage state - const storageStatePath = `tests/.auth/${userType}.json`; - const storageState = JSON.parse(fs.readFileSync(storageStatePath, 'utf-8')); - // Clear existing cookies first - await page.context().clearCookies(); - // Set cookies from the new user's storage state - if (storageState.cookies) { - await page.context().addCookies(storageState.cookies); - } - // Set localStorage from the new user's storage state - if (storageState.origins) { - for (const origin of storageState.origins) { - await page.addInitScript(originData => { - if (originData.localStorage) { - for (const item of originData.localStorage) { - localStorage.setItem(item.name, item.value); - } - } - }, origin); - } - } - console.log(`āœ… Successfully switched to ${userType} user authentication`); - } - catch (error) { - throw new Error(`Failed to switch to ${userType} user: ${error}`); - } -} -/** - * Core navigation function that handles account navigation consistently - */ -async function navigateTo(targetPage, page) { - const nav = new AccountNavigation_1.default(page); - const pageConfig = nav.pages[targetPage]; - try { - // Single page check at start - if (page.isClosed()) - return; - // Quick DOM ready check only - await page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => { }); - // Open navigation menu if needed (only for non-AccountNav targets) - if (targetPage !== 'AccountNav') { - const menuVisible = await nav.pages.AccountNav.verifyElement - .isVisible({ timeout: 1000 }) - .catch(() => false); - if (!menuVisible) { - await nav.pages.AccountNav.link.click(); - await nav.pages.AccountNav.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); - } - } - // Handle logout specially - if (targetPage === 'Logout') { - await pageConfig.link.click(); - await page - .waitForURL(/.*login.*/, { waitUntil: 'domcontentloaded', timeout: 5000 }) - .catch(() => { }); - } - else { - // Standard navigation - click and verify - await pageConfig.link.click(); - await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); - } - } - catch (error) { - if (!page.isClosed()) - throw error; - } -} -const test = base_1.test; -exports.test = test; -test.account = { - navigateTo, - switchUser, -}; diff --git a/build/tests/fixtures/base.js b/build/tests/fixtures/base.js deleted file mode 100644 index 2c7e91d..0000000 --- a/build/tests/fixtures/base.js +++ /dev/null @@ -1,262 +0,0 @@ -"use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -Object.defineProperty(exports, "__esModule", { value: true }); -exports.expect = exports.test = void 0; -exports.step = step; -const test_1 = require("@playwright/test"); -const fs = __importStar(require("node:fs")); -const path = __importStar(require("node:path")); -// Define the test type with custom fixtures -exports.test = test_1.test.extend({ - page: async ({ page }, use, testInfo) => { - const modifiedTestInfo = testInfo; - modifiedTestInfo.snapshotSuffix = ''; - modifiedTestInfo.snapshotPath = name => `${testInfo.file}-snapshots/${name}`; - // Make testInfo globally available for network helpers - globalThis.testInfo = testInfo; - try { - await use(page); - } - finally { - // Clean up after test - delete globalThis.testInfo; - } - }, - timeLogger: [ - async ({ page }, use, testInfo) => { - testInfo.annotations.push({ - type: 'Start', - description: new Date().toISOString(), - }); - await use(page); - testInfo.annotations.push({ - type: 'End', - description: new Date().toISOString(), - }); - }, - { auto: true }, - ], - timeStepLogger: [ - async ({ page }, use, testInfo) => { - const startTime = Date.now(); - console.time(`[test] ${testInfo.title}`); - await use(page); - console.timeEnd(`[test] ${testInfo.title}`); - const endTime = Date.now(); - const duration = endTime - startTime; - testInfo.annotations.push({ - type: 'Duration', - description: `${duration}ms`, - }); - testInfo.annotations.push({ - type: 'End', - description: new Date().toISOString(), - }); - }, - { auto: true }, - ], - stepTimer: [ - async ({ page }, use, testInfo) => { - const originalStep = exports.test.step; - const stepTimings = new Map(); - // Create a new step function with the same interface as the original - const newStep = function newStepWrapper(name, fn) { - return originalStep.call(this, name, async (stepInfo) => { - const startTime = Date.now(); - console.time(`[step] ${name}`); - const result = await fn(stepInfo); - console.timeEnd(`[step] ${name}`); - const endTime = Date.now(); - const duration = endTime - startTime; - stepTimings.set(name, duration); - testInfo.annotations.push({ - type: `Step Duration: ${name}`, - description: `${duration}ms`, - }); - return result; - }); - }; - // Add the skip method to match the original test.step interface - newStep.skip = function skipStep(name, fn) { - return originalStep.skip.call(this, name, fn); - }; - // Replace the original step with our enhanced version - exports.test.step = newStep; - await use(page); - // Restore original test.step - exports.test.step = originalStep; - }, - { auto: true }, - ], - stepScreenshoter: [ - async ({ page }, use, testInfo) => { - const originalStep = exports.test.step; - let stepCounter = 0; - // Create a safe directory name based on test info - const testDirName = path.basename(testInfo.file, '.spec.ts').replace(/[^a-z0-9]/gi, '-'); - const screenshotDir = path.join('test-results', testDirName); - // Store current step name for network helpers - let currentStepName = ''; - // Make step counter accessible globally for network helper - globalThis.stepCounter = { - get: () => stepCounter, - increment: () => { - stepCounter += 1; - return stepCounter; - }, - getDirectory: () => screenshotDir, - getCurrentStepName: () => currentStepName, - setCurrentStepName: (name) => { - currentStepName = name; - }, - }; - // Clean up existing screenshots from previous runs - try { - await fs.promises.access(screenshotDir); - await fs.promises.rm(screenshotDir, { recursive: true, force: true }); - } - catch { - // Directory doesn't exist, no need to clean up - } - // Create a new step function that takes screenshots after completion and attaches them to the report - const newStep = function newStepScreenshot(name, fn) { - return originalStep.call(this, name, async (stepInfo) => { - // Set current step name for network helpers (clean name without [no-screenshot]) - const stepCounterObj = globalThis.stepCounter; - if (stepCounterObj) { - const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); - stepCounterObj.setCurrentStepName(cleanName); - } - const result = await fn(stepInfo); - // Skip screenshot if step name contains [no-screenshot] - if (name.includes('[no-screenshot]')) { - return result; - } - // Take screenshot after step completion - stepCounter += 1; - try { - if (!page.isClosed()) { - // Use clean name for filename (without [no-screenshot]) - const cleanName = name.replace(/\s*\[no-screenshot\]\s*/g, '').trim(); - const screenshotName = `step-${stepCounter.toString().padStart(2, '0')}-${cleanName.toLowerCase().replace(/[^a-z0-9]/g, '-')}.png`; - // Take screenshot directly to buffer (no local file) - const screenshot = await page.screenshot({ - fullPage: true, - }); - // Attach to Playwright report AND force test-results folder creation - if (testInfo && typeof testInfo.attach === 'function') { - await testInfo.attach(screenshotName, { - body: screenshot, - contentType: 'image/png', - }); - // Also save to test-results for organized viewing (single source) - const testResultsDir = path.join(testInfo.outputDir, 'attachments'); - await fs.promises.mkdir(testResultsDir, { recursive: true }); - const screenshotPath = path.join(testResultsDir, screenshotName); - await fs.promises.writeFile(screenshotPath, screenshot); - } - } - } - catch (error) { - // Screenshot capture failed, continue without screenshot - } - return result; - }); - }; - // Add the skip method to match the original test.step interface - newStep.skip = function skipStepScreenshot(name, fn) { - return originalStep.skip.call(this, name, fn); - }; - // Add a custom stepNoScreenshot function for API validation steps - const stepNoScreenshot = function stepNoScreenshot(name, fn) { - return originalStep.call(this, name, async (stepInfo) => { - // Set current step name for network helpers (clean name) - const stepCounterObj = globalThis.stepCounter; - if (stepCounterObj) { - stepCounterObj.setCurrentStepName(name); - } - const result = await fn(stepInfo); - // No screenshot taken for this step type - // console.log(`ā­ļø API step completed without screenshot: ${name}`); - return result; - }); - }; - // Replace the original step with our enhanced version - exports.test.step = newStep; - // Add the no-screenshot step function to the test object - exports.test.stepNoScreenshot = stepNoScreenshot; - await use(page); - // Restore original test.step - exports.test.step = originalStep; - }, - { auto: true }, - ], - exceptionLogger: [ - async ({ page }, use, testInfo) => { - const errors = []; - page.on('pageerror', (error) => { - errors.push(error); - }); - await use(page); - if (errors.length > 0) { - await testInfo.attach('frontend-exceptions', { - body: errors.map(error => `${error.message}\n${error.stack}`).join('\n---------\n'), - }); - throw new Error('Some frontend exceptions occurred'); - } - }, - { auto: true }, - ], -}); -var test_2 = require("@playwright/test"); -Object.defineProperty(exports, "expect", { enumerable: true, get: function () { return test_2.expect; } }); -/** - * Decorator function for wrapping POM methods in a test.step. - * - * Use it without a step name `@step()`. - * - * Or with a step name `@step("Search something")`. - * - * @param stepName - The name of the test step. - * @returns A decorator function that can be used to decorate test methods. - */ -function step(stepName) { - return function decorator(target, context) { - return function replacementMethod(...args) { - const name = `${stepName || context.name} (${this.name})`; - return exports.test.step(name, async () => await target.call(this, ...args)); - }; - }; -} diff --git a/build/tests/fixtures/clinic-helpers.js b/build/tests/fixtures/clinic-helpers.js deleted file mode 100644 index b328d86..0000000 --- a/build/tests/fixtures/clinic-helpers.js +++ /dev/null @@ -1,280 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.test = void 0; -const base_1 = require("@fixtures/base"); -const ClinicianNavigation_1 = __importDefault(require("../../page-objects/clinician/ClinicianNavigation")); -const ClinicianDashboardPage_1 = __importDefault(require("../../page-objects/clinician/ClinicianDashboardPage")); -const AccountNavigation_1 = __importDefault(require("../../page-objects/account/AccountNavigation")); -/** - * Initialize clinician navigation helpers after login - */ -async function setupClinicianSession(page) { - // Wait for clinician navigation to be available - const nav = new ClinicianNavigation_1.default(page); - // Navigate to login and setup clinic session if needed - if (!page.url().includes('clinic-workspace')) { - await page.goto('/login'); - // Add any necessary login steps here - } - console.log('šŸ„ Clinic session setup complete'); - return nav; -} -/** - * Navigate to workspace selection page - */ -async function navigateToWorkspaceSelection(page) { - const accountNav = new AccountNavigation_1.default(page); - // Open the account navigation menu first - await accountNav.pages.AccountNav.link.click(); - // Then click the ManageWorkspaces option - await accountNav.pages.ManageWorkspaces.link.click(); - // Verify we're on the workspace selection page using the known verification element - await accountNav.pages.ManageWorkspaces.verifyElement.waitFor({ - state: 'visible', - timeout: 5000, - }); - // console.log('āœ… Navigated to workspace selection page'); -} -/** - * Navigate to a specific workspace using hardcoded workspace key - */ -async function navigateToWorkspace(workspaceKey, page) { - const clinicianNav = new ClinicianNavigation_1.default(page); - // First navigate to workspace selection if not already there - if (!page.url().includes('workspaces')) { - await navigateToWorkspaceSelection(page); - } - // Click on the specific workspace using the page object locator - await clinicianNav.workspaces[workspaceKey].link.click(); - // Verify we're in the correct workspace using URL verification - await page.waitForURL(new RegExp(clinicianNav.workspaces[workspaceKey].verifyURL), { - timeout: 5000, - }); - // console.log(`āœ… Successfully navigated to workspace: ${clinicianNav.workspaces[workspaceKey].name}`); -} -/** - * Core navigation function that handles workspace prerequisites and page navigation - */ -async function navigateTo(targetPage, page, workspaceKey) { - const clinicianNav = new ClinicianNavigation_1.default(page); - const pageConfig = clinicianNav.pages[targetPage]; - // Ensure we're in a workspace context (but don't auto-switch if already in one) - const isInWorkspaceContext = page.url().includes('clinic-workspace') || - page.url().includes('/patients/') || - page.url().includes('/profile'); - if (!isInWorkspaceContext) { - const defaultWorkspace = workspaceKey || 'AdminClinicBase'; - await navigateToWorkspace(defaultWorkspace, page); - } - else if (workspaceKey) { - // Only switch if specifically requested and we can verify we're in wrong workspace - const currentUrl = page.url(); - const targetWorkspacePattern = clinicianNav.workspaces[workspaceKey].verifyURL; - if (!currentUrl.includes(targetWorkspacePattern)) { - await navigateToWorkspace(workspaceKey, page); - } - } - // Handle page-specific prerequisites - if (targetPage === 'AddPatient') { - // AddPatient might need to be on PatientList first - if (!page.url().includes('patients')) { - await clinicianNav.pages.PatientList.link.click(); - await clinicianNav.pages.PatientList.verifyElement.waitFor({ - state: 'visible', - timeout: 5000, - }); - } - } - // Perform the actual navigation - try { - await pageConfig.link.click(); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.log(`Failed to click ${targetPage}: ${errorMessage}`); - throw error; - } - // Verify navigation succeeded - try { - if (pageConfig.verifyURL) { - await page.waitForURL(`**/*${pageConfig.verifyURL}*`, { timeout: 5000 }); - } - if (pageConfig.verifyElement) { - await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); - } - // console.log(`āœ… Navigated to page: ${targetPage}`); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - // console.log(`Navigation verification failed for ${targetPage}: ${errorMessage}`); - } -} -/** - * Execute test logic across multiple workspaces - */ -async function executeAcrossWorkspaces(workspaceConfigs, action, page) { - for (const config of workspaceConfigs) { - console.log(`šŸ”„ Executing across workspace: ${config.workspaceKey}`); - // Navigate to the workspace - await navigateToWorkspace(config.workspaceKey, page); - // Execute the action - await action(config); - // Navigate back to workspace selection for next iteration - if (workspaceConfigs.indexOf(config) < workspaceConfigs.length - 1) { - await navigateToWorkspaceSelection(page); - } - } -} -/** - * Find and access any available patient (fastest option) - * @param page - The Playwright page object - * @returns The full name of the first patient that was accessed - */ -async function findAndAccessAnyPatient(page) { - const dashboard = new ClinicianDashboardPage_1.default(page); - try { - // Clear search to show all patients - await dashboard.searchInput.click(); - await dashboard.searchInput.fill(' '); - await page.waitForTimeout(500); - await dashboard.searchInput.fill(''); - await page.waitForTimeout(1500); - let allCells = await dashboard.patientListTable.getByRole('cell').all(); - // If no cells, try pressing Enter on empty search - if (allCells.length === 0) { - await dashboard.searchInput.press('Enter'); - await page.waitForTimeout(1500); - allCells = await dashboard.patientListTable.getByRole('cell').all(); - } - // Find the first cell that looks like a patient name - for (const cell of allCells) { - const cellText = await cell.textContent(); - if (cellText && cellText.trim().length > 3 && cellText.includes(' ')) { - await cell.click(); - await page.waitForTimeout(800); - return cellText.trim(); - } - } - throw new Error('No patient names found in table'); - } - catch (error) { - throw new Error(`Failed to find any patient: ${error}`); - } -} -/** - * Find and access any patient whose name contains the search term (optimized version) - * @param searchTerm - Partial name to search for (e.g., "Custodial") - * @param page - The Playwright page object - * @returns The full name of the patient that was accessed - */ -async function findAndAccessPatientByPartialName(searchTerm, page) { - const dashboard = new ClinicianDashboardPage_1.default(page); - // If empty search term, find any available patient - if (!searchTerm || searchTerm.trim() === '') { - return findAndAccessAnyPatient(page); - } - // Strategy 1: Fill search field THEN click Show All (proven fastest method) - try { - await dashboard.searchInput.fill(searchTerm); - await page.waitForTimeout(500); - const showAllButton = page - .getByRole('button', { name: 'Show All' }) - .or(page.getByRole('button', { name: 'Show all' })) - .or(page.getByText('Show All')) - .or(page.getByText('Show all')); - if (await showAllButton.isVisible({ timeout: 1000 })) { - await showAllButton.click(); - await page.waitForTimeout(1000); - const searchResultCells = await dashboard.patientListTable.getByRole('cell').all(); - if (searchResultCells.length > 0) { - for (const cell of searchResultCells) { - const cellText = await cell.textContent(); - if (cellText && cellText.toLowerCase().includes(searchTerm.toLowerCase())) { - await cell.click(); - await page.waitForTimeout(600); - return cellText.trim(); - } - } - } - } - else { - await dashboard.searchInput.press('Enter'); - await page.waitForTimeout(1000); - const searchResultCells = await dashboard.patientListTable.getByRole('cell').all(); - if (searchResultCells.length > 0) { - for (const cell of searchResultCells) { - const cellText = await cell.textContent(); - if (cellText && cellText.toLowerCase().includes(searchTerm.toLowerCase())) { - await cell.click(); - await page.waitForTimeout(600); - return cellText.trim(); - } - } - } - } - } - catch (error) { - // Silent fallback to any patient - } - // Strategy 2: Fallback to any available patient if specific search fails - try { - return await findAndAccessAnyPatient(page); - } - catch (fallbackError) { - throw new Error(`No patient found containing "${searchTerm}" and no fallback patients available`); - } -} -/** - * Access a specific patient by name and navigate to their summary page - * @param patientName - The name of the patient to access - * @param page - The Playwright page object - */ -async function accessPatient(patientName, page) { - const dashboard = new ClinicianDashboardPage_1.default(page); - console.log(`šŸ” Searching for patient: ${patientName}`); - // Try optimized search first - await dashboard.searchForPatient(patientName); - await page.waitForTimeout(1000); // Reduced wait time - // Check if search worked - const patientCell = dashboard.getPatientCellByName(patientName); - const isVisible = await patientCell.isVisible({ timeout: 2000 }); - if (isVisible) { - console.log(`šŸ‘¤ Found patient via search: ${patientName}`); - await patientCell.click(); - await page.waitForTimeout(1000); - console.log(`āœ… Successfully accessed patient summary for: ${patientName}`); - return; - } - // If search failed, fall back to show all + find - console.log(`šŸ”„ Search failed, trying show all approach...`); - const showAllButton = page.getByRole('button', { name: 'Show All' }); - if (await showAllButton.isVisible({ timeout: 1000 })) { - await showAllButton.click(); - await page.waitForTimeout(1500); - } - // Try again after showing all - const isVisibleAfterShowAll = await patientCell.isVisible({ timeout: 2000 }); - if (isVisibleAfterShowAll) { - await patientCell.click(); - await page.waitForTimeout(1000); - // console.log(`āœ… Successfully accessed patient summary for: ${patientName}`); - return; - } - // If still not found, throw error - throw new Error(`Patient "${patientName}" not found in current workspace`); -} -const test = base_1.test; -exports.test = test; -test.clinician = { - navigateTo, - navigateToWorkspace, - navigateToWorkspaceSelection, - executeAcrossWorkspaces, - accessPatient, - findAndAccessPatientByPartialName, - findAndAccessAnyPatient, - setup: setupClinicianSession, -}; diff --git a/build/tests/fixtures/network-helpers.js b/build/tests/fixtures/network-helpers.js deleted file mode 100644 index ea7dd18..0000000 --- a/build/tests/fixtures/network-helpers.js +++ /dev/null @@ -1,480 +0,0 @@ -"use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -Object.defineProperty(exports, "__esModule", { value: true }); -exports.NetworkHelper = void 0; -exports.createNetworkHelper = createNetworkHelper; -const fs = __importStar(require("node:fs")); -const path = __importStar(require("node:path")); -const endpoint_registry_1 = require("../../endpoint-schema/endpoint-registry"); -const ENDPOINTS = { - profile: /\/data\/[^\/]+$/, // GET requests for patient data - profileUpdate: /\/data\/[^\/]+$/, // PUT requests for patient data updates - profileMetrics: /\/metrics\/thisuser\//, - profileMessage: /\/message\/notes\//, -}; -/** - * Simple network helper for API validation - */ -class NetworkHelper { - constructor(page) { - this.captures = []; - this.isCapturing = false; - this.page = page; - } - async startCapture() { - if (this.isCapturing) - return; - // Only intercept API requests we care about to avoid interfering with other requests - const apiPatterns = [ - '**/data/**', - '**/metrics/**', - '**/message/**', - '**/auth/**', - '**/v1/**', - '**/metadata/**', - '**/user/**', - '**/users/**', - '**/profile/**', - ]; - for (const pattern of apiPatterns) { - await this.page.route(pattern, async (route) => { - const request = route.request(); - try { - const response = await route.fetch(); - let requestBody; - let responseBody; - try { - requestBody = request.postDataJSON(); - } - catch { - requestBody = request.postData(); - } - try { - responseBody = await response.json(); - } - catch { - responseBody = await response.text(); - } - this.captures.push({ - url: request.url(), - method: request.method(), - requestBody, - responseBody, - statusCode: response.status(), - timestamp: Date.now(), - }); - await route.fulfill({ response }); - } - catch (error) { - // If there's an error, continue the request without handling - try { - await route.continue(); - } - catch { - // Route might already be handled, ignore - } - } - }); - } - this.isCapturing = true; - } - async stopCapture() { - if (!this.isCapturing) - return; - // Remove all API route handlers - const apiPatterns = ['**/data/**', '**/metrics/**', '**/message/**', '**/auth/**', '**/v1/**']; - for (const pattern of apiPatterns) { - await this.page.unroute(pattern); - } - this.isCapturing = false; - } - async waitForEndpoint(endpointName, method, timeout = 30000) { - const pattern = ENDPOINTS[endpointName]; - if (!pattern) { - throw new Error(`Unknown endpoint: ${endpointName}`); - } - const startTime = Date.now(); - while (Date.now() - startTime < timeout) { - const matches = this.captures.filter(capture => pattern.test(capture.url) && capture.method.toLowerCase() === method.toLowerCase()); - if (matches.length > 0) { - return matches[matches.length - 1]; // Return latest match - } - await this.page.waitForTimeout(100); - } - throw new Error(`${method} request to ${endpointName} not found within ${timeout}ms`); - } - getCaptures() { - return [...this.captures]; - } - /** - * Simple helper to validate endpoint requests by URL pattern and method - */ - validateEndpointRequests(urlPattern, method) { - return this.captures.filter(c => c.url.includes(urlPattern) && c.method === method); - } - /** - * Save all captures to a JSON file - */ - async saveCapturesTo(filename, testInfo) { - const logDir = path.join(process.cwd(), 'log'); - if (!fs.existsSync(logDir)) { - fs.mkdirSync(logDir, { recursive: true }); - } - // Create capture data - const captureData = { - timestamp: new Date().toISOString(), - totalCaptures: this.captures.length, - captures: this.captures, - }; - // Use Playwright's automatic attachment instead of manual file writing - if (testInfo && typeof testInfo.attach === 'function') { - await testInfo.attach(filename, { - body: JSON.stringify(captureData, null, 2), - contentType: 'application/json', - }); - console.log(`šŸ“„ Network captures attached to Playwright report: ${filename}`); - } - else { - console.log(`šŸ“„ Network captures ready (${this.captures.length} captures)`); - } - } - /** - * Print a summary of all captures to console - */ - printCaptureSummary() { - console.log(`\nšŸ“Š Network Capture Summary (${this.captures.length} total requests):`); - console.log('='.repeat(60)); - this.captures.forEach((capture, index) => { - const timestamp = new Date(capture.timestamp).toLocaleTimeString(); - console.log(`${index + 1}. ${capture.method} ${capture.statusCode} - ${capture.url}`); - console.log(` Time: ${timestamp}`); - if (capture.requestBody) { - console.log(` Request: ${JSON.stringify(capture.requestBody).substring(0, 100)}...`); - } - console.log(''); - }); - } - /** - * Get captures filtered by status code - */ - getCapturesByStatus(statusCode) { - return this.captures.filter(c => c.statusCode === statusCode); - } - /** - * Get the most recent capture matching method and URL pattern - */ - getLatestCaptureMatching(method, urlPattern) { - const matches = this.captures - .filter(c => c.method === method && urlPattern.test(c.url)) - .sort((a, b) => b.timestamp - a.timestamp); - return matches.length > 0 ? matches[0] : null; - } - /** - * Get all captures for a specific endpoint - */ - getCapturesForEndpoint(endpointName) { - const pattern = ENDPOINTS[endpointName]; - if (!pattern) { - throw new Error(`Unknown endpoint: ${endpointName}`); - } - return this.captures.filter(c => pattern.test(c.url)); - } - /** - * Get all captures - */ - getAllCaptures() { - return [...this.captures]; - } - /** - * Save API response as JSON attachment and to organized test-results folder - */ - async saveApiResponse(response, endpoint, method, fileName, testInfo) { - const responseData = { - _request: { - method, - endpoint, - }, - ...response, - }; - const jsonContent = JSON.stringify(responseData, null, 2); - // Attach to Playwright report AND save to organized test-results folder - if (testInfo && typeof testInfo.attach === 'function') { - await testInfo.attach(fileName, { - body: jsonContent, - contentType: 'application/json', - }); - // Also save to test-results for organized viewing (like screenshots) - const testResultsDir = path.join(testInfo.outputDir, 'attachments'); - await fs.promises.mkdir(testResultsDir, { recursive: true }); - const jsonPath = path.join(testResultsDir, fileName); - await fs.promises.writeFile(jsonPath, jsonContent, 'utf8'); - } - } - /** - * Validate and save API response for any endpoint defined in the endpoint registry - * @param endpointName - The endpoint name from the registry (e.g., 'profile-metadata-get') - * @returns The captured network request or null if not found - */ - async validateEndpointResponse(endpointName) { - const schema = (0, endpoint_registry_1.getEndpointSchema)(endpointName); - const request = this.getLatestCaptureMatching(schema.method, schema.url); - if (request?.responseBody) { - // Access the shared step counter from the stepScreenshoter fixture - const stepCounterObj = globalThis.stepCounter; - if (stepCounterObj) { - const stepNumber = stepCounterObj.increment(); - const currentStepName = stepCounterObj.getCurrentStepName(); - // Create consistent filename with step number and step name (like screenshots) - const stepNameForFile = currentStepName - ? currentStepName.toLowerCase().replace(/[^a-z0-9]/g, '-') - : endpointName.replace(/[^a-z0-9]/gi, '-'); - const fileName = `step-${stepNumber.toString().padStart(2, '0')}-${stepNameForFile}-response.json`; - await this.saveApiResponse(request.responseBody, request.url, schema.method, fileName, globalThis.testInfo); - } - } - return request; - } - /** - * Save network capture for producer/consumer test patterns - * @param endpointName - The endpoint to save - * @param testName - Name of the test (used for file naming) - * @returns The saved network capture or null - */ - async saveForDependentTests(endpointName, testName) { - const schema = (0, endpoint_registry_1.getEndpointSchema)(endpointName); - const capture = this.getLatestCaptureMatching(schema.method, schema.url); - if (capture) { - // Create step-based filename for better organization - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const stepName = testName.toLowerCase().replace(/[^a-z0-9]/g, '-'); - const fileName = `step-api-${stepName}-${endpointName.replace(/[^a-z0-9]/gi, '-')}-${timestamp}.json`; - console.log(`āœ… Saved ${endpointName} response for dependent tests`); - // Use Playwright's automatic attachment instead of file system - const { testInfo } = globalThis; - if (testInfo && typeof testInfo.attach === 'function') { - await testInfo.attach(fileName, { - body: JSON.stringify(capture, null, 2), - contentType: 'application/json', - }); - } - return capture; - } - return null; - } - /** - * Load producer test data for consumer tests - * @param testName - Name of the producer test (used for file naming) - * @returns The loaded network capture or null - */ - loadFromProducerTest(testName) { - const filePath = path.join(process.cwd(), 'log', 'test-data-pipeline', `${testName}-response.json`); - if (fs.existsSync(filePath)) { - const fileContent = fs.readFileSync(filePath, 'utf-8'); - const capture = JSON.parse(fileContent); - console.log(`āœ… Loaded ${testName} response from producer test`); - return capture; - } - throw new Error(`Producer test data not found at: ${filePath}. Please run ${testName} test first.`); - } - /** - * Validate data consistency between producer and consumer responses - * @param producerCapture - The producer test network capture - * @param consumerCapture - The consumer test network capture - * @param fieldsToValidate - Array of field paths to validate (e.g., ['fullName', 'patient.birthday']) - * @param requiredFields - Array of fields that must exist and match (defaults to common required fields) - */ - validateDataConsistency(producerCapture, consumerCapture, fieldsToValidate, requiredFields = ['fullName']) { - // Use provided fields or fall back to a basic set for backward compatibility - const defaultFields = ['fullName', 'patient.fullName', 'patient.birthday', 'email']; - const fieldsToCheck = fieldsToValidate || defaultFields; - const producerData = producerCapture.responseBody; - const consumerData = consumerCapture.responseBody; - if (!producerData || !consumerData) { - throw new Error('Missing response data for consistency validation'); - } - console.log('šŸ” Validating data consistency:'); - // Only log full data in development mode - if (process.env.VERBOSE_VALIDATION) { - console.log('Producer:', JSON.stringify(producerData, null, 2)); - console.log('Consumer:', JSON.stringify(consumerData, null, 2)); - } - else { - console.log('Producer fullName:', producerData.fullName); - console.log('Consumer fullName:', consumerData.fullName); - } - // Validate each specified field - for (const fieldPath of fieldsToCheck) { - const producerValue = this.getNestedValue(producerData, fieldPath); - const consumerValue = this.getNestedValue(consumerData, fieldPath); - // Check if this field is marked as required - const isRequired = requiredFields.includes(fieldPath); - if (isRequired) { - if (producerValue === undefined || producerValue === null) { - throw new Error(`Required field ${fieldPath} is missing in producer data`); - } - if (consumerValue === undefined || consumerValue === null) { - throw new Error(`Required field ${fieldPath} is missing in consumer data`); - } - } - // For optional fields: only validate if the field exists in producer data - // If it exists in producer, it must also exist in consumer with same value - if (producerValue !== undefined && producerValue !== null) { - // Handle array comparison - if (Array.isArray(producerValue) && Array.isArray(consumerValue)) { - if (JSON.stringify(producerValue) !== JSON.stringify(consumerValue)) { - throw new Error(`${fieldPath} mismatch - Expected: ${JSON.stringify(producerValue)}, Got: ${JSON.stringify(consumerValue)}`); - } - } - else if (producerValue !== consumerValue) { - throw new Error(`${fieldPath} mismatch - Expected: ${producerValue}, Got: ${consumerValue}`); - } - } - // If producer value doesn't exist, consumer doesn't need to have it either (optional field) - } - console.log('āœ… Data consistency validated: consumer data reflects producer changes'); - } - /** - * Helper method to get nested object values using dot notation - * @param obj - The object to search - * @param path - The dot-notation path (e.g., 'patient.birthday') - * @returns The value at the path or undefined - */ - getNestedValue(obj, propertyPath) { - return propertyPath.split('.').reduce((current, key) => current?.[key], obj); - } - /** - * Validate producer-consumer data consistency for profile endpoints - * @param producerEndpointName - The PUT endpoint name (e.g., 'profile-metadata-put') - * @param consumerEndpointName - The GET endpoint name (e.g., 'profile-metadata-get') - * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) - * @throws Error if validation fails - */ - async validateProducerConsumerData(producerEndpointName, consumerEndpointName, fieldsToValidate) { - const producerSchema = (0, endpoint_registry_1.getEndpointSchema)(producerEndpointName); - const consumerSchema = (0, endpoint_registry_1.getEndpointSchema)(consumerEndpointName); - // Use provided fields, or consumer endpoint validation fields, or producer endpoint validation fields - const validationFields = fieldsToValidate || - consumerSchema.validationFields || - producerSchema.validationFields || ['fullName', 'email']; - // Use consumer endpoint required fields, or producer endpoint required fields, or default - const requiredFields = consumerSchema.requiredFields || - producerSchema.requiredFields || ['fullName']; - const producerCapture = this.getLatestCaptureMatching(producerSchema.method, producerSchema.url); - const consumerCapture = this.getLatestCaptureMatching(consumerSchema.method, consumerSchema.url); - if (!producerCapture) { - throw new Error(`No ${producerEndpointName} capture found for producer validation`); - } - if (!consumerCapture) { - throw new Error(`No ${consumerEndpointName} capture found for consumer validation`); - } - this.validateDataConsistency(producerCapture, consumerCapture, validationFields, requiredFields); - } - /** - * Private method to validate endpoint response without generating JSON file - * @param endpointName - The endpoint name from the registry - * @returns The captured network request or null if not found - */ - validateEndpointResponseSilent(endpointName) { - const schema = (0, endpoint_registry_1.getEndpointSchema)(endpointName); - const request = this.getLatestCaptureMatching(schema.method, schema.url); - return request; - } - /** - * Complete validation workflow for a user viewing profile data - * Validates both API schema and data consistency in one call - * @param consumerEndpointName - The GET endpoint name - * @param producerCapture - The stored PUT capture from the producer - * @param fieldsToValidate - Optional array of fields to validate (overrides endpoint schema) - */ - async compareEndpointResponse(consumerEndpointName, producerCapture, fieldsToValidate) { - // Get the endpoint schema to determine validation fields - const consumerSchema = (0, endpoint_registry_1.getEndpointSchema)(consumerEndpointName); - // Use provided fields, or endpoint-specific fields, or fall back to basic fields - const validationFields = fieldsToValidate || - consumerSchema.validationFields || ['fullName', 'patient.fullName', 'email']; - // Use endpoint-specific required fields, or default to fullName for backward compatibility - const requiredFields = consumerSchema.requiredFields || ['fullName']; - // Validate GET response schema without generating JSON file - const consumerCapture = this.validateEndpointResponseSilent(consumerEndpointName); - if (!consumerCapture) { - throw new Error(`No compare endpoint found`); - } - if (!producerCapture) { - throw new Error('No base endpoint found'); - } - // Generate comparison JSON file similar to validateEndpointResponse - const stepCounterObj = globalThis.stepCounter; - if (stepCounterObj) { - // Increment for JSON file naming (this is correct behavior) - const stepNumber = stepCounterObj.increment(); - const currentStepName = stepCounterObj.getCurrentStepName(); - // Create comparison data object - const comparisonData = { - _comparison: { - description: `Data consistency comparison for ${consumerEndpointName}`, - timestamp: new Date().toISOString(), - fieldsValidated: validationFields, - requiredFields, - }, - original: { - url: producerCapture.url, - method: producerCapture.method, - timestamp: producerCapture.timestamp, - responseBody: producerCapture.responseBody, - }, - new: { - url: consumerCapture.url, - method: consumerCapture.method, - timestamp: consumerCapture.timestamp, - responseBody: consumerCapture.responseBody, - }, - }; - // Create consistent filename with step number and step name (like screenshots) - const stepNameForFile = currentStepName - ? currentStepName.toLowerCase().replace(/[^a-z0-9]/g, '-') - : consumerEndpointName.replace(/[^a-z0-9]/gi, '-'); - const fileName = `step-${stepNumber.toString().padStart(2, '0')}-${stepNameForFile}-comparison.json`; - // Save the comparison data using the unified approach - const { testInfo } = globalThis; - await this.saveApiResponse(comparisonData, consumerCapture.url, consumerCapture.method, fileName, testInfo); - } - // Validate data consistency using the determined validation fields and required fields - this.validateDataConsistency(producerCapture, consumerCapture, validationFields, requiredFields); - } -} -exports.NetworkHelper = NetworkHelper; -function createNetworkHelper(page) { - return new NetworkHelper(page); -} diff --git a/build/tests/fixtures/patient-helpers.js b/build/tests/fixtures/patient-helpers.js deleted file mode 100644 index b47b24c..0000000 --- a/build/tests/fixtures/patient-helpers.js +++ /dev/null @@ -1,484 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.test = void 0; -const base_1 = require("@fixtures/base"); -const PatientNavigation_1 = __importDefault(require("@pom/patient/PatientNavigation")); -const env_1 = __importDefault(require("../../utilities/env")); -/** - * Initialize patient navigation helpers after login - */ -async function setupPatientSession(page) { - // Wait for patient navigation to be available - const nav = new PatientNavigation_1.default(page); - await Promise.all([ - nav.pages.ViewData.link.waitFor({ state: 'visible' }), - nav.pages.Profile.link.waitFor({ state: 'visible' }), - ]); - return nav; -} -/** - * Close any open modal dialogs that might block navigation - */ -async function closeOpenDialogs(page) { - try { - if (page.isClosed()) - return; - // Simple and fast: just press Escape twice to close any modals - await page.keyboard.press('Escape'); - await page.keyboard.press('Escape'); - } - catch (error) { - // Ignore errors in dialog closing - they're not critical - } -} -/** - * Check if we're in a context where patient navigation is supported - */ -async function isInPatientContext(nav, page) { - try { - // Check if any patient navigation elements are visible - const patientElements = [nav.pages.ViewData.link, nav.pages.Profile.link, nav.pages.Share.link]; - for (const element of patientElements) { - if (await element.isVisible({ timeout: 1000 })) { - return true; - } - } - return false; - } - catch { - return false; - } -} -/** - * Get current page state by checking URL and visible elements - */ -async function getCurrentPageState(nav, page) { - const url = page.url(); - // Check each page in order of specificity - for (const [pageName, pageConfig] of Object.entries(nav.pages)) { - try { - if (pageConfig.verifyURL && url.includes(pageConfig.verifyURL)) { - if (pageConfig.verifyElement && - (await pageConfig.verifyElement.isVisible({ timeout: 1000 }))) { - return pageName; - } - } - } - catch { - // Continue checking other pages - } - } - return 'unknown'; -} -/** - * Navigation strategies for different page types - */ -const navigationStrategies = { - // Basic page navigation - default: [ - { - name: 'close-dialogs', - action: async (state) => closeOpenDialogs(state.page), - }, - { - name: 'check-patient-context', - condition: async (state) => !(await isInPatientContext(state.nav, state.page)), - action: async (state) => { - console.log('Not in patient context, navigating to /data URL to reset'); - // Navigate to /data endpoint specifically, not just base URL - await state.page.goto(`${env_1.default.BASE_URL}/data`); - await state.page.waitForLoadState('domcontentloaded'); - // Wait for patient navigation to be available - await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); - console.log('Successfully reset to patient context via /data URL'); - }, - }, - { - name: 'wait-for-loading', - action: async (state) => { - const loading = state.page.getByText('Loading...', { exact: true }); - try { - await loading.waitFor({ state: 'hidden', timeout: 3000 }); - } - catch { - // Loading might not be visible - } - }, - }, - { - name: 'navigate-click', - action: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - await pageConfig.link.click({ timeout: 5000 }); - }, - }, - { - name: 'verify-navigation', - verify: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - if (pageConfig.verifyElement) { - try { - await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); - return true; - } - catch { - return false; - } - } - return true; - }, - }, - ], - // Profile page - handle account settings conflict - Profile: [ - { - name: 'close-dialogs', - action: async (state) => closeOpenDialogs(state.page), - }, - { - name: 'check-patient-context', - condition: async (state) => !(await isInPatientContext(state.nav, state.page)), - action: async (state) => { - console.log('Not in patient context, navigating to /data URL to reset'); - // Navigate to /data endpoint specifically, not just base URL - await state.page.goto(`${env_1.default.BASE_URL}/data`); - await state.page.waitForLoadState('domcontentloaded'); - // Wait for patient navigation to be available - await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); - console.log('Successfully reset to patient context via /data URL'); - }, - }, - { - name: 'handle-account-settings-conflict', - condition: async (state) => state.page.url().includes('/profile') && - (await state.page - .getByRole('heading', { name: /account/i }) - .or(state.page.getByText('Account Settings')) - .or(state.page.getByText('Account')) - .or(state.page.locator('.profile-subnav-title').getByText('Account')) - .isVisible() - .catch(() => false)), - action: async (state) => { - console.log('On account settings page, redirecting to base URL first'); - await state.page.goto(env_1.default.BASE_URL); - await state.page.waitForTimeout(500); - }, - }, - { - name: 'navigate-click', - action: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - await pageConfig.link.click({ timeout: 5000 }); - }, - }, - { - name: 'verify-navigation', - verify: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - if (pageConfig.verifyElement) { - try { - await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); - return true; - } - catch { - return false; - } - } - return true; - }, - }, - ], - // Modal dialogs - modal: [ - { - name: 'close-dialogs', - action: async (state) => closeOpenDialogs(state.page), - }, - { - name: 'navigate-click', - action: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - await pageConfig.link.click({ timeout: 5000 }); - }, - }, - { - name: 'wait-for-modal', - action: async (state) => { - await state.page.waitForTimeout(500); - }, - }, - ], - // Data pages that need ViewData prerequisite - 'data-page': [ - { - name: 'close-dialogs', - action: async (state) => closeOpenDialogs(state.page), - }, - { - name: 'ensure-data-view', - condition: async (state) => !state.page.url().includes('/data/'), - action: async (state) => { - await state.nav.pages.ViewData.link.click(); - await state.nav.pages.ViewData.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); - }, - }, - { - name: 'navigate-click', - action: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - await pageConfig.link.click({ timeout: 5000 }); - }, - }, - { - name: 'verify-navigation', - verify: async (state) => { - const pageConfig = state.nav.pages[state.targetPage]; - if (pageConfig.verifyElement) { - try { - await pageConfig.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); - return true; - } - catch { - return false; - } - } - return true; - }, - }, - ], - // ShareData requires Share main page to be accessible first - ShareData: [ - { - name: 'close-dialogs', - action: async (state) => closeOpenDialogs(state.page), - }, - { - name: 'check-patient-context', - condition: async (state) => !(await isInPatientContext(state.nav, state.page)), - action: async (state) => { - console.log('Not in patient context, navigating to /data URL to reset'); - await state.page.goto(`${env_1.default.BASE_URL}/data`); - await state.page.waitForLoadState('domcontentloaded'); - await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); - console.log('Successfully reset to patient context via /data URL'); - }, - }, - { - name: 'ensure-share-dependency', - action: async (state) => { - // First ensure Share main page is accessible - try { - await state.nav.pages.Share.link.waitFor({ state: 'visible', timeout: 3000 }); - console.log('Share dependency met - Share button is accessible'); - } - catch { - console.log('Share dependency not met - performing URL reset to /data'); - await state.page.goto(`${env_1.default.BASE_URL}/data`); - await state.page.waitForLoadState('domcontentloaded'); - await state.nav.pages.ViewData.link.waitFor({ state: 'visible', timeout: 10000 }); - console.log('URL reset completed, Share dependency should now be available'); - } - }, - }, - { - name: 'navigate-to-share-first', - action: async (state) => { - // Navigate to Share main page first to establish context - try { - await state.nav.pages.Share.link.click({ timeout: 3000 }); - await state.nav.pages.Share.verifyElement.waitFor({ state: 'visible', timeout: 5000 }); - console.log('Successfully navigated to Share main page'); - } - catch { - console.log('Could not reach Share main page, staying in current state'); - } - }, - }, - { - name: 'navigate-to-sharedata', - action: async (state) => { - // Now try to navigate to ShareData sub-page - try { - await state.nav.pages.ShareData.link.click({ timeout: 5000 }); - console.log('Successfully clicked ShareData button'); - } - catch { - console.log('ShareData button not available - this is expected and OK'); - } - }, - }, - { - name: 'verify-navigation', - verify: async (state) => { - // Try to verify ShareData first, fall back to Share if not available - try { - await state.nav.pages.ShareData.verifyElement.waitFor({ - state: 'visible', - timeout: 3000, - }); - console.log('āœ… ShareData page verified'); - return true; - } - catch { - try { - await state.nav.pages.Share.verifyElement.waitFor({ state: 'visible', timeout: 3000 }); - console.log('āœ… Share main page verified (ShareData not available - this is OK)'); - return true; - } - catch { - console.log('Neither ShareData nor Share page could be verified'); - return false; - } - } - }, - }, - ], -}; -/** - * Page type mappings to determine which strategy to use - */ -const pageStrategies = { - ViewData: 'default', - Basics: 'data-page', - Daily: 'data-page', - BGLog: 'data-page', - Trends: 'data-page', - Devices: 'data-page', - Profile: 'Profile', - ProfileEdit: 'default', // TODO: Add prerequisite logic - Share: 'default', - ShareData: 'ShareData', // Uses dependency-aware strategy - UploadData: 'default', - ChartDateRange: 'modal', - ChartDate: 'modal', - Print: 'modal', -}; -/** - * Execute navigation strategy - */ -async function executeNavigationStrategy(state) { - const strategyName = pageStrategies[state.targetPage] || 'default'; - const strategy = navigationStrategies[strategyName]; - console.log(`Executing ${strategyName} strategy for ${state.targetPage}`); - for (const step of strategy) { - try { - // Check condition if present - if (step.condition && !(await step.condition(state))) { - console.log(`Skipping step ${step.name} - condition not met`); - // eslint-disable-next-line no-continue - continue; - } - console.log(`Executing step: ${step.name}`); - // Execute action if present - if (step.action) { - await step.action(state); - } - // Verify if present - if (step.verify && !(await step.verify(state))) { - console.log(`Step ${step.name} verification failed`); - return false; - } - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.log(`Step ${step.name} failed:`, errorMessage); - return false; - } - } - return true; -} -/** - * New scalable navigation function using state machine approach - */ -async function navigateTo(targetPage, page) { - if (page.isClosed()) { - console.log(`Page is closed, cannot navigate to ${targetPage}`); - return; - } - const nav = new PatientNavigation_1.default(page); - const currentPage = await getCurrentPageState(nav, page); - const state = { - currentPage, - targetPage, - nav, - page, - }; - console.log(`Navigating from ${currentPage} to ${targetPage}`); - // Execute primary navigation strategy - const success = await executeNavigationStrategy(state); - if (!success) { - console.log(`Primary navigation failed, trying fallback strategies`); - // Fallback strategy - go to base URL and try again - if (targetPage === 'Profile') { - try { - console.log('Profile fallback: going to base URL and trying again'); - await page.goto(env_1.default.BASE_URL); - await page.waitForTimeout(500); - await nav.pages[targetPage].link.click({ timeout: 3000 }); - console.log(`Successfully navigated to ${targetPage} via fallback`); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.log(`Profile fallback failed: ${errorMessage}`); - throw error; - } - } - else if (nav.pages[targetPage].verifyURL) { - // Generic URL fallback for pages with backup URLs - try { - let fallbackURL = env_1.default.BASE_URL; - // For sub-pages that might not be available, fall back to the main page - if (targetPage === 'ShareData') { - fallbackURL = `${env_1.default.BASE_URL}/share`; // Fall back to main Share page - } - else if (targetPage === 'ProfileEdit') { - fallbackURL = `${env_1.default.BASE_URL}/profile`; // Fall back to main Profile page - } - else if (['Basics', 'Daily', 'BGLog', 'Trends', 'Devices'].includes(targetPage)) { - fallbackURL = `${env_1.default.BASE_URL}/data`; // Fall back to main ViewData page - } - else if (nav.pages[targetPage].verifyURL) { - fallbackURL = `${env_1.default.BASE_URL}/${nav.pages[targetPage].verifyURL}`; - } - await page.goto(fallbackURL); - console.log(`Used backup URL for ${targetPage}: ${fallbackURL}`); - // For sub-pages that fall back to main pages, verify the main page elements - let { verifyElement } = nav.pages[targetPage]; - if (targetPage === 'ShareData') { - verifyElement = nav.pages.Share.verifyElement; // Verify main Share page instead - } - else if (targetPage === 'ProfileEdit') { - verifyElement = nav.pages.Profile.verifyElement; // Verify main Profile page instead - } - else if (['Basics', 'Daily', 'BGLog', 'Trends', 'Devices'].includes(targetPage)) { - verifyElement = nav.pages.ViewData.verifyElement; // Verify main ViewData page instead - } - // Wait for the fallback page to actually load and verify we're there - if (verifyElement) { - await verifyElement.waitFor({ - state: 'visible', - timeout: 10000, - }); - console.log(`āœ… Backup URL navigation to ${targetPage} verified successfully (using fallback verification)`); - } - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.log(`Backup URL failed: ${errorMessage}`); - throw error; - } - } - else { - throw new Error(`Navigation to ${targetPage} failed and no fallback available`); - } - } -} -const test = base_1.test; -exports.test = test; -test.patient = { - navigateTo, - setup: setupPatientSession, -}; diff --git a/build/tests/fixtures/test-tags.js b/build/tests/fixtures/test-tags.js deleted file mode 100644 index a2f7ec6..0000000 --- a/build/tests/fixtures/test-tags.js +++ /dev/null @@ -1,98 +0,0 @@ -"use strict"; -/** - * Test Tags Fixture - * - * Simple tag definitions for test organization and Xray integration. - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.TAG_CATEGORIES = exports.TEST_TAGS = void 0; -exports.validateRequiredTags = validateRequiredTags; -exports.createValidatedTags = createValidatedTags; -exports.TEST_TAGS = { - /** - * Generate a Jira-related tag for linking tests to Jira tickets. - * Usage: TEST_TAGS.RELATED('JIRA-1234') => '@jira(JIRA-1234)' - */ - RELATED: (jiraId) => { - // Accepts formats like ABC-1234 or JIRA-1234 - const jiraPattern = /^[A-Z][A-Z0-9]+-\d+$/; - if (!jiraPattern.test(jiraId)) { - throw new Error(`Invalid Jira ID: ${jiraId}. Must match pattern ABC-1234.`); - } - return `@jira(${jiraId})`; - }, - // Backend Services - BACK_SHORELINE: '@back-shoreline', - BACK_CLINIC: '@back-clinic', - BACK_HIGHWATER: '@back-highwater', - BACK_HYDROPHONE: '@back-hydrophone', - BACK_PLATFORM: '@back-platform', - BACK_SEAGULL: '@back-seagull', - BACK_TIDEWHISPERER: '@back-tidewhisperer', - BACK_MESSAGEAPI: '@back-messageapi', - BACK_JELLYFISH: '@back-jellyfish', - BACK_GATEKEEPER: '@back-gatekeeper', - BACK_EXPORT: '@back-export', - BACK_KEYCLOAK: '@back-keycloak', - // User Types - PATIENT: '@patient', - CLINICIAN: '@clinician', - // User-Subtypes - CUSTODIAL: '@custodial', - SHARED_MEMBER: '@shared_member', - PERSONAL: '@personal', - CLAIMED: '@claimed', - // Test Types - API: '@api', - UI: '@ui', - SMOKE: '@smoke', - REGRESSION: '@regression', - // Priority - CRITICAL: '@critical', - HIGH: '@high', - MEDIUM: '@medium', - LOW: '@low', - // Endpoint API Testing - API_PROFILE: '@api_profile', - API_USER: '@api_user', -}; -// Tag Categories for Validation -exports.TAG_CATEGORIES = { - USER_TYPES: [exports.TEST_TAGS.PATIENT, exports.TEST_TAGS.CLINICIAN], - TEST_TYPES: [exports.TEST_TAGS.API, exports.TEST_TAGS.UI, exports.TEST_TAGS.SMOKE, exports.TEST_TAGS.REGRESSION], - PRIORITIES: [exports.TEST_TAGS.CRITICAL, exports.TEST_TAGS.HIGH, exports.TEST_TAGS.MEDIUM, exports.TEST_TAGS.LOW], -}; -/** - * Validates that tags include at least one from each required category - * @param tags Array of tags to validate - * @returns Object with validation results - */ -function validateRequiredTags(tags) { - const hasUserType = tags.some(tag => exports.TAG_CATEGORIES.USER_TYPES.includes(tag)); - const hasTestType = tags.some(tag => exports.TAG_CATEGORIES.TEST_TYPES.includes(tag)); - const hasPriority = tags.some(tag => exports.TAG_CATEGORIES.PRIORITIES.includes(tag)); - const isValid = hasUserType && hasTestType && hasPriority; - const missing = []; - if (!hasUserType) - missing.push('User Type'); - if (!hasTestType) - missing.push('Test Type'); - if (!hasPriority) - missing.push('Priority'); - return { - isValid, - missing, - message: isValid ? 'All required tags present' : `Missing required tags: ${missing.join(', ')}`, - }; -} -/** - * Helper function to create tags with validation - * Throws error if required tags are missing - */ -function createValidatedTags(tags) { - const validation = validateRequiredTags(tags); - if (!validation.isValid) { - throw new Error(`Test tags validation failed: ${validation.message}`); - } - return tags; -} diff --git a/build/tests/global-setup.js b/build/tests/global-setup.js deleted file mode 100644 index 03e5990..0000000 --- a/build/tests/global-setup.js +++ /dev/null @@ -1,47 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.default = globalSetup; -const test_1 = require("@playwright/test"); -const LoginPage_1 = __importDefault(require("@pom/LoginPage")); -const node_fs_1 = __importDefault(require("node:fs")); -const node_path_1 = __importDefault(require("node:path")); -const env_1 = __importDefault(require("../utilities/env")); -async function loginUserType(role) { - const browser = await test_1.chromium.launch(); - const context = await browser.newContext({ - baseURL: env_1.default.BASE_URL, - }); - const page = await context.newPage(); - await page.goto(env_1.default.BASE_URL); - const loginPage = new LoginPage_1.default(page); - if (role === 'personal') { - await loginPage.login(env_1.default.PERSONAL_USERNAME, env_1.default.PERSONAL_PASSWORD); - await page.waitForURL('**/data'); - } - else if (role === 'claimed') { - await loginPage.login(env_1.default.CLAIMED_USERNAME, env_1.default.CLAIMED_PASSWORD); - await page.waitForURL('**/data'); - } - else if (role === 'shared') { - await loginPage.login(env_1.default.SHARED_USERNAME, env_1.default.SHARED_PASSWORD); - await page.waitForURL('**/data'); - } - else { - await loginPage.login(env_1.default.CLINICIAN_USERNAME, env_1.default.CLINICIAN_PASSWORD); - await page.waitForURL('**/workspaces'); - } - const authDir = node_path_1.default.resolve(process.cwd(), 'tests', '.auth'); - await node_fs_1.default.promises.mkdir(authDir, { recursive: true }); - const filePath = node_path_1.default.join(authDir, `${role}.json`); - await context.storageState({ path: filePath }); - await browser.close(); -} -async function globalSetup(_config) { - await loginUserType('personal'); - await loginUserType('claimed'); - await loginUserType('shared'); - await loginUserType('clinician'); -} diff --git a/build/tests/personal/AP-Profile/edit-personal-profile-API.spec.js b/build/tests/personal/AP-Profile/edit-personal-profile-API.spec.js deleted file mode 100644 index 45bc9b2..0000000 --- a/build/tests/personal/AP-Profile/edit-personal-profile-API.spec.js +++ /dev/null @@ -1,75 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const patient_helpers_1 = require("../../fixtures/patient-helpers"); -const network_helpers_1 = require("../../fixtures/network-helpers"); -const test_tags_1 = require("../../fixtures/test-tags"); -const ProfilePage_1 = require("../../../page-objects/patient/ProfilePage"); -patient_helpers_1.test.describe('Personal Accounts allow access and modification of profile details', () => { - // API Test cases require this to capture network activity - let api; - (0, patient_helpers_1.test)('should allow navigation to profile details and edit profile fields', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.PATIENT, // User Type (required) - test_tags_1.TEST_TAGS.PERSONAL, // User Subtype (required) - test_tags_1.TEST_TAGS.API, // Test Type (required) - test_tags_1.TEST_TAGS.UI, // Test Type (required) - test_tags_1.TEST_TAGS.HIGH, // Priority (required) - test_tags_1.TEST_TAGS.API_PROFILE, // Feature (optional) - ]), - }, async ({ page }) => { - // Step 1: Log in to personal account and setup network capture - await patient_helpers_1.test.step('Given personal account has been logged in', async () => { - api = (0, network_helpers_1.createNetworkHelper)(page); - await api.startCapture(); - await page.goto('/data'); - await patient_helpers_1.test.patient.setup(page); - // Step 2: Navigate to profile - await patient_helpers_1.test.step('When user navigates to Profile page', async () => { - await patient_helpers_1.test.patient.navigateTo('Profile', page); - }); - // Step 3: Check profile GET response - await patient_helpers_1.test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - // Step 4: Open Edit Profile - await patient_helpers_1.test.step('When user selects Edit button', async () => { - await patient_helpers_1.test.patient.navigateTo('ProfileEdit', page); - }); - // Initialize ProfilePage for steps 4 and 5 - const profilePage = new ProfilePage_1.ProfilePage(page); - // Step 5: Change profile fields (confirmed user access) - await patient_helpers_1.test.step('When user updates profile fields', async () => { - // Generate completely unique values for this confirmed user test run - const testRunId = Math.floor(Math.random() * 10000); - const updatedName = `Personal Patient Updated ${testRunId}`; - const birthYear = 1985 + (testRunId % 10); - const diagnosisYear = birthYear + 20; - const birthDate = `01/15/${birthYear}`; - const diagnosisDate = `03/10/${diagnosisYear}`; - // Generate random 15-letter string for clinical notes - const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(''); - // Get current diagnosis index and calculate next one (1-7, wrapping) - const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); - let nextDiagnosisIndex = currentDiagnosisIndex + 1; - if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { - nextDiagnosisIndex = 1; - } - // Update fields using ProfilePage methods - await profilePage.fillFullName(updatedName); - await profilePage.fillBirthDate(birthDate); - await profilePage.fillDiagnosisDate(diagnosisDate); - await profilePage.selectDiagnosisType(nextDiagnosisIndex); - await profilePage.fillClinicalNotes(randomString); - }); - // Step 6: Save profile edit - await patient_helpers_1.test.step('When user saves profile changes', async () => { - await profilePage.saveProfile(); - }); - // Step 7: Check profile PUT response - await patient_helpers_1.test.stepNoScreenshot('Then profile endpoint responds with PUT request consistent with schema', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - }); - await api.stopCapture(); - }); - }); -}); diff --git a/build/tests/personal/basic-functionality.spec.js b/build/tests/personal/basic-functionality.spec.js deleted file mode 100644 index 48e40fa..0000000 --- a/build/tests/personal/basic-functionality.spec.js +++ /dev/null @@ -1,240 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -// @ts-check -const base_1 = require("@fixtures/base"); -const BasicsPage_1 = __importDefault(require("@pom/patient/BasicsPage")); -const DailyPage_1 = __importDefault(require("@pom/patient/DailyPage")); -base_1.test.describe('Patient Data Navigation and Visualization', () => { - base_1.test.beforeEach(async ({ page }) => { - await base_1.test.step('Given user has been logged in', async () => { - const basicsPage = new BasicsPage_1.default(page); - await basicsPage.goto(); - // await page.getByText("Loading").waitFor({ state: "detached", timeout: 10000 }); - }); - }); - // BG readings dashboard functionality - (0, base_1.test)('should display daily chart when selecting a date from basics page', async ({ page }) => { - const basicsPage = new BasicsPage_1.default(page); - const dailyPage = new DailyPage_1.default(page); - let selectedDateText; - await base_1.test.step('When the navigation bar is visible', async () => { - await basicsPage.navigationBar.buttons.viewData.waitFor({ - state: 'visible', - }); - }); - await base_1.test.step('When the user clicks on the most recent day', async () => { - const recentDayElement = basicsPage.bgReadingsSection.firstDayOfData; - await recentDayElement.waitFor({ state: 'visible' }); - await recentDayElement.hover(); - selectedDateText = await basicsPage.bgReadingsSection.calendarDayhover.text(); - await basicsPage.bgReadingsSection.calendarDayhover.el.click(); - }); - await base_1.test.step('Then the daily chart is visible and correctly rendered', async () => { - const chartContainer = dailyPage.dailyChart.container; - await chartContainer.waitFor({ state: 'visible' }); - if (!selectedDateText) { - throw new Error('Selected date text is null'); - } - // Verify the selected date matches the displayed date - // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); - // Capture chart screenshot for visual regression - await (0, base_1.expect)(chartContainer).toHaveScreenshot('daily-chart-1.png'); - }); - }); - // Bolus dashboard functionality - (0, base_1.test)('should display bolus dashboard when selecting a date from basics page', async ({ page, }) => { - const basicsPage = new BasicsPage_1.default(page); - const dailyPage = new DailyPage_1.default(page); - let selectedDateText; - await base_1.test.step('When the navigation bar is visible', async () => { - await basicsPage.navigationBar.buttons.viewData.waitFor({ - state: 'visible', - }); - }); - await base_1.test.step('When the user clicks on the most recent day', async () => { - const recentDayElement = basicsPage.bolusingSection.firstDayOfData; - await recentDayElement.waitFor({ state: 'visible' }); - await recentDayElement.hover(); - selectedDateText = await basicsPage.bolusingSection.calendarDayhover.text(); - await basicsPage.bolusingSection.calendarDayhover.el.click(); - }); - await base_1.test.step('Then the daily chart is visible and correctly rendered', async () => { - const chartContainer = dailyPage.dailyChart.container; - await chartContainer.waitFor({ state: 'visible' }); - if (!selectedDateText) { - throw new Error('Selected date text is null'); - } - // Verify the selected date matches the displayed date - // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); - // Capture chart screenshot for visual regression - await (0, base_1.expect)(chartContainer).toHaveScreenshot('daily-chart-2.png'); - }); - }); - // Infusion Site Changes dashboard functionality - (0, base_1.test)('should display Infusion site changes dashboard when selecting a date from basics page', async ({ page, }) => { - const basicsPage = new BasicsPage_1.default(page); - const dailyPage = new DailyPage_1.default(page); - let selectedDateText; - await base_1.test.step('When the infusion site changes dashboard is visible', async () => { - // Verify dashboard title and initial state - // await expect(basicsPage.tubingPrimeSection.title).toBeVisible(); - // await expect(basicsPage.tubingPrimeSection.description).toHaveText( - // "We are using Fill Cannula to visualize your infusion site changes." - // ); - }); - await base_1.test.step('When testing Fill Cannula functionality', async () => { - // Verify radio button options - await basicsPage.tubingPrimeSection.settingsOption.fillCannula.waitFor({ - state: 'visible', - timeout: 60000, - }); - await (0, base_1.expect)(basicsPage.tubingPrimeSection.settingsOption.fillCannula).toBeVisible(); - await (0, base_1.expect)(basicsPage.tubingPrimeSection.settingsOption.fillTubing).toBeVisible(); - // Select Fill Cannula and verify highlighted days - await basicsPage.tubingPrimeSection.settingsOption.fillCannula.click(); - // // Verify duration indicator is visible - // await expect( - // basicsPage.tubingPrimeSection.durationIndicator - // ).toContainText("4 days"); - // Verify cannula icons are visible and tubing icons are not - await (0, base_1.expect)(basicsPage.tubingPrimeSection.cannulaIcons).toBeAttached(); - await (0, base_1.expect)(basicsPage.tubingPrimeSection.tubingIcons).not.toBeAttached(); - // Select a highlighted day - const highlightedDay = basicsPage.tubingPrimeSection.filledDay; - await highlightedDay.hover(); - selectedDateText = await basicsPage.tubingPrimeSection.calendarDayhover.text(); - await basicsPage.tubingPrimeSection.calendarDayhover.el.click(); - }); - await base_1.test.step('Then the daily chart shows correct cannula fill date', async () => { - const chartContainer = dailyPage.dailyChart.container; - await chartContainer.waitFor({ state: 'visible' }); - if (!selectedDateText) { - throw new Error('Selected date text is null'); - } - // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); - await (0, base_1.expect)(chartContainer).toHaveScreenshot('daily-chart-cannula.png'); - }); - // Return to basics page and test Fill Tubing Option - await base_1.test.step('When testing Fill Tubing functionality', async () => { - // Navigate back to basics - await base_1.test.step('When the navigation bar is visible', async () => { - await basicsPage.navigationBar.buttons.viewData.waitFor({ - state: 'visible', - }); - }); - // await basicsPage.navigationSubMenu.links.basics.click(); - await basicsPage.tubingPrimeSection.settings.waitFor({ - state: 'visible', - }); - // Click settings and select Fill Tubing - await basicsPage.tubingPrimeSection.settings.click(); - await basicsPage.tubingPrimeSection.settingsOption.fillTubing.click(); - // Verify filled tubing day is visible and cannula day is not - await (0, base_1.expect)(basicsPage.tubingPrimeSection.tubingIcons).toBeAttached(); - await (0, base_1.expect)(basicsPage.tubingPrimeSection.cannulaIcons).not.toBeAttached(); - // Click on the most recent day with tubing fill - const tubingDay = basicsPage.tubingPrimeSection.filledDay; - await tubingDay.hover(); - selectedDateText = await basicsPage.tubingPrimeSection.calendarDayhover.text(); - await basicsPage.tubingPrimeSection.calendarDayhover.el.click(); - }); - await base_1.test.step('Then the daily chart shows correct tubing fill date', async () => { - const chartContainer = dailyPage.dailyChart.container; - await chartContainer.waitFor({ state: 'visible' }); - if (!selectedDateText) { - throw new Error('Selected date text is null'); - } - // await expect(dailyPage.navigationSubMenu.currentDate).toContainText(selectedDateText); - await (0, base_1.expect)(chartContainer).toHaveScreenshot('daily-chart-tubing.png'); - }); - }); - // TODO: Previous test doesn't test values. Should we? :) - // Readings in range functionality - (0, base_1.test)('The hover over elements in sidebar shows correct values', async ({ page }) => { - // Stats for BGM - const expectedHeadersReadingInRange = [ - { header: 'Readings Below Range', value: 3 }, - { header: 'Readings Below Range', value: 0 }, - { header: 'Readings In Range', value: 71 }, - { header: 'Readings Above Range', value: 24 }, - { header: 'Readings Above Range', value: 2 }, - ]; - const basicsPage = new BasicsPage_1.default(page); - await base_1.test.step('When the navigation bar is visible', async () => { - await basicsPage.navigationBar.buttons.viewData.waitFor({ - state: 'visible', - }); - }); - // Other BGM tooltip functionality - await basicsPage.statsSidebar.toggleTo('BGM'); - for (let i = 0; i < 5; i += 1) { - const bar = basicsPage.statsSidebar.readingsInRange.hoverBar.nth(i); - const barLabel = basicsPage.statsSidebar.readingsInRange.hoverBarLabel.nth(i); - await base_1.test.step('When the user hovers over the Avg. Daily Readings In Range chart', async () => { - await bar.hover(); - }); - await base_1.test.step('Then the correct header is visible', async () => { - await base_1.expect - .soft(basicsPage.statsSidebar.readingsInRange.header) - .toContainText(expectedHeadersReadingInRange[i].header); - }); - await base_1.test.step('Then the correct value is visible', async () => { - await base_1.expect - .soft(barLabel) - .toContainText(expectedHeadersReadingInRange[i].value.toString()); - }); - } - // Stats for CGM - // Time in range functionality - const expectedHeadersTimeInRange = [ - { header: 'Time Below Range', value: 0.1 }, - { header: 'Time Below Range', value: 1 }, - { header: 'Time In Range', value: 90 }, - { header: 'Time Above Range', value: 9 }, - { header: 'Time Above Range', value: 0.3 }, - ]; - await basicsPage.statsSidebar.toggleTo('CGM'); - for (let i = 0; i < expectedHeadersTimeInRange.length; i += 1) { - const bar = basicsPage.statsSidebar.timeInRange.hoverBar.nth(i); - const barLabel = basicsPage.statsSidebar.timeInRange.hoverBarLabel.nth(i); - await base_1.test.step('When the user hovers over the Avg. Daily Time In Range chart', async () => { - await bar.hover(); - }); - await base_1.test.step('Then the correct header is visible', async () => { - await base_1.expect - .soft(basicsPage.statsSidebar.timeInRange.header) - .toContainText(expectedHeadersTimeInRange[i].header); - }); - await base_1.test.step('Then the correct value is visible', async () => { - await base_1.expect.soft(barLabel).toContainText(expectedHeadersTimeInRange[i].value.toString()); - }); - } - }); - // Other CGM tooltip functionality - (0, base_1.test)('other CGM tooltip functionality', async ({ page }) => { - const basicsPage = new BasicsPage_1.default(page); - await basicsPage.statsSidebar.toggleTo('CGM'); - const expectedHeadersTimeInRange = [ - { header: 'Basal Insulin', value: 14.7, percentage: 44 }, - { header: 'Bolus Insulin', value: 18.8, percentage: 56 }, - ]; - for (let i = 0; i < expectedHeadersTimeInRange.length; i += 1) { - const bar = basicsPage.statsSidebar.totalInsulin.hoverBar.nth(i); - const barLabel = basicsPage.statsSidebar.totalInsulin.hoverBarLabel.nth(i); - await base_1.test.step('When the user hovers over the Avg. Daily Total Insulin chart', async () => { - await bar.hover(); - }); - await base_1.test.step('Then the correct header is visible', async () => { - await base_1.expect - .soft(basicsPage.statsSidebar.timeInRange.header) - .toContainText(expectedHeadersTimeInRange[i].header); - }); - await base_1.test.step('Then the correct value is visible', async () => { - await base_1.expect.soft(barLabel).toContainText(expectedHeadersTimeInRange[i].value.toString()); - }); - } - }); -}); diff --git a/build/tests/personal/login.spec.js b/build/tests/personal/login.spec.js deleted file mode 100644 index 8c78393..0000000 --- a/build/tests/personal/login.spec.js +++ /dev/null @@ -1,95 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -// @ts-check -const base_1 = require("@fixtures/base"); -const LoginPage_1 = __importDefault(require("page-objects/LoginPage")); -const WorkspacesPage_1 = __importDefault(require("@pom/clinician/WorkspacesPage")); -const env_1 = __importDefault(require("../../utilities/env")); -const test_tags_1 = require("../fixtures/test-tags"); -// make sure we don't have any cookies or origins -base_1.test.use({ storageState: { cookies: [], origins: [] } }); -// Possible testcases: https://tidepool.atlassian.net/jira/software/c/projects/WEB/issues/?jql=project%20%3D%20%22WEB%22%20AND%20type%20%3D%20Test%20AND%20textfields%20~%20%22login%22%20ORDER%20BY%20created%20DESC -base_1.test.describe('Login into application', () => { - (0, base_1.test)('should work with valid credentials for clinician with multiple clinics', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.CLINICIAN, - test_tags_1.TEST_TAGS.UI, - test_tags_1.TEST_TAGS.SMOKE, - test_tags_1.TEST_TAGS.CRITICAL, - ]), - }, async ({ page }) => { - const loginPage = new LoginPage_1.default(page); - await base_1.test.step('When user is logged into application', async () => { - await loginPage.goto(); - await loginPage.login(env_1.default.CLINICIAN_USERNAME, env_1.default.CLINICIAN_PASSWORD); - }); - await base_1.test.step('Then the user is redirected to workspaces page', async () => { - const workspacesPage = new WorkspacesPage_1.default(page); - await page.waitForURL(workspacesPage.url); - await (0, base_1.expect)(workspacesPage.header).toBeVisible(); - }); - }); - (0, base_1.test)('should show error message with invalid credentials', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.CLINICIAN, - test_tags_1.TEST_TAGS.UI, - test_tags_1.TEST_TAGS.SMOKE, - test_tags_1.TEST_TAGS.HIGH, - ]), - }, async ({ page }) => { - const loginPage = new LoginPage_1.default(page); - await base_1.test.step('When user attempts to login with invalid credentials', async () => { - await loginPage.goto(); - // Enter email - await page.fill('#username', 'invalid@email.com'); - await page.click('#kc-login'); - }); - await base_1.test.step('Then error message should be displayed', async () => { - // Wait for the error message to appear - await (0, base_1.expect)(page.locator('#input-error-username')).toBeVisible(); - await (0, base_1.expect)(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); - }); - }); - (0, base_1.test)('should validate email format', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.CLINICIAN, - test_tags_1.TEST_TAGS.UI, - test_tags_1.TEST_TAGS.REGRESSION, - test_tags_1.TEST_TAGS.MEDIUM, - ]), - }, async ({ page }) => { - const loginPage = new LoginPage_1.default(page); - await base_1.test.step('When user attempts to login with invalid email format', async () => { - await loginPage.goto(); - // Enter invalid email format - await page.fill('#username', 'invalidemail'); - await page.click('#kc-login'); - }); - await base_1.test.step('Then email validation error should be displayed', async () => { - // Check for email validation error message - await (0, base_1.expect)(page.locator('#input-error-username')).toBeVisible(); - await (0, base_1.expect)(page.locator('#input-error-username')).toContainText("This email doesn't belong to an account yet."); - }); - }); - (0, base_1.test)('should show error message with invalid credentials 1', { - tag: (0, test_tags_1.createValidatedTags)([ - test_tags_1.TEST_TAGS.CLINICIAN, - test_tags_1.TEST_TAGS.UI, - test_tags_1.TEST_TAGS.SMOKE, - test_tags_1.TEST_TAGS.HIGH, - ]), - }, async ({ page }) => { - const loginPage = new LoginPage_1.default(page); - await base_1.test.step('When user is logged into application', async () => { - await loginPage.goto(); - await loginPage.login(env_1.default.CLINICIAN_USERNAME, `${env_1.default.CLINICIAN_PASSWORD}1`); - }); - await base_1.test.step('Then error message should be displayed', async () => { - await (0, base_1.expect)(page.locator('#input-error')).toBeVisible(); - await (0, base_1.expect)(page.locator('#input-error')).toContainText('Invalid password.'); - }); - }); -}); diff --git a/build/utilities/annotations.js b/build/utilities/annotations.js deleted file mode 100644 index 528cbcc..0000000 --- a/build/utilities/annotations.js +++ /dev/null @@ -1,24 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.default = addTestAnnotations; -/** - * Add test annotations to the test info for JIRA integration - */ -function addTestAnnotations(testInfo, annotations) { - testInfo.annotations.push({ - type: 'test_key', - description: annotations.testKey, - }); - testInfo.annotations.push({ - type: 'test_summary', - description: annotations.testSummary, - }); - testInfo.annotations.push({ - type: 'requirements', - description: annotations.requirements, - }); - testInfo.annotations.push({ - type: 'test_description', - description: annotations.testDescription, - }); -} diff --git a/build/utilities/env.js b/build/utilities/env.js deleted file mode 100644 index 123e678..0000000 --- a/build/utilities/env.js +++ /dev/null @@ -1,46 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const dotenv_1 = __importDefault(require("dotenv")); -const zod_1 = __importDefault(require("zod")); -dotenv_1.default.config(); -const envSchema = zod_1.default.object({ - BROWSERSTACK_USERNAME: zod_1.default.string().optional(), - BROWSERSTACK_ACCESS_KEY: zod_1.default.string().optional(), - PERSONAL_USERNAME: zod_1.default.string(), - PERSONAL_PASSWORD: zod_1.default.string(), - CLAIMED_USERNAME: zod_1.default.string(), - CLAIMED_PASSWORD: zod_1.default.string(), - SHARED_USERNAME: zod_1.default.string(), - SHARED_PASSWORD: zod_1.default.string(), - CLINICIAN_USERNAME: zod_1.default.string(), - CLINICIAN_PASSWORD: zod_1.default.string(), - TARGET_ENV: zod_1.default.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production', 'prd', 'int']), - XRAY_CLIENT_ID: zod_1.default.string().optional(), - XRAY_CLIENT_SECRET: zod_1.default.string().optional(), - XRAY_PROJECT_KEY: zod_1.default.string().default('SAND'), - XRAY_EVIDENCE_SIZE_THRESHOLD_KB: zod_1.default.coerce.number().default(100), - JIRA_EMAIL: zod_1.default.string().optional(), - JIRA_API_KEY: zod_1.default.string().optional(), -}); -const env = envSchema.safeParse(process.env); -if (!env.success) { - console.error('āŒ Invalid environment variables:\n', env.error.format()); - throw new Error('Invalid environment variables. Check your .env file.'); -} -const URL_MAP = { - qa1: 'https://qa1.development.tidepool.org', - qa2: 'https://qa2.development.tidepool.org', - qa3: 'https://qa3.development.tidepool.org', - qa4: 'https://qa4.development.tidepool.org', - qa5: 'https://qa5.development.tidepool.org', - production: 'https://app.tidepool.org', - prd: 'https://app.tidepool.org', // Alias for production - int: 'https://int.development.tidepool.org', // Integration environment -}; -exports.default = { - ...env.data, - BASE_URL: URL_MAP[env.data.TARGET_ENV], -}; diff --git a/build/utilities/xray-json-reporter.js b/build/utilities/xray-json-reporter.js deleted file mode 100644 index 56046f6..0000000 --- a/build/utilities/xray-json-reporter.js +++ /dev/null @@ -1,473 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const node_fs_1 = __importDefault(require("node:fs")); -const node_path_1 = __importDefault(require("node:path")); -const env_1 = __importDefault(require("./env")); -const xray_graphql_evidence_1 = require("./xray-graphql-evidence"); -/** - * Unified Xray JSON Reporter for Playwright - * Maps rich Playwright test data to Xray's JSON format with intelligent evidence handling - */ -class XrayJsonReporter { - constructor() { - this.styles = { - success: 'āœ…', - error: 'āŒ', - info: 'ā„¹ļø', - warning: 'āš ļø', - upload: 'šŸš€', - test: '🧪', - evidence: 'šŸ“Ž', - separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - }; - this.startTime = ''; - this.endTime = ''; - this.deferredEvidenceUploads = []; - } - /** - * Authenticates with Xray API using client credentials - */ - async authenticateWithXray() { - const startAuth = Date.now(); - try { - console.log(`${this.styles.info} Authenticating with Xray Cloud API...`); - if (!env_1.default.XRAY_CLIENT_ID || !env_1.default.XRAY_CLIENT_SECRET) { - throw new Error('XRAY_CLIENT_ID and XRAY_CLIENT_SECRET are required for authentication'); - } - const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - client_id: env_1.default.XRAY_CLIENT_ID, - client_secret: env_1.default.XRAY_CLIENT_SECRET, - }), - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Authentication failed (HTTP ${response.status}): ${errorText || 'No error details'}`); - } - const token = await response.text(); - const cleanToken = token.replace(/"/g, ''); // Remove quotes from token - if (!cleanToken || cleanToken.length < 10) { - throw new Error(`Invalid token received: ${cleanToken.substring(0, 20)}...`); - } - const authDuration = Date.now() - startAuth; - console.log(`${this.styles.success} Successfully authenticated with Xray (${authDuration}ms)`); - return cleanToken; - } - catch (error) { - console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); - throw error; - } - } - /** - * Maps Playwright test status to Xray status - */ - getTestStatus(status) { - if (status === 'passed') - return 'PASS'; - if (status === 'skipped') - return 'PENDING'; - return 'FAIL'; - } - /** - * Converts file to base64 string for Xray evidence - */ - async fileToBase64(filePath) { - try { - const fileBuffer = node_fs_1.default.readFileSync(filePath); - return fileBuffer.toString('base64'); - } - catch (error) { - console.warn(`${this.styles.warning} Could not read file ${filePath}:`, error); - return ''; - } - } - /** - * Get file size in bytes - */ - getFileSize(filePath) { - try { - const stats = node_fs_1.default.statSync(filePath); - return stats.size; - } - catch (error) { - return 0; - } - } - /** - * Classifies evidence based on type, size, and test result - */ - classifyEvidence(attachment, testStatus, contentType) { - const filePath = attachment.path; - if (!filePath || !node_fs_1.default.existsSync(filePath)) { - return 'skip'; - } - const sizeBytes = this.getFileSize(filePath); - const sizeKB = sizeBytes / 1024; - const thresholdKB = env_1.default.XRAY_EVIDENCE_SIZE_THRESHOLD_KB || 100; - // Videos: Only for failed tests - if (contentType.includes('video')) { - if (testStatus !== 'passed') { - return 'deferred'; // Always defer videos (large files) - } - return 'skip'; - } - // Screenshots (PNG/JPEG): Always include - if (contentType.includes('image')) { - if (sizeKB < thresholdKB) { - return 'inline'; - } - return 'deferred'; - } - // JSON responses: Always inline (small) - if (contentType.includes('json')) { - return 'inline'; - } - // Other attachments: Check size - if (sizeKB < thresholdKB) { - return 'inline'; - } - return 'deferred'; - } - /** - * Extracts step information from test annotations and maps evidence - */ - async extractSteps(annotations, attachments, testStatus) { - const steps = []; - const classifiedEvidence = []; - const stepAnnotations = annotations.filter(ann => ann.type.startsWith('Step Duration:')); - for (let i = 0; i < stepAnnotations.length; i += 1) { - const stepAnn = stepAnnotations[i]; - const stepName = stepAnn.type.replace('Step Duration: ', ''); - const duration = stepAnn.description; - const stepNumber = i + 1; - // Find associated step attachments using step number pattern - const stepPattern = `step-${stepNumber.toString().padStart(2, '0')}`; - const stepAttachments = attachments.filter(att => att.name.toLowerCase().includes(stepPattern)); - const step = { - action: stepName, - data: `Duration: ${duration}`, - result: stepName.includes('Then') ? stepName : undefined, - status: 'PASS', // Will be updated if test failed - evidences: [], - }; - // Classify and process step evidence - for (const attachment of stepAttachments) { - if (attachment.path && node_fs_1.default.existsSync(attachment.path)) { - const contentType = attachment.contentType || 'application/octet-stream'; - const classification = this.classifyEvidence(attachment, testStatus, contentType); - if (classification !== 'skip') { - const sizeBytes = this.getFileSize(attachment.path); - if (classification === 'inline') { - // Embed in Xray JSON - const base64Data = await this.fileToBase64(attachment.path); - if (base64Data) { - step.evidences?.push({ - data: base64Data, - filename: node_path_1.default.basename(attachment.path), - contentType, - }); - } - } - else { - // Mark for deferred upload - classifiedEvidence.push({ - evidence: { - data: '', // Will be loaded during GraphQL upload - filename: node_path_1.default.basename(attachment.path), - contentType, - }, - classification: 'deferred', - stepIndex: i, - filePath: attachment.path, - fileSize: sizeBytes, - }); - } - } - } - } - steps.push(step); - } - return { steps, classified: classifiedEvidence }; - } - /** - * Maps Playwright test result to Xray test format - */ - async mapPlaywrightTestToXray(testCase, testResult) { - const tags = testCase.tags || []; - const annotations = testResult.annotations || []; - const attachments = testResult.attachments || []; - const testStatus = testResult.status; - // Extract steps from annotations - const { steps, classified: stepDeferred } = await this.extractSteps(annotations, attachments, testStatus); - // Mark failed steps if test failed - if (testStatus !== 'passed' && steps.length > 0) { - steps[steps.length - 1].status = 'FAIL'; - steps[steps.length - 1].actualResult = testResult.error?.message || 'Test failed'; - } - // Collect test-level evidence (screenshots, videos) - const testEvidences = []; - const testLevelDeferred = []; - for (const attachment of attachments) { - // Only process test-level evidence (not step-level) - if (attachment.path && - node_fs_1.default.existsSync(attachment.path) && - !attachment.name.toLowerCase().includes('step-')) { - const contentType = attachment.contentType || 'application/octet-stream'; - const classification = this.classifyEvidence(attachment, testStatus, contentType); - if (classification !== 'skip') { - const sizeBytes = this.getFileSize(attachment.path); - if (classification === 'inline') { - const base64Data = await this.fileToBase64(attachment.path); - if (base64Data) { - testEvidences.push({ - data: base64Data, - filename: attachment.name, - contentType, - }); - } - } - else { - testLevelDeferred.push({ - evidence: { - data: '', - filename: attachment.name, - contentType, - }, - classification: 'deferred', - filePath: attachment.path, - fileSize: sizeBytes, - }); - } - } - } - } - const xrayTest = { - testInfo: { - summary: testCase.title, - type: 'Generic', - projectKey: env_1.default.XRAY_PROJECT_KEY || 'SAND', - labels: tags, - }, - status: this.getTestStatus(testStatus), - comment: testResult.error?.message, - evidences: testEvidences.length > 0 ? testEvidences : undefined, - steps: steps.length > 0 ? steps : undefined, - }; - return { - test: xrayTest, - deferred: [...stepDeferred, ...testLevelDeferred], - }; - } - /** - * Converts Playwright JSON results to Xray format - */ - async convertPlaywrightJsonToXray(playwrightJsonPath) { - const jsonContent = node_fs_1.default.readFileSync(playwrightJsonPath, 'utf8'); - const playwrightResult = JSON.parse(jsonContent); - const tests = []; - this.deferredEvidenceUploads = []; // Reset deferred uploads - // Process all test suites - for (const suite of playwrightResult.suites || []) { - await this.processSuite(suite, tests); - } - const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; - const targetEnv = process.env.TARGET_ENV || 'qa1'; - // Calculate statistics - const passedCount = tests.filter(t => t.status === 'PASS').length; - const failedCount = tests.filter(t => t.status === 'FAIL').length; - const pendingCount = tests.filter(t => t.status === 'PENDING').length; - const xrayResult = { - info: { - summary: `Playwright Test Execution - ${new Date().toISOString()}`, - description: `Automated test execution for ${targetEnv} environment\n\nResults: ${passedCount} passed, ${failedCount} failed, ${pendingCount} pending`, - version: '1.0', - testExecutionKey: testExecKey && testExecKey !== 'none' && testExecKey.trim() !== '' - ? testExecKey - : undefined, - startDate: playwrightResult.stats?.startTime || new Date().toISOString(), - finishDate: new Date(new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + - (playwrightResult.stats?.duration || 0)).toISOString(), - testEnvironments: [targetEnv], - }, - tests, - }; - // Log deferred evidence summary - if (this.deferredEvidenceUploads.length > 0) { - const totalSizeKB = this.deferredEvidenceUploads.reduce((sum, d) => sum + this.getFileSize(d.filePath) / 1024, 0); - console.log(`${this.styles.evidence} ${this.deferredEvidenceUploads.length} evidence files marked for deferred upload (${totalSizeKB.toFixed(1)} KB)`); - } - return xrayResult; - } - /** - * Recursively processes test suites - */ - async processSuite(suite, tests) { - // Process specs in this suite - for (const spec of suite.specs || []) { - for (const test of spec.tests || []) { - for (const result of test.results || []) { - const { test: xrayTest, deferred } = await this.mapPlaywrightTestToXray(spec, result); - tests.push(xrayTest); - // Store deferred evidence for later upload - // Note: We'll need test run IDs from import response to upload these - for (const evidence of deferred) { - this.deferredEvidenceUploads.push({ - testRunId: '', // Will be populated after import - testRunStepId: evidence.stepIndex !== undefined ? '' : undefined, - filePath: evidence.filePath, - filename: evidence.evidence.filename, - contentType: evidence.evidence.contentType, - stepAction: evidence.stepIndex !== undefined - ? xrayTest.steps?.[evidence.stepIndex]?.action - : undefined, - }); - } - } - } - } - // Process nested suites - for (const nestedSuite of suite.suites || []) { - await this.processSuite(nestedSuite, tests); - } - } - /** - * Uploads Xray execution result to Xray - */ - async uploadToXray(xrayResult) { - try { - const uploadStart = Date.now(); - const payloadSize = JSON.stringify(xrayResult).length; - const payloadSizeKB = (payloadSize / 1024).toFixed(1); - console.log(`${this.styles.info} Uploading test execution to Xray...`); - console.log(`${this.styles.info} Payload: ${xrayResult.tests.length} tests, ${payloadSizeKB} KB`); - const token = await this.authenticateWithXray(); - const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(xrayResult), - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Upload failed (HTTP ${response.status}): ${errorText}`); - } - const result = await response.json(); - const uploadDuration = Date.now() - uploadStart; - console.log(`${this.styles.success} Successfully uploaded to Xray (${uploadDuration}ms)`); - console.log(`${this.styles.success} Test Execution Key: ${result.testExecIssue?.key || 'N/A'}`); - return result; - } - catch (error) { - console.error(`${this.styles.error} Failed to upload to Xray:`, error); - throw error; - } - } - /** - * Upload deferred evidence via GraphQL - */ - async uploadDeferredEvidenceViaGraphQL(importResponse) { - try { - console.log(`${this.styles.evidence} Uploading ${this.deferredEvidenceUploads.length} deferred evidence files via GraphQL...`); - // Get fresh token for GraphQL - const token = await this.authenticateWithXray(); - // Create GraphQL client - const graphqlClient = new xray_graphql_evidence_1.XrayGraphQLClient(); - graphqlClient.setAuthToken(token); - // Note: The import response doesn't directly provide test run IDs - // For now, we'll skip GraphQL upload and log a warning - // This requires additional API calls to fetch test run details - console.log(`${this.styles.warning} GraphQL evidence upload requires test run ID mapping`); - console.log(`${this.styles.info} Test Execution: ${importResponse.testExecIssue?.key}`); - console.log(`${this.styles.info} Deferred uploads will be enhanced in a future update to fetch test run IDs`); - // TODO: Implement test run ID fetching via GraphQL query - // Query: { getTestExecution(issueId: "...") { testRuns { id, test { summary } } } } - // Then map playwright test titles to test run IDs - // Then call graphqlClient.uploadBatch(uploadsWithIds) - } - catch (error) { - console.error(`${this.styles.error} Failed to upload deferred evidence:`, error); - // Don't throw - evidence upload is non-critical - } - } - /** - * Main method to process and upload results - */ - async processAndUpload(playwrightJsonPath) { - if (!(env_1.default.XRAY_CLIENT_ID && env_1.default.XRAY_CLIENT_SECRET)) { - console.log(`${this.styles.warning} No Xray credentials found, skipping upload to JIRA Xray`); - return; - } - try { - const processStart = Date.now(); - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.info} Processing Playwright results for Xray...`); - console.log(`${this.styles.info} Project Key: ${env_1.default.XRAY_PROJECT_KEY || 'SAND'}`); - console.log(`${this.styles.info} Environment: ${process.env.TARGET_ENV || 'qa1'}`); - const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; - if (testExecKey && testExecKey !== 'none' && testExecKey.trim() !== '') { - console.log(`${this.styles.info} Linking to Test Execution: ${testExecKey}`); - } - else { - console.log(`${this.styles.info} Creating new Test Execution`); - } - const xrayResult = await this.convertPlaywrightJsonToXray(playwrightJsonPath); - // Save converted result for debugging - node_fs_1.default.writeFileSync('test-results/xray-execution.json', JSON.stringify(xrayResult, null, 2)); - console.log(`${this.styles.info} Saved Xray JSON to: test-results/xray-execution.json`); - const importResponse = await this.uploadToXray(xrayResult); - // Phase 3 - Upload deferred evidence via GraphQL - if (this.deferredEvidenceUploads.length > 0 && importResponse) { - await this.uploadDeferredEvidenceViaGraphQL(importResponse); - } - const totalDuration = Date.now() - processStart; - console.log(`${this.styles.upload} Xray upload completed successfully (${totalDuration}ms)`); - console.log(`${this.styles.separator}\n`); - } - catch (error) { - console.error(`${this.styles.error} Failed to process and upload:`, error); - throw error; - } - } - /** - * Reporter lifecycle methods for direct Playwright integration - */ - onBegin(_config, suite) { - this.startTime = new Date().toISOString(); - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); - console.log(`${this.styles.separator}\n`); - } - onTestBegin(test, _result) { - console.log(`${this.styles.test} Starting: ${test.title}`); - } - onTestEnd(test, result) { - const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; - console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); - } - async onEnd(result) { - this.endTime = new Date().toISOString(); - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.info} Test Run Summary:`); - console.log(`Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`); - console.log(`Duration: ${result.duration}ms`); - console.log(`${this.styles.separator}\n`); - // Only attempt upload if Xray credentials and execution key are configured - const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; - if (env_1.default.XRAY_CLIENT_ID && env_1.default.XRAY_CLIENT_SECRET && testExecKey && testExecKey !== 'none') { - const jsonPath = 'test-results/last-run.json'; - if (node_fs_1.default.existsSync(jsonPath)) { - await this.processAndUpload(jsonPath); - } - } - } -} -exports.default = XrayJsonReporter; diff --git a/build/utilities/xray-reporter.js b/build/utilities/xray-reporter.js deleted file mode 100644 index 0532c49..0000000 --- a/build/utilities/xray-reporter.js +++ /dev/null @@ -1,134 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const node_fs_1 = __importDefault(require("node:fs")); -const env_1 = __importDefault(require("./env")); -/** - * Reporter class for uploading test results to Xray - */ -class XRayReporter { - constructor() { - this.styles = { - success: 'āœ…', - error: 'āŒ', - info: 'ā„¹ļø', - warning: 'ā›”ļø', - upload: 'šŸš€', - test: '🧪', - separator: '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', - }; - } - /** - * Authenticates with Xray API using client credentials - * @returns {Promise} The authentication token - * @throws {Error} If authentication fails - */ - async authenticateWithXray() { - try { - console.log(`${this.styles.info} Authenticating with Xray...`); - const response = await fetch('https://xray.cloud.getxray.app/api/v1/authenticate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - client_id: env_1.default.XRAY_CLIENT_ID, - client_secret: env_1.default.XRAY_CLIENT_SECRET, - }), - }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}, ${response.body}`); - } - const data = await response.json(); - console.log(`${this.styles.success} Successfully authenticated with Xray`); - return data.token; - } - catch (error) { - console.error(`${this.styles.error} Failed to authenticate with Xray:`, error); - throw error; - } - } - /** - * Uploads test results to Xray - * @param {string} token - The authentication token - * @param {string} xmlContent - The JUnit XML content to upload - * @returns {Promise} - * @throws {Error} If upload fails - */ - async uploadTestResults(token, xmlContent) { - try { - console.log(`${this.styles.info} Uploading test results to Xray...`); - const response = await fetch('https://xray.cloud.getxray.app/api/v2/import/execution/junit?projectKey=XT&testPlanKey=XT-380', { - method: 'POST', - headers: { - 'Content-Type': 'text/xml', - Authorization: `Bearer ${token}`, - }, - body: xmlContent, - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, Response: ${errorText}`); - } - console.log(`${this.styles.success} Successfully uploaded test results to Xray`); - } - catch (error) { - console.error(`${this.styles.error} Failed to upload test results to Xray:`, error); - throw error; - } - } - /** - * Called when test run begins - * @param suite - Test suite object containing all tests - */ - onBegin(_config, suite) { - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); - console.log(`${this.styles.separator}\n`); - } - /** - * Called when a test begins - * @param test - Test case object - */ - onTestBegin(test, _result) { - console.log(`${this.styles.test} Starting: ${test.title}`); - } - /** - * Called when a test ends - * @param {Object} test - Test case object - * @param {Object} result - Test result object containing status and other details - */ - onTestEnd(test, result) { - const statusEmoji = result.status === 'passed' ? this.styles.success : this.styles.error; - console.log(`${statusEmoji} Finished: ${test.title} (${result.status})`); - } - /** - * Called when all tests have finished - * @param result - Full test run result object containing status and duration - */ - async onEnd(result) { - console.log(`\n${this.styles.separator}`); - console.log(`${this.styles.info} Test Run Summary:`); - console.log(`Status: ${result.status === 'passed' ? this.styles.success : this.styles.error} ${result.status}`); - console.log(`Duration: ${result.duration}ms`); - console.log(`${this.styles.separator}\n`); - if (!(env_1.default.XRAY_CLIENT_ID || env_1.default.XRAY_CLIENT_SECRET)) { - console.log(`${this.styles.warning} No Xray client ID or secret found, skipping upload to JIRA Xray`); - return; - } - try { - console.log(`${this.styles.info} Reading test results file...`); - const testResults = node_fs_1.default.readFileSync('./test-results/test-results.xml', 'utf8'); - const token = await this.authenticateWithXray(); - await this.uploadTestResults(token, testResults); - console.log(`${this.styles.upload} Successfully uploaded test results to Xray`); - } - catch (error) { - console.error(`${this.styles.error} Failed to process test results:`, error); - } - console.log(`${this.styles.separator}\n`); - } -} -exports.default = XRayReporter; From 6f9bb44cb4315b346cca5e6c2ddd5b2ff015e9bc Mon Sep 17 00:00:00 2001 From: Foscottl-TP Date: Tue, 24 Feb 2026 10:13:32 -0800 Subject: [PATCH 46/60] Add MRN/email inputs and patient edit helpers Enhance clinician page objects and network tooling to support adding/editing patients and stronger API capture/validation. Key changes: - page-objects/clinician/ClinicianDashboardPage: add MRN and Email inputs, editPatientDetails button, clickPatientCell/clickEditPatientDetails helpers, loosen patient cell matching. - page-objects/clinician/ClinicianNavigation: update Profile/ProfileEdit verify locators to target Edit Patient Details heading/button. - page-objects/patient/ProfilePage: normalize field locators (Full Name / Birthdate / Date of Birth), add saveButton and fillDateOfBirth, simplify saveProfile to click the button. - tests: add new clinician API-profile tests (Clinician-AddAndDeletePatient, Clinician-EditCustodialProfile) and personal AccountSettings test; rename many test files and spec titles to standardize naming; remove obsolete duplicate tests. - tests/fixtures/network-helpers.ts: add waitForCaptureMatching, reloadPage, validateResponseFields, and improve getNestedValue to handle array indices for robust response validation and timing-sensitive GET captures. - playwright.config.ts: point xray reporter to built JS output. These changes enable full add/edit/delete patient flows from clinician UI and improve deterministic API response capture/validation for tests. TLDR: Renamed Tests for xray test reporting reasons, fixed local executions so the report to xray correctly targets the right test/execution project. Test coverage corrected to match specifications of seagull merge ticket. --- .../clinician/ClinicianDashboardPage.ts | 37 +- page-objects/clinician/ClinicianNavigation.ts | 8 +- page-objects/patient/ProfilePage.ts | 49 +-- playwright.config.ts | 2 +- ...AccountSettings-Claimed-EditEmail.spec.ts} | 6 +- ...ountSettings-Claimed-EditFullName.spec.ts} | 10 +- ...d-EditProfileDetailsAccessConfirm.spec.ts} | 52 +-- .../Clinician-AddAndDeletePatient.spec.ts | 255 ++++++++++++++ .../Clinician-EditCustodialProfile.spec.ts | 122 +++++++ .../edit-custodial-profile-API.spec.ts | 115 ------ ...> Clinician-CreateClinicWorkspace.spec.ts} | 4 +- ...ts => Clinician-EditClinicDetails.spec.ts} | 2 +- ...=> Clinician-FilterPatientsByName.spec.ts} | 20 +- tests/clinician/add-delete-patient.spec.ts | 91 ----- tests/fixtures/network-helpers.ts | 136 +++++++- ...AccountSettings-Personal-EditEmail.spec.ts | 122 +++++++ ...ountSettings-Personal-EditFullName.spec.ts | 135 +++++++ ...ts => Personal-EditProfileDetails.spec.ts} | 65 ++-- ...ogin.spec.ts => Login-Validations.spec.ts} | 8 +- ...s => Patient-DisplayFunctionality.spec.ts} | 4 +- utilities/env.ts | 2 + utilities/xray-json-reporter.ts | 328 +++++++++++++++--- 22 files changed, 1197 insertions(+), 376 deletions(-) rename tests/claimed/{API-User/claimed-email-edit.spec.ts => API-Profile/AccountSettings-Claimed-EditEmail.spec.ts} (96%) rename tests/claimed/API-Profile/{claimed-profile-edit-fullname.spec.ts => AccountSettings-Claimed-EditFullName.spec.ts} (94%) rename tests/claimed/API-Profile/{comprehensive-profile-access-test.spec.ts => Claimed-EditProfileDetailsAccessConfirm.spec.ts} (82%) create mode 100644 tests/clinician/API-Profile/Clinician-AddAndDeletePatient.spec.ts create mode 100644 tests/clinician/API-Profile/Clinician-EditCustodialProfile.spec.ts delete mode 100644 tests/clinician/API-Profile/edit-custodial-profile-API.spec.ts rename tests/clinician/{create-clinic-workspace.spec.ts => Clinician-CreateClinicWorkspace.spec.ts} (96%) rename tests/clinician/{edit-clinic-address.spec.ts => Clinician-EditClinicDetails.spec.ts} (98%) rename tests/clinician/{filter-patient.spec.ts => Clinician-FilterPatientsByName.spec.ts} (83%) delete mode 100644 tests/clinician/add-delete-patient.spec.ts create mode 100644 tests/personal/AP-Profile/AccountSettings-Personal-EditEmail.spec.ts create mode 100644 tests/personal/AP-Profile/AccountSettings-Personal-EditFullName.spec.ts rename tests/personal/AP-Profile/{edit-personal-profile-API.spec.ts => Personal-EditProfileDetails.spec.ts} (59%) rename tests/personal/{login.spec.ts => Login-Validations.spec.ts} (94%) rename tests/personal/{basic-functionality.spec.ts => Patient-DisplayFunctionality.spec.ts} (99%) diff --git a/page-objects/clinician/ClinicianDashboardPage.ts b/page-objects/clinician/ClinicianDashboardPage.ts index 22baba1..c06413e 100644 --- a/page-objects/clinician/ClinicianDashboardPage.ts +++ b/page-objects/clinician/ClinicianDashboardPage.ts @@ -23,6 +23,10 @@ class ClinicianDashboardPage { readonly addPatientDialog_birthdateInput: Locator; + readonly addPatientDialog_mrnInput: Locator; + + readonly addPatientDialog_emailInput: Locator; + readonly addPatientDialog_addButton: Locator; // Locators for the Bring Data Dialog @@ -35,6 +39,8 @@ class ClinicianDashboardPage { readonly removePatientButton: Locator; + readonly editPatientDetailsButton: Locator; + readonly removePatientConfirm: Locator; constructor(page: Page) { @@ -56,6 +62,12 @@ class ClinicianDashboardPage { this.addPatientDialog_birthdateInput = this.addPatientDialog.getByRole('textbox', { name: 'Birthdate', }); + this.addPatientDialog_mrnInput = this.addPatientDialog.getByRole('textbox', { + name: 'MRN (optional)', + }); + this.addPatientDialog_emailInput = this.addPatientDialog.getByRole('textbox', { + name: 'Email (optional)', + }); this.addPatientDialog_addButton = this.addPatientDialog.getByRole('button', { name: 'Add Patient', }); @@ -71,6 +83,9 @@ class ClinicianDashboardPage { .getByRole('button', { name: /info|\.\.\./i }) .first(); this.removePatientButton = this.page.getByRole('button', { name: /remove patient/i }).first(); + this.editPatientDetailsButton = this.page + .getByRole('button', { name: /edit patient details/i }) + .first(); this.removePatientConfirm = this.page.getByRole('button', { name: /^Remove$/i }); } @@ -78,12 +93,21 @@ class ClinicianDashboardPage { * Opens the Add Patient dialog and fills in the patient details. * @param name - The full name of the patient. * @param birthdate - The birthdate of the patient (e.g., MM/DD/YYYY). + * @param mrn - The medical record number of the patient. + * @param email - The email address of the patient. */ - async openAndFillAddPatientDialog(name: string, birthdate: string): Promise { + async openAndFillAddPatientDialog( + name: string, + birthdate: string, + mrn: string, + email: string, + ): Promise { await this.addNewPatientButton.click(); await this.addPatientDialog.waitFor({ state: 'visible' }); await this.addPatientDialog_fullNameInput.fill(name); await this.addPatientDialog_birthdateInput.fill(birthdate); + await this.addPatientDialog_mrnInput.fill(mrn); + await this.addPatientDialog_emailInput.fill(email); } /** @@ -123,7 +147,7 @@ class ClinicianDashboardPage { */ getPatientCellByName(name: string): Locator { // Use exact match to avoid multiple matches with similar names - return this.patientListTable.getByRole('cell', { name, exact: true }); + return this.patientListTable.getByRole('cell', { name, exact: false }); } /** @@ -139,10 +163,19 @@ class ClinicianDashboardPage { await this.page.waitForTimeout(500); } + async clickPatientCell(name: string): Promise { + const patientCell = this.getPatientCellByName(name); + await patientCell.click(); + } + async clickRemovePatientMenuItem(): Promise { await this.removePatientButton.click(); } + async clickEditPatientDetailsMenuItem(): Promise { + await this.editPatientDetailsButton.click(); + } + async confirmRemovePatient(): Promise { await this.removePatientConfirm.click(); } diff --git a/page-objects/clinician/ClinicianNavigation.ts b/page-objects/clinician/ClinicianNavigation.ts index d1bdcf9..d331e8d 100644 --- a/page-objects/clinician/ClinicianNavigation.ts +++ b/page-objects/clinician/ClinicianNavigation.ts @@ -89,14 +89,12 @@ export default class ClinicianNav { .or(page.getByRole('link', { name: 'Profile' })) .or(page.getByRole('button', { name: 'Profile' })), verifyURL: 'profile', - verifyElement: page - .getByRole('button', { name: 'Edit' }) - .or(page.getByRole('button', { name: 'Edit Profile' })), + verifyElement: page.getByRole('heading', { name: 'Edit Patient Details' }), }, ProfileEdit: { link: page - .getByRole('button', { name: 'Edit' }) - .or(page.getByRole('button', { name: 'Edit Profile' })), + .getByRole('button', { name: 'Edit Patient Details' }) + .or(page.getByRole('button', { name: 'Edit Patient Details' })), verifyURL: 'profile', verifyElement: page .getByRole('button', { name: 'Save changes' }) diff --git a/page-objects/patient/ProfilePage.ts b/page-objects/patient/ProfilePage.ts index e2971c3..6f210ae 100644 --- a/page-objects/patient/ProfilePage.ts +++ b/page-objects/patient/ProfilePage.ts @@ -6,16 +6,21 @@ export class ProfilePage { // Centralized field locators private fieldLocators: Record; + private saveButton: Locator; + constructor(page: Page) { this.page = page; this.fieldLocators = { - fullName: this.page.getByRole('textbox', { name: 'Full name' }), - birthDate: this.page.getByRole('textbox', { name: 'Date of birth' }), + fullName: this.page.getByRole('textbox', { name: 'Full Name' }), + birthDate: this.page.getByRole('textbox', { name: 'Birthdate' }), + dateOfBirth: this.page.getByRole('textbox', { name: 'Date of Birth' }), // for claimed profile version mrn: this.page.getByRole('textbox', { name: 'MRN' }), - diagnosisDate: this.page.getByRole('textbox', { name: 'Date of diagnosis' }), + // diagnosisDate: this.page.getByRole('textbox', { name: 'Date of diagnosis' }), clinicalNotes: this.page.getByRole('textbox', { name: 'Anything you would like to share' }), email: this.page.getByRole('textbox', { name: /email/i }), }; + + this.saveButton = this.page.getByRole('button', { name: 'Save Changes' }); } // Generic fill method for text fields @@ -64,6 +69,10 @@ export class ProfilePage { return this.fillField('birthDate', date); } + async fillDateOfBirth(date: string) { + return this.fillField('dateOfBirth', date); // redundant for claimed profile version + } + async fillMRN(mrn: string) { return this.fillField('mrn', mrn); } @@ -80,38 +89,8 @@ export class ProfilePage { return this.fillField('email', email); } - async saveProfile(): Promise { - // Save button locators - const saveButtons = [ - this.page.getByRole('button', { name: 'Save changes' }), - this.page.getByRole('button', { name: 'Save Profile' }), - this.page.getByRole('button', { name: 'Save' }), - ]; - - // Wait for the PUT request to complete after clicking save - const saveProfilePromise = this.page.waitForResponse( - response => - response.url().includes('/metadata/') && - response.url().includes('/profile') && - response.request().method() === 'PUT', - ); - - let clicked = false; - for (const btn of saveButtons) { - if (await btn.isVisible({ timeout: 5000 }).catch(() => false)) { - await btn.click(); - clicked = true; - break; - } - } - if (!clicked) throw new Error('No save button found'); - - // Wait for the PUT request to complete (with timeout) - try { - await saveProfilePromise; - } catch (error) { - console.log('āš ļø PUT request timeout - continuing anyway'); - } + async saveProfile() { + await this.saveButton.click(); } /** diff --git a/playwright.config.ts b/playwright.config.ts index 6bc3197..bf41c1f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -38,7 +38,7 @@ export default defineConfig({ reporter: [ ['html', { open: 'never', outputFolder: 'playwright-report' }], ['json', { outputFile: 'test-results/last-run.json' }], - ['./utilities/xray-json-reporter.ts'], + ['./build/utilities/xray-json-reporter.js'], ], use: { diff --git a/tests/claimed/API-User/claimed-email-edit.spec.ts b/tests/claimed/API-Profile/AccountSettings-Claimed-EditEmail.spec.ts similarity index 96% rename from tests/claimed/API-User/claimed-email-edit.spec.ts rename to tests/claimed/API-Profile/AccountSettings-Claimed-EditEmail.spec.ts index 8109075..713a370 100644 --- a/tests/claimed/API-User/claimed-email-edit.spec.ts +++ b/tests/claimed/API-Profile/AccountSettings-Claimed-EditEmail.spec.ts @@ -5,12 +5,12 @@ import { createNetworkHelper } from '../../fixtures/network-helpers'; import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; import { AccountSettingsPage } from '../../../page-objects/account/AccountSettingsPage'; -test.describe('Clinician Account Settings Access', () => { +test.describe('Account Settings - Claimed - Edit Email', () => { // API Test cases require this to capture network activity let api: ReturnType; test( - 'should allow navigation to account settings and capture GET response', + 'Account Settings - Claimed - Edit Email', { tag: createValidatedTags([ TEST_TAGS.PATIENT, @@ -18,7 +18,7 @@ test.describe('Clinician Account Settings Access', () => { TEST_TAGS.API, TEST_TAGS.UI, TEST_TAGS.HIGH, - TEST_TAGS.API_USER, + TEST_TAGS.API_PROFILE, ]), }, async ({ page }) => { diff --git a/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts b/tests/claimed/API-Profile/AccountSettings-Claimed-EditFullName.spec.ts similarity index 94% rename from tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts rename to tests/claimed/API-Profile/AccountSettings-Claimed-EditFullName.spec.ts index ec63a04..4e65b72 100644 --- a/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts +++ b/tests/claimed/API-Profile/AccountSettings-Claimed-EditFullName.spec.ts @@ -18,7 +18,7 @@ test.describe('Claimed Account Settings edit (Full Name only) updates Profile en let newName: string; // Declare at test level scope test( - 'should allow navigation to account settings, edit full name, and verify profile update for claimed, shared, and clinician users', + 'Account Settings - Claimed - Edit Full Name', { tag: createValidatedTags([ TEST_TAGS.PATIENT, @@ -178,13 +178,7 @@ test.describe('Claimed Account Settings edit (Full Name only) updates Profile en await clinicTest.clinician.navigateTo('Profile', page); }); - // Step 15: Verify Edit button is not present for claimed patients viewed by clinicians - await test.step('Then Edit button should not be present for claimed patients', async () => { - const profilePage = new ProfilePage(page); - await profilePage.editButtonDisplays(false); - }); - - // Step 16: Validate clinician sees updated profile data + // Step 15: Validate clinician sees updated profile data await (test as any).stepNoScreenshot( 'Then clinician sees claimed profile data with matching data and no save access', async () => { diff --git a/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.ts b/tests/claimed/API-Profile/Claimed-EditProfileDetailsAccessConfirm.spec.ts similarity index 82% rename from tests/claimed/API-Profile/comprehensive-profile-access-test.spec.ts rename to tests/claimed/API-Profile/Claimed-EditProfileDetailsAccessConfirm.spec.ts index 9f09401..a524f35 100644 --- a/tests/claimed/API-Profile/comprehensive-profile-access-test.spec.ts +++ b/tests/claimed/API-Profile/Claimed-EditProfileDetailsAccessConfirm.spec.ts @@ -4,6 +4,7 @@ import { test as clinicTest } from '../../fixtures/clinic-helpers'; import { test as accountTest } from '../../fixtures/account-helpers'; import { createNetworkHelper } from '../../fixtures/network-helpers'; import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { getProfileMetadataSchema } from '../../../endpoint-schema/profile-endpoints'; import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; @@ -11,22 +12,22 @@ const CLAIMED_PATIENT_SEARCH = 'Claimed Patient'; test.describe('Comprehensive Profile Access Test: Edit as Claimed, View as Shared and Clinician', () => { test( - 'should edit claimed profile then verify view-only access for shared and clinician users', + 'Claimed - Edit Profile Details with Access Confirmation', { tag: createValidatedTags([ - TEST_TAGS.PATIENT, // User Type (required) - TEST_TAGS.CLINICIAN, // User Type (required) + TEST_TAGS.PATIENT, + TEST_TAGS.CLINICIAN, TEST_TAGS.CLAIMED, TEST_TAGS.SHARED_MEMBER, - TEST_TAGS.API, // Test Type (required) - TEST_TAGS.UI, // Test Type (required) - TEST_TAGS.HIGH, // Priority (required) - TEST_TAGS.API_PROFILE, // Feature (optional) + TEST_TAGS.API, + TEST_TAGS.UI, + TEST_TAGS.HIGH, + TEST_TAGS.API_PROFILE, ]), }, async ({ page }) => { let api: ReturnType; - let producerPutCapture: any; + let producerGetCapture: any; // ========== PHASE 1: CLAIMED USER EDITS PROFILE ========== @@ -66,7 +67,6 @@ test.describe('Comprehensive Profile Access Test: Edit as Claimed, View as Share const birthYear = 1985 + (testRunId % 10); const diagnosisYear = birthYear + 20; const birthDate = `01/15/${birthYear}`; - const diagnosisDate = `03/10/${diagnosisYear}`; // Generate random 15-letter string for clinical notes const randomString = Array.from({ length: 15 }, () => @@ -82,8 +82,7 @@ test.describe('Comprehensive Profile Access Test: Edit as Claimed, View as Share // Update fields using ProfilePage methods await profilePage.fillFullName(updatedName); - await profilePage.fillBirthDate(birthDate); - await profilePage.fillDiagnosisDate(diagnosisDate); + await profilePage.fillDateOfBirth(birthDate); await profilePage.selectDiagnosisType(nextDiagnosisIndex); await profilePage.fillClinicalNotes(randomString); }); @@ -93,14 +92,22 @@ test.describe('Comprehensive Profile Access Test: Edit as Claimed, View as Share await profilePage.saveProfile(); }); - // Step 7: PUT response is validated and saved for comparison + // Step 7: GET response is validated and saved for comparison await (test as any).stepNoScreenshot( - 'Then profile endpoint responds with PUT request consistent with schema', + 'Then profile endpoint responds with GET request consistent with schema', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - const putSchema = await import('../../../endpoint-schema/profile-endpoints'); - const schema = putSchema.putProfileMetadataSchema; - producerPutCapture = api.getLatestCaptureMatching(schema.method, schema.url as RegExp); + await api.reloadPage('load'); + const clickTimestamp = Date.now(); + producerGetCapture = await api.waitForCaptureMatching( + getProfileMetadataSchema.method, + getProfileMetadataSchema.url as RegExp, + clickTimestamp, + 15000, + ); + await api.validateEndpointResponse('profile-metadata-get'); + const getSchema = await import('../../../endpoint-schema/profile-endpoints'); + const schema = getSchema.getProfileMetadataSchema; + producerGetCapture = api.getLatestCaptureMatching(schema.method, schema.url as RegExp); }, ); @@ -127,7 +134,8 @@ test.describe('Comprehensive Profile Access Test: Edit as Claimed, View as Share await (test as any).stepNoScreenshot( 'Then shared user sees view-only claimed profile data with matching data', async () => { - await api.compareEndpointResponse('profile-metadata-get', producerPutCapture); + await api.reloadPage('load'); + await api.compareEndpointResponse('profile-metadata-get', producerGetCapture); }, ); @@ -150,16 +158,12 @@ test.describe('Comprehensive Profile Access Test: Edit as Claimed, View as Share await clinicTest.clinician.navigateTo('Profile', page); }); - // Step 15: Confirm edit button is not present - await test.step('Then Edit button should not be present for claimed patients', async () => { - await profilePage.editButtonDisplays(false); - }); - // Step 16: Validate GET response and confirm appropriate permissions await (test as any).stepNoScreenshot( 'Then clinician sees claimed profile data with matching data and no save access', async () => { - await api.compareEndpointResponse('profile-metadata-get', producerPutCapture); + await api.reloadPage('load'); + await api.compareEndpointResponse('profile-metadata-get', producerGetCapture); }, ); }, diff --git a/tests/clinician/API-Profile/Clinician-AddAndDeletePatient.spec.ts b/tests/clinician/API-Profile/Clinician-AddAndDeletePatient.spec.ts new file mode 100644 index 0000000..70be014 --- /dev/null +++ b/tests/clinician/API-Profile/Clinician-AddAndDeletePatient.spec.ts @@ -0,0 +1,255 @@ +import { expect } from '@fixtures/base'; +import { test, ALL_WORKSPACE_KEYS } from '@fixtures/clinic-helpers'; +import { WorkspaceKey } from '@pom/clinician/ClinicianNavigation'; +import { TEST_TAGS, createValidatedTags } from '@fixtures/test-tags'; +import ClinicianDashboardPage from '@pom/clinician/ClinicianDashboardPage'; +import { getProfileMetadataSchema } from '../../../endpoint-schema/profile-endpoints'; +import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; +import { createNetworkHelper } from '../../fixtures/network-helpers'; + +ALL_WORKSPACE_KEYS.forEach((workspace: WorkspaceKey) => { + test.describe('Custodial patients are allowed access and modification of profile details', () => { + // Define the patient data at top level (unique per workspace) + const currentDate = Date.now(); + const workspaceId = workspace.replace(/[^a-zA-Z0-9]/g, ''); // Clean workspace name for IDs + const patientName = `New Patient ${currentDate}`; + const patientBirthdate = '01/01/2000'; + const patientMRN = '123456789'; + const patientEmail = `webuiautomation+createdprofile${currentDate}@tidepool.org`; // must be lowercase to pass email validation + const randomSeed = Math.random(); + const randomId = Math.floor(randomSeed * 10000); + const updatedName = `New Patient Updated ${Math.floor(Math.random() * 10000)}-${workspaceId}`; + const updateBirthDate = `05/20/1991`; + const updateMRN = Array.from({ length: 15 }, () => + Math.floor(Math.random() * 10).toString(), + ).join(''); + const updateEmail = `webuiautomation+updatedprofile${randomId}@tidepool.org`; // must be lowercase to pass email validation + + // API Test cases require this to capture network activity + let api: ReturnType; + let producerGetCapture: any; + + test( + `Clinician - Add Patient->Edit->Delete [${workspace}]`, + { + tag: createValidatedTags([ + TEST_TAGS.CLINICIAN, + TEST_TAGS.API, + TEST_TAGS.UI, + TEST_TAGS.HIGH, + TEST_TAGS.API_PROFILE, + ]), + }, + async ({ page }, testInfo) => { + // Step 1: Log in to clinician account and setup network capture + await test.step('Given clinician has been logged in', async () => { + api = createNetworkHelper(page); + await api.startCapture(); + await test.clinician.setup(page); + }); + + // Step 2: Navigate to workspace + await test.step(`When user navigates to workspace ${workspace}`, async () => { + await test.clinician.navigateToWorkspace(workspace, page); + }); + + // Create pages + const clinicianDashboardPage = new ClinicianDashboardPage(page); + + // Step 3: Click the New Patient button and fill out the form + await test.step('When user clicks the new patient button and fills out the form', async () => { + await clinicianDashboardPage.openAndFillAddPatientDialog( + patientName, + patientBirthdate, + patientMRN, + patientEmail, + ); + }); + + // Step 4: Submit the New Patient form + await test.step('When user submits the new patient form', async () => { + await clinicianDashboardPage.submitAddPatientDialog(); + }); + + // Step 5: Close Bring Data Dialog + await test.step('When user closes the bring data dialog', async () => { + await clinicianDashboardPage.closeBringDataDialog(); + }); + + // Step 6: Search for the newly added patient + await test.step('When user searches for the newly added patient', async () => { + await clinicianDashboardPage.searchForPatient(patientName); + }); + + // Step 7: Verify the new patient appears in the patient list + await test.step('Then the new patient should appear in the patient list', async () => { + await clinicianDashboardPage.searchForPatient(patientName); + const patientCell = clinicianDashboardPage.getPatientCellByName(patientName); + await expect(patientCell).toBeVisible(); + }); + + // Step 8: Click the first patient in the list and capture profile load + await test.step('When user clicks on the patient in the list', async () => { + const clickTimestamp = Date.now(); + await clinicianDashboardPage.clickPatientCell(patientName); + + // Wait for the profile GET request to complete after the click + producerGetCapture = await api.waitForCaptureMatching( + getProfileMetadataSchema.method, + getProfileMetadataSchema.url as RegExp, + clickTimestamp, + 15000, // Wait up to 15 seconds + ); + }); + + // Step 9: Validate captured response and creation values + await (test as any).stepNoScreenshot( + 'Then profile endpoint responds with GET request consistent with schema and saved values [no-screenshot]', + async () => { + await api.validateEndpointResponse('profile-metadata-get'); + + // Validate that creation values appear in correct GET response fields + const expectedFieldValues = { + fullName: patientName, + 'patient.birthday': '2000-01-01', // API returns birthdate in YYYY-MM-DD format + 'patient.mrn': patientMRN, + email: patientEmail, + }; + + api.validateResponseFields(producerGetCapture, expectedFieldValues); + }, + ); + + // Step 10: Click 'Edit Patient Details' option from the dropdown + await test.step("When user clicks 'Edit Patient Details' option", async () => { + await clinicianDashboardPage.clickEditPatientDetailsMenuItem(); + }); + + // Step 11: Change profile fields + await test.step('When user updates profile fields', async () => { + const profilePage = new ProfilePage(page); + + // Get current diagnosis index and calculate next one (1-7, wrapping) + const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); + let nextDiagnosisIndex = currentDiagnosisIndex + 1; + if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { + nextDiagnosisIndex = 1; + } + + // Update fields using ProfilePage methods + await profilePage.fillFullName(updatedName); + await profilePage.fillBirthDate(updateBirthDate); + await profilePage.fillMRN(updateMRN); + await profilePage.selectDiagnosisType(nextDiagnosisIndex); + await profilePage.fillEmail(updateEmail); + }); + + // Step 12: Save profile edit + await test.step('When user saves profile changes', async () => { + const profilePage = new ProfilePage(page); + await profilePage.saveProfile(); + }); + + // Step 13: Navigate to workspace + await test.step(`When user navigates to workspace ${workspace}`, async () => { + await test.clinician.navigateToWorkspace(workspace, page); + }); + + // Step 14: Search for the edited patient + await test.step('When user searches for the new edited patient', async () => { + await clinicianDashboardPage.searchForPatient(updatedName); + }); + + // Step 15: Verify the edited patient appears in the patient list + await test.step('Then the edited patient should appear in the patient list', async () => { + await clinicianDashboardPage.searchForPatient(updatedName); + const patientCell = clinicianDashboardPage.getPatientCellByName(updatedName); + await expect(patientCell).toBeVisible(); + }); + + // Step 16: Click the first patient in the list and capture profile load + await test.step('When user clicks on the patient in the list', async () => { + const clickTimestamp = Date.now(); + await clinicianDashboardPage.clickPatientCell(updatedName); + + // Wait for the profile GET request to complete after the click + producerGetCapture = await api.waitForCaptureMatching( + getProfileMetadataSchema.method, + getProfileMetadataSchema.url as RegExp, + clickTimestamp, + 15000, // Wait up to 15 seconds + ); + }); + + // Step 17: Validate captured response and creation values + await (test as any).stepNoScreenshot( + 'Then profile endpoint responds with GET request consistent with schema and saved values [no-screenshot]', + async () => { + await api.validateEndpointResponse('profile-metadata-get'); + + // Validate that creation values appear in correct GET response fields + const expectedFieldValues = { + fullName: updatedName, + 'patient.birthday': '1991-05-20', // API returns birthdate in YYYY-MM-DD format + 'patient.mrn': updateMRN, + email: updateEmail, + }; + + api.validateResponseFields(producerGetCapture, expectedFieldValues); + }, + ); + + // Step 18: Navigate to workspace + await test.step(`When user navigates to workspace ${workspace}`, async () => { + await test.clinician.navigateToWorkspace(workspace, page); + }); + + // Step 19: Search for the edited patient + await test.step('When user searches for the new edited patient', async () => { + await clinicianDashboardPage.searchForPatient(updatedName); + }); + + // Step 20: Verify the edited patient appears in the patient list + await test.step('Then the edited patient should appear in the patient list', async () => { + await clinicianDashboardPage.searchForPatient(updatedName); + const patientCell = clinicianDashboardPage.getPatientCellByName(updatedName); + await expect(patientCell).toBeVisible(); + }); + + // Step 21: Select '...' within the patient row + await test.step('When user opens the options dropdown for the patient', async () => { + await clinicianDashboardPage.openFirstPatientOptionsDropdown(); + }); + + // Step 21a: Member users do not have remove patient option + if (workspace.includes('Member')) { + await test.step('Then Remove Patient option is not present for Member users', async () => { + await expect(clinicianDashboardPage.removePatientButton).not.toBeVisible(); + }); + return; + } + + // Step 22: Click 'Remove Patient' option from the dropdown + await test.step("When user clicks 'Remove Patient' option", async () => { + await clinicianDashboardPage.clickRemovePatientMenuItem(); + }); + + // Step 23: Click Remove button in confirmation dialog + await test.step('When user confirms patient removal', async () => { + await clinicianDashboardPage.confirmRemovePatient(); + }); + + // Step 24: Search for the removed patient + await test.step('When user searches for the removed patient', async () => { + await clinicianDashboardPage.searchForPatient(updatedName); + }); + + // Step 25: Verify the deleted patient does not appear in patient list + await test.step('Then the deleted patient should not appear in the patient list', async () => { + const patientCell = clinicianDashboardPage.getPatientCellByName(updatedName); + await expect(patientCell).not.toBeVisible(); + }); + }, + ); + }); +}); diff --git a/tests/clinician/API-Profile/Clinician-EditCustodialProfile.spec.ts b/tests/clinician/API-Profile/Clinician-EditCustodialProfile.spec.ts new file mode 100644 index 0000000..c24b2bb --- /dev/null +++ b/tests/clinician/API-Profile/Clinician-EditCustodialProfile.spec.ts @@ -0,0 +1,122 @@ +import * as fs from 'node:fs'; +import { WorkspaceKey } from '@pom/clinician/ClinicianNavigation'; +import { test, ALL_WORKSPACE_KEYS } from '../../fixtures/clinic-helpers'; +import { createNetworkHelper } from '../../fixtures/network-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; + +ALL_WORKSPACE_KEYS.forEach((workspace: WorkspaceKey) => { + test.describe('Custodial patients are allowed access and modification of profile details', () => { + // Define the patient search term + const CUSTODIAL_PATIENT_SEARCH = 'Custodial Patient'; + + const updatedName = `Custodial Patient Updated ${Math.floor( + Math.random() * 10000, + )}-${workspace}`; + const updateBirthYear = 1990 + Math.floor(Math.random() * 30); + const updateBirthDate = `05/20/${updateBirthYear}`; + const updateMRN = Array.from({ length: 15 }, () => + Math.floor(Math.random() * 10).toString(), + ).join(''); + const updateEmail = `webuiautomation+updatedprofile${Math.floor(Math.random() * 10000)}@tidepool.org`; // must be lowercase to pass email validation + + // API Test cases require this to capture network activity + let api: ReturnType; + let producerGetCapture: any; + + test( + `Clinician - Edit Custodial Profile [${workspace}]`, + { + tag: createValidatedTags([ + TEST_TAGS.CLINICIAN, + TEST_TAGS.API, + TEST_TAGS.UI, + TEST_TAGS.HIGH, + TEST_TAGS.API_PROFILE, + ]), + }, + async ({ page }, testInfo) => { + // Step 1: Log in to clinician account and setup network capture + await test.step('Given clinician has been logged in', async () => { + api = createNetworkHelper(page); + await api.startCapture(); + await test.clinician.setup(page); + }); + + // Step 2: Navigate to workspace + await test.step(`When user navigates to workspace ${workspace}`, async () => { + await test.clinician.navigateToWorkspace(workspace, page); + }); + + // Step 3: Access custodial patient + await test.step('When user accesses a custodial patient summary', async () => { + await test.clinician.findAndAccessPatientByPartialName(CUSTODIAL_PATIENT_SEARCH, page); + }); + + // Step 4: Navigate to profile + await test.step('When user navigates to Profile Edit page', async () => { + await test.clinician.navigateTo('ProfileEdit', page); + }); + + // Step 5: Capture GET response + await test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }); + + // Create Profile page for following steps + const profilePage = new ProfilePage(page); + + // Step 7: Change profile fields (custodial access) + await test.step('When user updates profile fields', async () => { + // Get current diagnosis index and calculate next one (1-7, wrapping) + const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); + let nextDiagnosisIndex = currentDiagnosisIndex + 1; + if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { + nextDiagnosisIndex = 1; + } + + // Update fields using ProfilePage methods + await profilePage.fillFullName(updatedName); + await profilePage.fillBirthDate(updateBirthDate); + await profilePage.fillMRN(updateMRN); + await profilePage.selectDiagnosisType(nextDiagnosisIndex); + await profilePage.fillEmail(updateEmail); + }); + + // Step 8: Save profile edit + await test.step('When user saves profile changes', async () => { + await profilePage.saveProfile(); + }); + + // Step 13: Navigate to workspace + await test.step(`When user navigates to workspace ${workspace}`, async () => { + await test.clinician.navigateToWorkspace(workspace, page); + }); + + // Step 3: Access custodial patient + await test.step('When user accesses a custodial patient summary', async () => { + await test.clinician.findAndAccessPatientByPartialName(CUSTODIAL_PATIENT_SEARCH, page); + }); + + // Step 9: Validate captured response and creation values + await (test as any).stepNoScreenshot( + 'Then profile endpoint responds with GET request consistent with schema and saved values [no-screenshot]', + async () => { + producerGetCapture = await api.validateEndpointResponse('profile-metadata-get'); + + // Validate that creation values appear in correct GET response fields + const expectedFieldValues = { + fullName: updatedName, + 'patient.birthday': `${updateBirthYear}-05-20`, // API returns birthdate in YYYY-MM-DD format + 'patient.mrn': updateMRN, + email: updateEmail, + }; + + api.validateResponseFields(producerGetCapture, expectedFieldValues); + }, + ); + await api.stopCapture(); + }, + ); + }); +}); diff --git a/tests/clinician/API-Profile/edit-custodial-profile-API.spec.ts b/tests/clinician/API-Profile/edit-custodial-profile-API.spec.ts deleted file mode 100644 index 736adc4..0000000 --- a/tests/clinician/API-Profile/edit-custodial-profile-API.spec.ts +++ /dev/null @@ -1,115 +0,0 @@ -import * as fs from 'node:fs'; -import { test } from '../../fixtures/clinic-helpers'; -import { createNetworkHelper } from '../../fixtures/network-helpers'; -import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; -import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; - -test.describe('Custodial patients are allowed access and modification of profile details', () => { - // Define the workspace and patient at top level - const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; - const CUSTODIAL_PATIENT_SEARCH = 'Custodial Patient'; - - // API Test cases require this to capture network activity - let api: ReturnType; - - test( - 'should allow navigation to profile details and edit profile fields', - { - tag: createValidatedTags([ - TEST_TAGS.CLINICIAN, // User Type (required) - TEST_TAGS.API, // Test Type (required) - TEST_TAGS.UI, // Test Type (required) - TEST_TAGS.HIGH, // Priority (required) - TEST_TAGS.API_PROFILE, // Feature (optional) - ]), - }, - async ({ page }, testInfo) => { - // Step 1: Log in to clinician account and setup network capture - await test.step('Given clinician has been logged in', async () => { - api = createNetworkHelper(page); - await api.startCapture(); - await test.clinician.setup(page); - }); - - // Step 2: Navigate to workspace - await test.step('When user navigates to desired workspace', async () => { - await test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); - }); - - // Step 3: Access custodial patient - await test.step('When user accesses a custodial patient summary', async () => { - await test.clinician.findAndAccessPatientByPartialName(CUSTODIAL_PATIENT_SEARCH, page); - }); - - // Step 4: Navigate to profile - await test.step('When user navigates to Profile page', async () => { - await test.clinician.navigateTo('Profile', page); - }); - - // Step 5: Capture GET response - await test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - - // Step 6: Open Edit Profile - await test.step('When user selects Edit button', async () => { - await test.clinician.navigateTo('ProfileEdit', page); - }); - - // Create Profile page for following steps - const profilePage = new ProfilePage(page); - - // Step 7: Change profile fields (custodial access) - await test.step('When user updates profile fields', async () => { - // Generate completely unique values for this custodial test run - const randomSeed = Math.random(); - const randomId = Math.floor(randomSeed * 10000); - const updatedName = `Custodial Patient Updated ${Math.floor(randomId * 10000)}`; - const birthYear = 1980 + (randomId % 15); - const diagnosisYear = birthYear + 25; - const birthDate = `05/20/${birthYear}`; - const diagnosisDate = `08/15/${diagnosisYear}`; - - // Generate random 15-digit MRN - const randomMRN = Array.from({ length: 15 }, () => - Math.floor(Math.random() * 10).toString(), - ).join(''); - - // Generate random 15-letter string for clinical notes - const randomString = Array.from({ length: 15 }, () => - String.fromCharCode(65 + Math.floor(Math.random() * 26)), - ).join(''); - - // Generate unique email - const email = `webuiautomation+custodialEdit${randomId}@tidepool.org`; - - // Get current diagnosis index and calculate next one (1-7, wrapping) - const currentDiagnosisIndex = await profilePage.getCurrentDiagnosisIndex(); - let nextDiagnosisIndex = currentDiagnosisIndex + 1; - if (nextDiagnosisIndex > 7 || nextDiagnosisIndex === 0) { - nextDiagnosisIndex = 1; - } - - // Update fields using ProfilePage methods - await profilePage.fillFullName(updatedName); - await profilePage.fillBirthDate(birthDate); - await profilePage.fillMRN(randomMRN); - await profilePage.fillDiagnosisDate(diagnosisDate); - await profilePage.selectDiagnosisType(nextDiagnosisIndex); - await profilePage.fillEmail(email); - await profilePage.fillClinicalNotes(randomString); - }); - - // Step 8: Save profile edit - await test.step('When user saves profile changes', async () => { - await profilePage.saveProfile(); - }); - - // Step 9: Check profile PUT response - await test.step('Then profile endpoint responds with PUT request consistent with schema [no-screenshot]', async () => { - await api.validateEndpointResponse('profile-metadata-put'); - }); - await api.stopCapture(); - }, - ); -}); diff --git a/tests/clinician/create-clinic-workspace.spec.ts b/tests/clinician/Clinician-CreateClinicWorkspace.spec.ts similarity index 96% rename from tests/clinician/create-clinic-workspace.spec.ts rename to tests/clinician/Clinician-CreateClinicWorkspace.spec.ts index a58c31a..85eb77e 100644 --- a/tests/clinician/create-clinic-workspace.spec.ts +++ b/tests/clinician/Clinician-CreateClinicWorkspace.spec.ts @@ -5,12 +5,12 @@ import { test } from '../fixtures/clinic-helpers'; import { TEST_TAGS, createValidatedTags } from '../fixtures/test-tags'; -test.describe('Custodial patients are allowed access and modification of profile details', () => { +test.describe('Clinic Account may create a new workspace', () => { const uniqueSuffix = `${Date.now()}`; const clinicName = `Test Clinic ${uniqueSuffix}`; test( - 'should create a new workspace as admin', + 'Clinician - Create Clinic Workspace', { tag: createValidatedTags([TEST_TAGS.CLINICIAN, TEST_TAGS.UI, TEST_TAGS.HIGH]), }, diff --git a/tests/clinician/edit-clinic-address.spec.ts b/tests/clinician/Clinician-EditClinicDetails.spec.ts similarity index 98% rename from tests/clinician/edit-clinic-address.spec.ts rename to tests/clinician/Clinician-EditClinicDetails.spec.ts index 165619c..48a7f05 100644 --- a/tests/clinician/edit-clinic-address.spec.ts +++ b/tests/clinician/Clinician-EditClinicDetails.spec.ts @@ -9,7 +9,7 @@ import type { WorkspaceKey } from '@pom/clinician/ClinicianNavigation'; ALL_WORKSPACE_KEYS.forEach((workspace: WorkspaceKey) => { test.describe('Clinic admin given edit permissions to Workspace Details. Clinic Members have view only access', () => { test( - `should allow navigation to workspace details and edit workspace: "[${workspace}]"`, + `Clinician - Edit Clinic Details [${workspace}]`, { tag: createValidatedTags([TEST_TAGS.CLINICIAN, TEST_TAGS.UI, TEST_TAGS.MEDIUM]), }, diff --git a/tests/clinician/filter-patient.spec.ts b/tests/clinician/Clinician-FilterPatientsByName.spec.ts similarity index 83% rename from tests/clinician/filter-patient.spec.ts rename to tests/clinician/Clinician-FilterPatientsByName.spec.ts index 2b73f56..d238ed8 100644 --- a/tests/clinician/filter-patient.spec.ts +++ b/tests/clinician/Clinician-FilterPatientsByName.spec.ts @@ -8,6 +8,10 @@ test.describe('Filter patients in clinic', () => { const patientName1 = `Filter Patient A ${timestamp}`; const patientName2 = `Filter Patient B ${timestamp}`; const patientBirthdate = '01/01/1995'; // Shared birthdate for simplicity + const patnetMRN1 = `MRN-${timestamp}-1`; + const patnetMRN2 = `MRN-${timestamp}-2`; + const patientEmail1 = `webuiautomation+createdpatient-${timestamp}-1@tidepool.org`; + const patientEmail2 = `webuiautomation+createdpatient-${timestamp}-2@tidepool.org`; let workspacesPage: WorkspacesPage; let clinicWorkspacePage: ClinicianDashboardPage; @@ -29,7 +33,12 @@ test.describe('Filter patients in clinic', () => { await test.step('Given two patients exist', async () => { // Add first patient - await clinicWorkspacePage.openAndFillAddPatientDialog(patientName1, patientBirthdate); + await clinicWorkspacePage.openAndFillAddPatientDialog( + patientName1, + patientBirthdate, + patnetMRN1, + patientEmail1, + ); await clinicWorkspacePage.submitAddPatientDialog(); await clinicWorkspacePage.closeBringDataDialog(); // Ensure the first patient is added before adding the second @@ -38,7 +47,12 @@ test.describe('Filter patients in clinic', () => { }); // Add second patient - await clinicWorkspacePage.openAndFillAddPatientDialog(patientName2, patientBirthdate); + await clinicWorkspacePage.openAndFillAddPatientDialog( + patientName2, + patientBirthdate, + patnetMRN2, + patientEmail2, + ); await clinicWorkspacePage.submitAddPatientDialog(); await clinicWorkspacePage.closeBringDataDialog(); // Ensure the second patient is also added @@ -48,7 +62,7 @@ test.describe('Filter patients in clinic', () => { }); }); - test('should successfully filter patients by name', async () => { + test('Clinician - Filter Patients by Name', async () => { await test.step("When user filters by the first patient's name", async () => { await clinicWorkspacePage.searchForPatient(patientName1); }); diff --git a/tests/clinician/add-delete-patient.spec.ts b/tests/clinician/add-delete-patient.spec.ts deleted file mode 100644 index 4bbb8d7..0000000 --- a/tests/clinician/add-delete-patient.spec.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { expect } from '@fixtures/base'; -import { test } from '@fixtures/clinic-helpers'; -import { TEST_TAGS, createValidatedTags } from '@fixtures/test-tags'; -import ClinicianDashboardPage from '@pom/clinician/ClinicianDashboardPage'; - -test.describe('Custodial patients are allowed access and modification of profile details', () => { - // Define the workspace and patient at top level - const CUSTODIAL_WORKSPACE = 'AdminClinicBase'; - const currentDate = Date.now(); - const patientName = `New Patient ${currentDate}`; - const patientBirthdate = '01/01/2000'; - - test( - 'should allow navigation to profile details and edit profile fields', - { - tag: createValidatedTags([ - TEST_TAGS.CLINICIAN, // User Type (required) - TEST_TAGS.API, // Test Type (required) - TEST_TAGS.UI, // Test Type (required) - TEST_TAGS.HIGH, // Priority (required) - ]), - }, - async ({ page }, testInfo) => { - // Step 1: Log in to clinician account and setup network capture - await test.step('Given clinician has been logged in', async () => { - await test.clinician.setup(page); - }); - - // Step 2: Navigate to workspace - await test.step('When user navigates to desired workspace', async () => { - await test.clinician.navigateToWorkspace(CUSTODIAL_WORKSPACE, page); - }); - - // Create pages - const clinicianDashboardPage = new ClinicianDashboardPage(page); - - // Step 3: Click the New Patient button and fill out the form - await test.step('When user clicks the new patient button and fills out the form', async () => { - await clinicianDashboardPage.openAndFillAddPatientDialog(patientName, patientBirthdate); - }); - - // Step 4: Submit the New Patient form - await test.step('When user submits the new patient form', async () => { - await clinicianDashboardPage.submitAddPatientDialog(); - }); - - // Step 5: Close Bring Data Dialog - await test.step('When user closes the bring data dialog', async () => { - await clinicianDashboardPage.closeBringDataDialog(); - }); - - // Step 6: Search for the newly added patient - await test.step('When user searches for the newly added patient', async () => { - await clinicianDashboardPage.searchForPatient(patientName); - }); - - // Step 7: Verify the new patient appears in the patient list - await test.step('Then the new patient should appear in the patient list', async () => { - await clinicianDashboardPage.searchForPatient(patientName); - const patientCell = clinicianDashboardPage.getPatientCellByName(patientName); - await expect(patientCell).toBeVisible(); - }); - - // Step 8: Select '...' within the patient row - await test.step('When user opens the options dropdown for the patient', async () => { - await clinicianDashboardPage.openFirstPatientOptionsDropdown(); - }); - - // Step 9: Click 'Remove Patient' option from the dropdown - await test.step("When user clicks 'Remove Patient' option", async () => { - await clinicianDashboardPage.clickRemovePatientMenuItem(); - }); - - // Step 10: Click Remove button in confirmation dialog - await test.step('When user confirms patient removal', async () => { - await clinicianDashboardPage.confirmRemovePatient(); - }); - - // Step 11: Search for the removed patient - await test.step('When user searches for the removed patient', async () => { - await clinicianDashboardPage.searchForPatient(patientName); - }); - - // Step 12: Verify the deleted patient does not appear in patient list - await test.step('Then the deleted patient should not appear in the patient list', async () => { - const patientCell = clinicianDashboardPage.getPatientCellByName(patientName); - await expect(patientCell).not.toBeVisible(); - }); - }, - ); -}); diff --git a/tests/fixtures/network-helpers.ts b/tests/fixtures/network-helpers.ts index fe9dee7..443ac35 100644 --- a/tests/fixtures/network-helpers.ts +++ b/tests/fixtures/network-helpers.ts @@ -218,6 +218,57 @@ export class NetworkHelper { return matches.length > 0 ? matches[0] : null; } + /** + * Wait for and get the most recent capture matching method and URL pattern after a specific timestamp + * @param method - HTTP method to match + * @param urlPattern - URL pattern to match + * @param afterTimestamp - Only consider captures after this timestamp (defaults to now) + * @param timeoutMs - Maximum time to wait in milliseconds (default 10000) + * @returns Promise that resolves with the matching capture or rejects on timeout + */ + async waitForCaptureMatching( + method: string, + urlPattern: RegExp, + afterTimestamp: number = Date.now(), + timeoutMs = 10000, + ): Promise { + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const checkForCapture = () => { + // Look for captures after the specified timestamp + const matches = this.captures + .filter( + c => c.method === method && urlPattern.test(c.url) && c.timestamp > afterTimestamp, + ) + .sort((a, b) => b.timestamp - a.timestamp); + + if (matches.length > 0) { + resolve(matches[0]); + return; + } + + // Check if we've exceeded the timeout + if (Date.now() - startTime > timeoutMs) { + reject( + new Error( + `Timeout waiting for ${method} request matching ${urlPattern} after timestamp ${afterTimestamp}. ` + + `Total captures: ${this.captures.length}, ` + + `Matching method/URL: ${this.captures.filter(c => c.method === method && urlPattern.test(c.url)).length}`, + ), + ); + return; + } + + // Check again in 100ms + setTimeout(checkForCapture, 100); + }; + + // Start checking + checkForCapture(); + }); + } + /** * Get all captures for a specific endpoint */ @@ -443,11 +494,25 @@ export class NetworkHelper { /** * Helper method to get nested object values using dot notation * @param obj - The object to search - * @param path - The dot-notation path (e.g., 'patient.birthday') + * @param path - The dot-notation path (e.g., 'patient.birthday' or 'patient.emails[0].address') * @returns The value at the path or undefined */ private getNestedValue(obj: any, propertyPath: string): any { - return propertyPath.split('.').reduce((current, key) => current?.[key], obj); + if (!obj || typeof obj !== 'object') return undefined; + + return propertyPath.split('.').reduce((current, key) => { + if (current === null || current === undefined) return undefined; + + // Handle array notation like 'emails[0]' + const arrayMatch = key.match(/^(\w+)\[(\d+)\]$/); + if (arrayMatch) { + const [, arrayKey, index] = arrayMatch; + const array = current[arrayKey]; + return Array.isArray(array) ? array[parseInt(index, 10)] : undefined; + } + + return current[key]; + }, obj); } /** @@ -597,6 +662,73 @@ export class NetworkHelper { requiredFields, ); } + + /** + * Reload the current page to trigger API calls again + * @param waitUntil - Wait until a specific state before considering reload complete + * @param timeout - Maximum time to wait for reload to complete (default 30s) + */ + async reloadPage( + waitUntil: 'load' | 'domcontentloaded' | 'networkidle' | 'commit' = 'networkidle', + timeout = 30000, + ): Promise { + console.log('šŸ”„ Reloading page to trigger API calls...'); + await this.page.reload({ waitUntil, timeout }); + console.log('āœ… Page reloaded successfully'); + } + + /** + * Validates that specific values appear in the correct fields of a captured response + * @param capture - The captured network response to validate + * @param expectedValues - Object mapping field paths to expected values + * Example: { 'patient.fullName': 'John Doe', 'patient.mrn': '123456' } + */ + validateResponseFields(capture: NetworkCapture, expectedValues: Record): void { + if (!capture || !capture.responseBody) { + throw new Error('No response body available for field validation'); + } + + const { responseBody } = capture; + const validationErrors: string[] = []; + + for (const [fieldPath, expectedValue] of Object.entries(expectedValues)) { + const actualValue = this.getNestedValue(responseBody, fieldPath); + + if (actualValue === undefined) { + validationErrors.push(`Field '${fieldPath}' not found in response`); + continue; + } + + // Handle different comparison types + let isMatch = false; + + if (expectedValue === actualValue) { + isMatch = true; + } else if (Array.isArray(actualValue)) { + // For arrays, check if expected value is contained + isMatch = actualValue.some(item => + typeof item === 'object' && item !== null + ? Object.values(item).includes(expectedValue) + : item === expectedValue, + ); + } else if (typeof actualValue === 'string' && typeof expectedValue === 'string') { + // For strings, allow partial matching (useful for emails, names with formatting) + isMatch = actualValue.includes(expectedValue) || expectedValue.includes(actualValue); + } + + if (!isMatch) { + validationErrors.push( + `Field '${fieldPath}' mismatch: expected '${expectedValue}', got '${actualValue}'`, + ); + } + } + + if (validationErrors.length > 0) { + throw new Error(`Field validation failed:\n${validationErrors.join('\n')}`); + } + + console.log(`āœ… All ${Object.keys(expectedValues).length} field validations passed`); + } } export function createNetworkHelper(page: Page): NetworkHelper { diff --git a/tests/personal/AP-Profile/AccountSettings-Personal-EditEmail.spec.ts b/tests/personal/AP-Profile/AccountSettings-Personal-EditEmail.spec.ts new file mode 100644 index 0000000..ee0d478 --- /dev/null +++ b/tests/personal/AP-Profile/AccountSettings-Personal-EditEmail.spec.ts @@ -0,0 +1,122 @@ +import { test } from '../../fixtures/base'; +import { test as patientTest } from '../../fixtures/patient-helpers'; +import { test as accountTest } from '../../fixtures/account-helpers'; +import { createNetworkHelper } from '../../fixtures/network-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { AccountSettingsPage } from '../../../page-objects/account/AccountSettingsPage'; + +test.describe('Account Settings - Personal - Edit Email', () => { + // API Test cases require this to capture network activity + let api: ReturnType; + + test( + 'Account Settings - Personal - Edit Email', + { + tag: createValidatedTags([ + TEST_TAGS.PATIENT, + TEST_TAGS.PERSONAL, + TEST_TAGS.API, + TEST_TAGS.UI, + TEST_TAGS.HIGH, + TEST_TAGS.API_PROFILE, + ]), + }, + async ({ page }) => { + // Step 1: Log in to personal account and setup network capture + await test.step('Given personal account has been logged in', async () => { + api = createNetworkHelper(page); + await api.startCapture(); + await page.goto('/data'); + await patientTest.patient.setup(page); + }); + + // Step 2: Navigate to account settings + await test.step('When user navigates to account settings', async () => { + await accountTest.account.navigateTo('AccountSettings', page); + }); + + // Step 3: Validate profile GET response + await (test as any).stepNoScreenshot( + 'Then profile endpoint responds with GET request consistent with schema ', + async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }, + ); + + // Setup for Account Settings page and previous email for reset + const accountSettingsPage = new AccountSettingsPage(page); + let originalEmail = ''; + + // Step 4: Read and change email field to temporary value + await test.step('When user updates the email field', async () => { + originalEmail = await accountSettingsPage.emailInput.inputValue(); + await accountSettingsPage.emailInput.fill('qa+TempPersonalEdit@tidepool.org'); + }); + + // Step 5: Tap the save button + await test.step('When user taps the save button', async () => { + await accountSettingsPage.saveButton.click(); + }); + + // Step 6: Confirm save changes message displays + await test.step('Then the save changes message displays', async () => { + await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); + }); + + // Step 7: Validate PUT request and email value + await (test as any).stepNoScreenshot( + 'Then PUT request is validated and email is set to new value', + async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putCapture = api + .getCaptures() + .find((req: any) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) throw new Error('No PUT /profile request captured'); + if ( + !putCapture.requestBody || + !putCapture.requestBody.email || + putCapture.requestBody.email !== 'qa+TempPersonalEdit@tidepool.org' + ) { + throw new Error('PUT request did not set email to qa+TempEdit@tidepool.org'); + } + }, + ); + + // Step 8: Change email field to temporary value + await test.step('When user sets the email field to the previous value', async () => { + await accountSettingsPage.emailInput.fill(originalEmail); + }); + + // Step 9: Tap the save button + await test.step('When user taps the save button', async () => { + await accountSettingsPage.saveButton.click(); + }); + + // Step 10: Confirm save changes message displays + await test.step('Then the save changes message displays', async () => { + await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); + }); + + // Step 7: Validate PUT request and email value + await (test as any).stepNoScreenshot( + 'Then PUT request is validated and email is set to new value', + async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putCapture = api + .getCaptures() + .find((req: any) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) throw new Error('No PUT /profile request captured'); + if ( + !putCapture.requestBody || + !putCapture.requestBody.email || + putCapture.requestBody.email !== originalEmail + ) { + throw new Error('PUT request did not set email to originalEmail'); + } + }, + ); + + await api.stopCapture(); + }, + ); +}); diff --git a/tests/personal/AP-Profile/AccountSettings-Personal-EditFullName.spec.ts b/tests/personal/AP-Profile/AccountSettings-Personal-EditFullName.spec.ts new file mode 100644 index 0000000..5f47442 --- /dev/null +++ b/tests/personal/AP-Profile/AccountSettings-Personal-EditFullName.spec.ts @@ -0,0 +1,135 @@ +import { test } from '../../fixtures/base'; +import { test as patientTest } from '../../fixtures/patient-helpers'; +import { test as accountTest } from '../../fixtures/account-helpers'; +import { createNetworkHelper } from '../../fixtures/network-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { AccountSettingsPage } from '../../../page-objects/account/AccountSettingsPage'; +import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; + +test.describe('Personal Account Settings edit (Full Name only) updates Profile endpoint', () => { + test.setTimeout(120000); + + let api: ReturnType; + let putCapture: any; + let newName: string; // Declare at test level scope + + test( + 'Account Settings - Personal - Edit Full Name', + { + tag: createValidatedTags([ + TEST_TAGS.PATIENT, + TEST_TAGS.PERSONAL, // Added personal tag + TEST_TAGS.CLAIMED, + TEST_TAGS.SHARED_MEMBER, // Added shared member tag + TEST_TAGS.API, + TEST_TAGS.UI, + TEST_TAGS.HIGH, + TEST_TAGS.API_PROFILE, + ]), + }, + async ({ page }) => { + // ========== PHASE 1: PERSONAL USER EDITS PROFILE ========== + + // Step 1: Log in to personal account and setup network capture + await test.step('Given Personal account has been logged in', async () => { + api = createNetworkHelper(page); + await api.startCapture(); + await page.goto('/data'); + await patientTest.patient.setup(page); + }); + + // Step 2: Navigate to account settings + await test.step('When user navigates to account settings', async () => { + await accountTest.account.navigateTo('AccountSettings', page); + }); + + // Step 3: GET response is pulled and validated + await (test as any).stepNoScreenshot( + 'Then profile endpoint responds with GET request consistent with schema', + async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }, + ); + + // Create new acccount settings page for the following test + const accountSettingsPage = new AccountSettingsPage(page); + + // Step 4: Change the Full Name field to a new value + await test.step('When user updates the Full Name field', async () => { + newName = `Personal User Updated ${Math.floor(Math.random() * 10000)}`; // Remove let declaration + const nameInput = page.getByRole('textbox', { name: /full name/i }); + await nameInput.fill(newName); + }); + + // Step 5: Tap the Save button + await test.step('When user taps the save button', async () => { + await accountSettingsPage.saveButton.click(); + }); + + // Step 6: Confirm save changes message displays + await test.step('Then the save changes message displays', async () => { + await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); + }); + + // Step 7: Validate PUT request and save value + await (test as any).stepNoScreenshot( + 'Then PUT request is validated and name is set to new value', + async () => { + await api.validateEndpointResponse('profile-metadata-put'); + putCapture = api + .getCaptures() + .find((req: any) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) throw new Error('No PUT /profile request captured'); + if ( + !putCapture.requestBody || + !putCapture.requestBody.fullName || + putCapture.requestBody.fullName !== newName + ) { + throw new Error(`PUT request did not set fullName to ${newName}`); + } + }, + ); + + // Step 8: Navigate to Profile page + await test.step('When user navigates to Profile page', async () => { + await patientTest.patient.navigateTo('Profile', page); + }); + + // Step 9: Confirm GET request matches the saved PUT request + await (test as any).stepNoScreenshot( + 'Then GET request matches the saved PUT request', + async () => { + await api.validateEndpointResponse('profile-metadata-get'); + + // Get all captures and find the LATEST GET request (after the PUT) + const allCaptures = api.getCaptures(); + const putIndex = allCaptures.findIndex(req => req === putCapture); + + // Find GET requests that occurred AFTER the PUT request + const laterGetCaptures = allCaptures + .slice(putIndex + 1) + .filter((req: any) => req.method === 'GET' && req.url.includes('/profile')); + + if (laterGetCaptures.length === 0) { + throw new Error('No GET /profile request captured after the PUT request'); + } + + // Use the most recent GET request + const getCapture = laterGetCaptures[laterGetCaptures.length - 1]; + + if ( + !getCapture.responseBody || + getCapture.responseBody.fullName !== putCapture.requestBody.fullName + ) { + console.log('GET response fullName:', getCapture.responseBody.fullName); + console.log('PUT request fullName:', putCapture.requestBody.fullName); + console.log('Total captures:', allCaptures.length); + console.log('PUT index:', putIndex); + console.log('Later GET captures found:', laterGetCaptures.length); + throw new Error('GET response fullName does not match PUT request fullName'); + } + }, + ); + }, + ); +}); diff --git a/tests/personal/AP-Profile/edit-personal-profile-API.spec.ts b/tests/personal/AP-Profile/Personal-EditProfileDetails.spec.ts similarity index 59% rename from tests/personal/AP-Profile/edit-personal-profile-API.spec.ts rename to tests/personal/AP-Profile/Personal-EditProfileDetails.spec.ts index 6e74860..111c13f 100644 --- a/tests/personal/AP-Profile/edit-personal-profile-API.spec.ts +++ b/tests/personal/AP-Profile/Personal-EditProfileDetails.spec.ts @@ -1,22 +1,28 @@ import { test } from '../../fixtures/patient-helpers'; import { createNetworkHelper } from '../../fixtures/network-helpers'; import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { getProfileMetadataSchema } from '../../../endpoint-schema/profile-endpoints'; import { ProfilePage } from '../../../page-objects/patient/ProfilePage'; test.describe('Personal Accounts allow access and modification of profile details', () => { + const updatedName = `Personal Patient Updated ${Math.floor(Math.random() * 10000)}`; + const updateBirthYear = 1990 + Math.floor(Math.random() * 30); + const updateBirthDate = `06/21/${updateBirthYear}`; + // API Test cases require this to capture network activity let api: ReturnType; + let producerGetCapture: any; test( - 'should allow navigation to profile details and edit profile fields', + 'Personal - Edit Profile Details', { tag: createValidatedTags([ - TEST_TAGS.PATIENT, // User Type (required) - TEST_TAGS.PERSONAL, // User Subtype (required) - TEST_TAGS.API, // Test Type (required) - TEST_TAGS.UI, // Test Type (required) - TEST_TAGS.HIGH, // Priority (required) - TEST_TAGS.API_PROFILE, // Feature (optional) + TEST_TAGS.PATIENT, + TEST_TAGS.PERSONAL, + TEST_TAGS.API, + TEST_TAGS.UI, + TEST_TAGS.HIGH, + TEST_TAGS.API_PROFILE, ]), }, async ({ page }) => { @@ -27,17 +33,21 @@ test.describe('Personal Accounts allow access and modification of profile detail await page.goto('/data'); await test.patient.setup(page); }); - // Step 2: Navigate to profile + + // Step 2: User navigates to Profile page await test.step('When user navigates to Profile page', async () => { await test.patient.navigateTo('Profile', page); }); - // Step 3: Check profile GET response - await test.step('Then profile endpoint responds with GET request consistent with schema [no-screenshot]', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); + // Step 3: GET response is pulled and validated + await (test as any).stepNoScreenshot( + 'Then profile endpoint responds with GET request consistent with schema', + async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }, + ); - // Step 4: Open Edit Profile + // Step 4: Confirm edit button and click it await test.step('When user selects Edit button', async () => { await test.patient.navigateTo('ProfileEdit', page); }); @@ -47,14 +57,6 @@ test.describe('Personal Accounts allow access and modification of profile detail // Step 5: Change profile fields (confirmed user access) await test.step('When user updates profile fields', async () => { - // Generate completely unique values for this confirmed user test run - const testRunId = Math.floor(Math.random() * 10000); - const updatedName = `Personal Patient Updated ${testRunId}`; - const birthYear = 1985 + (testRunId % 10); - const diagnosisYear = birthYear + 20; - const birthDate = `01/15/${birthYear}`; - const diagnosisDate = `03/10/${diagnosisYear}`; - // Generate random 15-letter string for clinical notes const randomString = Array.from({ length: 15 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26)), @@ -69,8 +71,7 @@ test.describe('Personal Accounts allow access and modification of profile detail // Update fields using ProfilePage methods await profilePage.fillFullName(updatedName); - await profilePage.fillBirthDate(birthDate); - await profilePage.fillDiagnosisDate(diagnosisDate); + await profilePage.fillDateOfBirth(updateBirthDate); await profilePage.selectDiagnosisType(nextDiagnosisIndex); await profilePage.fillClinicalNotes(randomString); }); @@ -80,11 +81,23 @@ test.describe('Personal Accounts allow access and modification of profile detail await profilePage.saveProfile(); }); - // Step 7: Check profile PUT response + // Step 7: GET response is validated and saved for comparison await (test as any).stepNoScreenshot( - 'Then profile endpoint responds with PUT request consistent with schema', + 'Then profile endpoint responds with GET request consistent with schema and saved values', async () => { - await api.validateEndpointResponse('profile-metadata-put'); + await api.reloadPage('load'); + const clickTimestamp = Date.now(); + producerGetCapture = await api.waitForCaptureMatching( + getProfileMetadataSchema.method, + getProfileMetadataSchema.url as RegExp, + clickTimestamp, + 15000, + ); + const expectedFieldValues = { + fullName: updatedName, + 'patient.birthday': `${updateBirthYear}-06-21`, // API returns birthdate in YYYY-MM-DD format + }; + api.validateResponseFields(producerGetCapture, expectedFieldValues); }, ); diff --git a/tests/personal/login.spec.ts b/tests/personal/Login-Validations.spec.ts similarity index 94% rename from tests/personal/login.spec.ts rename to tests/personal/Login-Validations.spec.ts index 64d4fdc..aed57ae 100644 --- a/tests/personal/login.spec.ts +++ b/tests/personal/Login-Validations.spec.ts @@ -11,7 +11,7 @@ test.use({ storageState: { cookies: [], origins: [] } }); // Possible testcases: https://tidepool.atlassian.net/jira/software/c/projects/WEB/issues/?jql=project%20%3D%20%22WEB%22%20AND%20type%20%3D%20Test%20AND%20textfields%20~%20%22login%22%20ORDER%20BY%20created%20DESC test.describe('Login into application', () => { test( - 'should work with valid credentials for clinician with multiple clinics', + 'Login - Valid credentials', { tag: createValidatedTags([ TEST_TAGS.CLINICIAN, @@ -38,7 +38,7 @@ test.describe('Login into application', () => { ); test( - 'should show error message with invalid credentials', + 'Login - Invalid credentials', { tag: createValidatedTags([ TEST_TAGS.CLINICIAN, @@ -69,7 +69,7 @@ test.describe('Login into application', () => { ); test( - 'should validate email format', + 'Login - Validate email format', { tag: createValidatedTags([ TEST_TAGS.CLINICIAN, @@ -100,7 +100,7 @@ test.describe('Login into application', () => { ); test( - 'should show error message with invalid password', + 'Login - Invalid password message displays', { tag: createValidatedTags([ TEST_TAGS.CLINICIAN, diff --git a/tests/personal/basic-functionality.spec.ts b/tests/personal/Patient-DisplayFunctionality.spec.ts similarity index 99% rename from tests/personal/basic-functionality.spec.ts rename to tests/personal/Patient-DisplayFunctionality.spec.ts index 88091de..164ef44 100644 --- a/tests/personal/basic-functionality.spec.ts +++ b/tests/personal/Patient-DisplayFunctionality.spec.ts @@ -52,9 +52,7 @@ test.describe('Patient Data Navigation and Visualization', () => { }); // Bolus dashboard functionality - test('should display bolus dashboard when selecting a date from basics page', async ({ - page, - }) => { + test('Patient - Bolus Dashboard - Navigation and Visualization', async ({ page }) => { const basicsPage = new PatientDataBasicsPage(page); const dailyPage = new PatientDataDailyPage(page); let selectedDateText: string | null; diff --git a/utilities/env.ts b/utilities/env.ts index d246243..206d92c 100644 --- a/utilities/env.ts +++ b/utilities/env.ts @@ -18,6 +18,8 @@ const envSchema = z.object({ XRAY_CLIENT_ID: z.string().optional(), XRAY_CLIENT_SECRET: z.string().optional(), XRAY_PROJECT_KEY: z.string().default('SAND'), + XRAY_BATCH_SIZE_MB: z.number().default(20), + TEST_EXECUTION_KEY: z.string().optional(), JIRA_EMAIL: z.string().optional(), JIRA_API_KEY: z.string().optional(), }); diff --git a/utilities/xray-json-reporter.ts b/utilities/xray-json-reporter.ts index 09a4ab4..288926d 100644 --- a/utilities/xray-json-reporter.ts +++ b/utilities/xray-json-reporter.ts @@ -16,6 +16,10 @@ import { * Maps Playwright test data to Xray Cloud JSON format and uploads results */ class XrayJsonReporter { + private config?: FullConfig; + + private rootSuite?: Suite; + private styles = { success: '\u2705', error: '\u274C', @@ -104,16 +108,25 @@ class XrayJsonReporter { * Videos are only included for failed tests; other files check size threshold */ private shouldIncludeEvidence(attachment: any, testStatus: string, contentType: string): boolean { - const filePath = attachment.path; - if (!filePath || !fs.existsSync(filePath)) { + // Check if attachment has embedded base64 data (from JSON) or file path + const hasData = !!attachment.body || (attachment.path && fs.existsSync(attachment.path)); + console.log( + `DEBUG shouldIncludeEvidence: name=${attachment.name}, hasData=${hasData}, contentType=${contentType}`, + ); + + if (!hasData) { return false; } // Videos: Only for failed tests if (contentType.includes('video')) { + console.log( + `DEBUG: Video detected, testStatus=${testStatus}, including=${testStatus !== 'passed'}`, + ); return testStatus !== 'passed'; } + console.log(`DEBUG: Non-video attachment, including=true`); return true; } @@ -153,18 +166,35 @@ class XrayJsonReporter { ); for (const attachment of stepAttachments) { - if (attachment.path && fs.existsSync(attachment.path)) { - const contentType = attachment.contentType || 'application/octet-stream'; - - if (this.shouldIncludeEvidence(attachment, testStatus, contentType)) { - const base64Data = await this.fileToBase64(attachment.path); - if (base64Data) { - evidence.push({ - data: base64Data, - filename: path.basename(attachment.path), - contentType, - }); - } + const contentType = attachment.contentType || 'application/octet-stream'; + + if (this.shouldIncludeEvidence(attachment, testStatus, contentType)) { + let base64Data: string | null = null; + let filename = attachment.name || 'attachment'; + + // Check if attachment has embedded base64 data (from JSON) + if (attachment.body) { + // Handle both Buffer and string cases + base64Data = + typeof attachment.body === 'string' + ? attachment.body + : attachment.body.toString('base64'); + console.log(`DEBUG: Using embedded base64 data for ${filename}`); + } + // Check if attachment has file path to read from + else if (attachment.path && fs.existsSync(attachment.path)) { + base64Data = await this.fileToBase64(attachment.path); + filename = path.basename(attachment.path); + console.log(`DEBUG: Using file path data for ${filename}`); + } + + if (base64Data) { + evidence.push({ + data: base64Data, + filename, + contentType, + }); + console.log(`DEBUG: Added evidence: ${filename}, size=${base64Data.length}`); } } } @@ -294,29 +324,7 @@ class XrayJsonReporter { stepResults[stepResults.length - 1].actualResult = testResult.error?.message || 'Test failed'; } - // Collect test-level evidence (not step-level) - const testEvidence: XrayEvidence[] = []; - - for (const attachment of attachments) { - if ( - attachment.path && - fs.existsSync(attachment.path) && - !attachment.name.toLowerCase().includes('step-') - ) { - const contentType = attachment.contentType || 'application/octet-stream'; - - if (this.shouldIncludeEvidence(attachment, testStatus, contentType)) { - const base64Data = await this.fileToBase64(attachment.path); - if (base64Data) { - testEvidence.push({ - data: base64Data, - filename: attachment.name, - contentType, - }); - } - } - } - } + // Remove test-level evidence to avoid duplication (using step-level evidence instead) return { testInfo: { @@ -327,7 +335,6 @@ class XrayJsonReporter { }, status: this.getTestStatus(testStatus), comment: testResult.error?.message, - evidence: testEvidence.length > 0 ? testEvidence : undefined, steps: stepResults.length > 0 ? stepResults : undefined, }; } @@ -345,8 +352,8 @@ class XrayJsonReporter { await this.processSuite(suite, tests); } - const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; - const targetEnv = process.env.TARGET_ENV || 'qa1'; + const testExecKey = env.TEST_EXECUTION_KEY; + const targetEnv = env.TARGET_ENV; const passedCount = tests.filter(t => t.status === 'PASSED').length; const failedCount = tests.filter(t => t.status === 'FAILED').length; @@ -390,10 +397,174 @@ class XrayJsonReporter { /** * Uploads Xray execution result to Xray Cloud */ + private calculatePayloadSize(xrayResult: XrayExecutionResult): number { + try { + // Calculate size safely, handling circular references + const safePayload = JSON.stringify(xrayResult, (key, value) => { + if (key === 'parent' || key === 'suite' || key === '_parentSuite' || key === '_project') { + return undefined; + } + return value; + }); + return safePayload.length; + } catch (error) { + console.log( + `${this.styles.warning} Could not calculate payload size: ${(error as Error).message}`, + ); + return 0; + } + } + + private createTestBatches(tests: XrayTest[]): XrayTest[][] { + const maxBatchSizeBytes = (env.XRAY_BATCH_SIZE_MB || 20) * 1024 * 1024; // Convert MB to bytes + const batches: XrayTest[][] = []; + let currentBatch: XrayTest[] = []; + let currentBatchSize = 0; + + // Base execution structure size (info + metadata) + const baseStructureSize = JSON.stringify({ + testExecutionKey: 'SAMPLE-123', + info: { + project: 'SAMPLE', + summary: 'Sample execution', + description: 'Sample description for size calculation', + testEnvironments: ['sample'], + }, + tests: [], + }).length; + + for (const test of tests) { + // Calculate size of this individual test + const testSize = JSON.stringify(test, (key, value) => { + if (key === 'parent' || key === 'suite' || key === '_parentSuite' || key === '_project') { + return undefined; + } + return value; + }).length; + + // Check if adding this test would exceed batch size limit + const projectedBatchSize = currentBatchSize + testSize + baseStructureSize; + + if (projectedBatchSize > maxBatchSizeBytes && currentBatch.length > 0) { + // Current batch would be too large, start new batch + console.log( + `${this.styles.info} Batch ${batches.length + 1}: ${currentBatch.length} tests, ${(currentBatchSize / 1024 / 1024).toFixed(1)}MB`, + ); + batches.push(currentBatch); + currentBatch = [test]; + currentBatchSize = testSize; + } else { + // Add test to current batch + currentBatch.push(test); + currentBatchSize += testSize; + } + + // Log warning for oversized individual tests + if (testSize + baseStructureSize > maxBatchSizeBytes) { + const testSizeMB = ((testSize + baseStructureSize) / 1024 / 1024).toFixed(1); + console.log( + `${this.styles.warning} Large test detected: ${testSizeMB}MB (exceeds ${env.XRAY_BATCH_SIZE_MB}MB limit) - will upload as single-test batch`, + ); + } + } + + // Don't forget the last batch + if (currentBatch.length > 0) { + console.log( + `${this.styles.info} Batch ${batches.length + 1}: ${currentBatch.length} tests, ${(currentBatchSize / 1024 / 1024).toFixed(1)}MB`, + ); + batches.push(currentBatch); + } + + return batches; + } + async uploadToXray(xrayResult: XrayExecutionResult): Promise { + // Check if batching is needed + const totalSize = this.calculatePayloadSize(xrayResult); + const maxBatchSizeBytes = (env.XRAY_BATCH_SIZE_MB || 20) * 1024 * 1024; + + if (totalSize > maxBatchSizeBytes && xrayResult.tests.length > 1) { + console.log( + `${this.styles.info} Payload size ${(totalSize / 1024 / 1024).toFixed(1)}MB exceeds ${env.XRAY_BATCH_SIZE_MB}MB limit`, + ); + console.log( + `${this.styles.info} Splitting ${xrayResult.tests.length} tests into size-capped batches...`, + ); + + return this.uploadInBatches(xrayResult); + } + // Single upload for small payloads + return this.uploadSingleBatch(xrayResult); + } + + private async uploadInBatches( + fullResult: XrayExecutionResult, + ): Promise { + const testBatches = this.createTestBatches(fullResult.tests); + let firstUploadResult: XrayImportResponse | null = null; + + console.log(`${this.styles.info} Uploading ${testBatches.length} batches...`); + + for (let i = 0; i < testBatches.length; i++) { + const batchNumber = i + 1; + const batch = testBatches[i]; + + // Create batch payload + const batchResult: XrayExecutionResult = { + ...fullResult, + tests: batch, + }; + + // For subsequent batches after the first, link to the same test execution + if (i > 0 && firstUploadResult?.testExecIssue?.key) { + batchResult.testExecutionKey = firstUploadResult.testExecIssue.key; + // Remove info object for updates (only needed for creation) + delete batchResult.info; + } + + console.log( + `${this.styles.upload} Uploading batch ${batchNumber}/${testBatches.length} (${batch.length} tests)...`, + ); + + try { + const batchResponse = await this.uploadSingleBatch(batchResult); + + if (i === 0) { + firstUploadResult = batchResponse; + } + + if (batchResponse) { + console.log(`${this.styles.upload} āœ… Batch ${batchNumber} uploaded successfully`); + } + } catch (error) { + console.log(`${this.styles.error} āŒ Batch ${batchNumber} failed: ${error}`); + // Continue with other batches even if one fails + } + } + + return firstUploadResult; + } + + private async uploadSingleBatch( + xrayResult: XrayExecutionResult, + ): Promise { try { const uploadStart = Date.now(); - const payloadSizeKB = (JSON.stringify(xrayResult).length / 1024).toFixed(1); + + // Calculate payload size safely, handling circular references + let payloadSizeKB = '0'; + try { + const safePayload = JSON.stringify(xrayResult, (key, value) => { + if (key === 'parent' || key === 'suite' || key === '_parentSuite' || key === '_project') { + return undefined; + } + return value; + }); + payloadSizeKB = (safePayload.length / 1024).toFixed(1); + } catch (sizeError) { + payloadSizeKB = 'unknown'; + } console.log(`${this.styles.info} Uploading test execution to Xray...`); console.log( @@ -408,7 +579,13 @@ class XrayJsonReporter { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, - body: JSON.stringify(xrayResult), + body: JSON.stringify(xrayResult, (key, value) => { + // Skip circular references in upload payload + if (key === 'parent' || key === 'suite' || key === '_parentSuite' || key === '_project') { + return undefined; + } + return value; + }), }); if (!response.ok) { @@ -445,9 +622,9 @@ class XrayJsonReporter { console.log(`\n${this.styles.separator}`); console.log(`${this.styles.info} Processing Playwright results for Xray...`); console.log(`${this.styles.info} Project Key: ${env.XRAY_PROJECT_KEY || 'SAND'}`); - console.log(`${this.styles.info} Environment: ${process.env.TARGET_ENV || 'qa1'}`); + console.log(`${this.styles.info} Environment: ${env.TARGET_ENV}`); - const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; + const testExecKey = env.TEST_EXECUTION_KEY; if (testExecKey && testExecKey !== 'none' && testExecKey.trim() !== '') { console.log(`${this.styles.info} Linking to Test Execution: ${testExecKey}`); } else { @@ -457,8 +634,29 @@ class XrayJsonReporter { const xrayResult = await this.convertPlaywrightJsonToXray(playwrightJsonPath); // Save converted result for debugging - fs.writeFileSync('test-results/xray-execution.json', JSON.stringify(xrayResult, null, 2)); - console.log(`${this.styles.info} Saved Xray JSON to: test-results/xray-execution.json`); + try { + // Handle circular references when saving debug JSON + const safeResult = JSON.parse( + JSON.stringify(xrayResult, (key, value) => { + // Skip circular references and other problematic fields + if ( + key === 'parent' || + key === 'suite' || + key === '_parentSuite' || + key === '_project' + ) { + return undefined; + } + return value; + }), + ); + fs.writeFileSync('test-results/xray-execution.json', JSON.stringify(safeResult, null, 2)); + console.log(`${this.styles.info} Saved Xray JSON to: test-results/xray-execution.json`); + } catch (debugError) { + console.log( + `${this.styles.warning} Could not save debug JSON: ${(debugError as Error).message}`, + ); + } if (xrayResult.tests.length === 0) { console.log(`${this.styles.warning} No tests to upload, skipping Xray upload`); @@ -479,7 +677,10 @@ class XrayJsonReporter { /** * Reporter lifecycle methods for Playwright integration */ - onBegin(_config: FullConfig, suite: Suite): void { + onBegin(config: FullConfig, suite: Suite): void { + this.config = config; + this.rootSuite = suite; + console.log(`\n${this.styles.separator}`); console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); console.log(`${this.styles.separator}\n`); @@ -503,11 +704,36 @@ class XrayJsonReporter { console.log(`Duration: ${result.duration}ms`); console.log(`${this.styles.separator}\n`); - const testExecKey = process.env.TEST_EXECUTION_KEY || process.env.testExecKey; + const testExecKey = env.TEST_EXECUTION_KEY; if (env.XRAY_CLIENT_ID && env.XRAY_CLIENT_SECRET && testExecKey && testExecKey !== 'none') { - const jsonPath = 'test-results/last-run.json'; - if (fs.existsSync(jsonPath)) { - await this.processAndUpload(jsonPath); + console.log(`${this.styles.info} Linking to Test Execution: ${testExecKey}`); + + // Check for multiple possible JSON file locations + const possiblePaths = [ + 'test-results/last-run.json', + 'test-results/.last-run.json', + path.resolve('test-results/last-run.json'), + path.resolve('test-results/.last-run.json'), + ]; + + let jsonPath: string | null = null; + for (const testPath of possiblePaths) { + if (fs.existsSync(testPath)) { + jsonPath = testPath; + break; + } + } + + if (jsonPath) { + console.log(`${this.styles.info} Found test results at: ${jsonPath}`); + try { + await this.processAndUpload(jsonPath); + } catch (error) { + console.log(`${this.styles.error} Xray upload failed: ${error}`); + } + } else { + console.log(`${this.styles.warning} No test results JSON file found for Xray upload`); + console.log(`${this.styles.info} Checked paths:`, possiblePaths); } } } From fca804117eef16b762fc8c98f67212b39683e386 Mon Sep 17 00:00:00 2001 From: Foscottl-TP Date: Mon, 2 Mar 2026 10:41:50 -0800 Subject: [PATCH 47/60] Use TypeScript xray reporter in Playwright config Change the Playwright reporter entry to reference build/utilities/xray-json-reporter.ts instead of the .js file. This updates the reporter path in playwright.config.ts so the TypeScript reporter source is used. --- playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index bf41c1f..69c8ce3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -38,7 +38,7 @@ export default defineConfig({ reporter: [ ['html', { open: 'never', outputFolder: 'playwright-report' }], ['json', { outputFile: 'test-results/last-run.json' }], - ['./build/utilities/xray-json-reporter.js'], + ['./build/utilities/xray-json-reporter.ts'], ], use: { From f926321196ec370fb5849e68dac0b299854cdce1 Mon Sep 17 00:00:00 2001 From: Foscottl-TP Date: Mon, 2 Mar 2026 10:48:35 -0800 Subject: [PATCH 48/60] Use source reporter path in Playwright config Update Playwright reporter path from './build/utilities/xray-json-reporter.ts' to './utilities/xray-json-reporter.ts' so the test runner uses the source reporter location (no longer relying on a build output path). This ensures the custom JSON reporter is resolved correctly during test runs. --- playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index 69c8ce3..6bc3197 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -38,7 +38,7 @@ export default defineConfig({ reporter: [ ['html', { open: 'never', outputFolder: 'playwright-report' }], ['json', { outputFile: 'test-results/last-run.json' }], - ['./build/utilities/xray-json-reporter.ts'], + ['./utilities/xray-json-reporter.ts'], ], use: { From aa664e1a02945fb27dfa1639566137adcf4be390 Mon Sep 17 00:00:00 2001 From: Foscottl-TP Date: Mon, 2 Mar 2026 11:00:58 -0800 Subject: [PATCH 49/60] Make Playwright headless on CI Use the CI environment flag to control Playwright's headless mode instead of hardcoding false. Updated three projects in playwright.config.ts (personal, claimed, clinician) to set headless: !!process.env.CI so tests run headed locally but run headless in CI environments. --- playwright.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 6bc3197..cd37a32 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -57,7 +57,7 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/personal.json', - headless: false, + headless: !!process.env.CI, }, }, @@ -67,7 +67,7 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/claimed.json', - headless: false, + headless: !!process.env.CI, }, }, @@ -77,7 +77,7 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/clinician.json', - headless: false, + headless: !!process.env.CI, }, }, From 2e3c812b3c84735e59e10bafde68e369ed6b9d59 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 2 Mar 2026 14:10:19 -0500 Subject: [PATCH 50/60] Update config.yml --- .circleci/config.yml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 95b2ba8..dc9d1be 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -67,10 +67,18 @@ jobs: name: Install Playwright Dependencies command: npx playwright install --with-deps - # Run tests with parallel execution + # Run tests with parallel execution and optional tag filtering - run: name: Run Playwright Tests - command: npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL + command: | + SHARD_ARG="--shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL" + if [ -n "$TEST_TAGS" ]; then + TAG=$(echo "$TEST_TAGS" | tr '[:upper:]' '[:lower:]') + [[ "$TAG" != @* ]] && TAG="@${TAG}" + npm test -- $SHARD_ARG --grep "$TAG" + else + npm test -- $SHARD_ARG + fi # Store test results and artifacts - store_artifacts: @@ -177,10 +185,18 @@ jobs: name: Install Playwright Dependencies command: npx playwright install --with-deps - # Run tests with parallel execution + # Run tests with parallel execution and optional tag filtering - run: name: Run Playwright Tests - command: npm test -- --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL + command: | + SHARD_ARG="--shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL" + if [ -n "$TEST_TAGS" ]; then + TAG=$(echo "$TEST_TAGS" | tr '[:upper:]' '[:lower:]') + [[ "$TAG" != @* ]] && TAG="@${TAG}" + npm test -- $SHARD_ARG --grep "$TAG" + else + npm test -- $SHARD_ARG + fi # Store test results and artifacts - store_artifacts: From ed8f94449f8a3070988cf01e297da868934d0e26 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 2 Mar 2026 14:18:29 -0500 Subject: [PATCH 51/60] Update Docker image --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index dc9d1be..76f0cf4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -39,7 +39,7 @@ jobs: test: working_directory: ~/tidepool-org/webuitests docker: - - image: mcr.microsoft.com/playwright:v1.54.1-noble + - image: mcr.microsoft.com/playwright:v1.57.0-noble parallelism: 4 steps: - checkout @@ -157,7 +157,7 @@ jobs: scheduled-test: working_directory: ~/tidepool-org/webuitests docker: - - image: mcr.microsoft.com/playwright:v1.54.1-noble + - image: mcr.microsoft.com/playwright:v1.57.0-noble parallelism: 4 steps: - checkout From 4456c8f2ab743f538db2e80ff9c156bcbfedb93b Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 2 Mar 2026 14:18:43 -0500 Subject: [PATCH 52/60] Lower retries on CI --- playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index cd37a32..288e0b7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -27,7 +27,7 @@ export default defineConfig({ globalSetup: require.resolve(path.join(__dirname, 'tests/global-setup')), fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, + retries: process.env.CI ? 1 : 0, workers: process.env.CI ? 1 : undefined, timeout: 60_000, From 3c7e1dbc141938de9349141921d513388b42d76b Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 2 Mar 2026 14:21:05 -0500 Subject: [PATCH 53/60] Remove debug logs and conditionally set info Strip noisy debug console.logs across evidence handling and upload flow, and remove unused reporter fields (config, rootSuite). When an existing test execution key is provided, omit the info block to avoid overwriting execution metadata (useful for sharded CI runs). Also minor cleanups: use i += 1 in batch loop and ignore the config parameter in onBegin (no longer storing it). --- utilities/xray-json-reporter.ts | 43 ++++++++++++--------------------- 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/utilities/xray-json-reporter.ts b/utilities/xray-json-reporter.ts index 288926d..7ee6ca9 100644 --- a/utilities/xray-json-reporter.ts +++ b/utilities/xray-json-reporter.ts @@ -16,10 +16,6 @@ import { * Maps Playwright test data to Xray Cloud JSON format and uploads results */ class XrayJsonReporter { - private config?: FullConfig; - - private rootSuite?: Suite; - private styles = { success: '\u2705', error: '\u274C', @@ -110,9 +106,6 @@ class XrayJsonReporter { private shouldIncludeEvidence(attachment: any, testStatus: string, contentType: string): boolean { // Check if attachment has embedded base64 data (from JSON) or file path const hasData = !!attachment.body || (attachment.path && fs.existsSync(attachment.path)); - console.log( - `DEBUG shouldIncludeEvidence: name=${attachment.name}, hasData=${hasData}, contentType=${contentType}`, - ); if (!hasData) { return false; @@ -120,13 +113,9 @@ class XrayJsonReporter { // Videos: Only for failed tests if (contentType.includes('video')) { - console.log( - `DEBUG: Video detected, testStatus=${testStatus}, including=${testStatus !== 'passed'}`, - ); return testStatus !== 'passed'; } - console.log(`DEBUG: Non-video attachment, including=true`); return true; } @@ -179,13 +168,11 @@ class XrayJsonReporter { typeof attachment.body === 'string' ? attachment.body : attachment.body.toString('base64'); - console.log(`DEBUG: Using embedded base64 data for ${filename}`); } // Check if attachment has file path to read from else if (attachment.path && fs.existsSync(attachment.path)) { base64Data = await this.fileToBase64(attachment.path); filename = path.basename(attachment.path); - console.log(`DEBUG: Using file path data for ${filename}`); } if (base64Data) { @@ -194,7 +181,6 @@ class XrayJsonReporter { filename, contentType, }); - console.log(`DEBUG: Added evidence: ${filename}, size=${base64Data.length}`); } } } @@ -361,17 +347,21 @@ class XrayJsonReporter { const hasExistingExecution = testExecKey && testExecKey !== 'none' && testExecKey.trim() !== ''; + // When linking to an existing execution (e.g., sharded CI runs), skip info to avoid + // overwriting the execution description with partial per-shard counts. return { testExecutionKey: hasExistingExecution ? testExecKey : undefined, - info: { - summary: `Playwright Test Execution - ${new Date().toISOString()}`, - description: `Automated test execution for ${targetEnv} environment\n\nResults: ${passedCount} passed, ${failedCount} failed, ${todoCount} skipped`, - startDate: playwrightResult.stats?.startTime || new Date().toISOString(), - finishDate: new Date( - new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + - (playwrightResult.stats?.duration || 0), - ).toISOString(), - }, + info: hasExistingExecution + ? undefined + : { + summary: `Playwright Test Execution - ${new Date().toISOString()}`, + description: `Automated test execution for ${targetEnv} environment\n\nResults: ${passedCount} passed, ${failedCount} failed, ${todoCount} skipped`, + startDate: playwrightResult.stats?.startTime || new Date().toISOString(), + finishDate: new Date( + new Date(playwrightResult.stats?.startTime || Date.now()).getTime() + + (playwrightResult.stats?.duration || 0), + ).toISOString(), + }, tests, }; } @@ -506,7 +496,7 @@ class XrayJsonReporter { console.log(`${this.styles.info} Uploading ${testBatches.length} batches...`); - for (let i = 0; i < testBatches.length; i++) { + for (let i = 0; i < testBatches.length; i += 1) { const batchNumber = i + 1; const batch = testBatches[i]; @@ -677,10 +667,7 @@ class XrayJsonReporter { /** * Reporter lifecycle methods for Playwright integration */ - onBegin(config: FullConfig, suite: Suite): void { - this.config = config; - this.rootSuite = suite; - + onBegin(_config: FullConfig, suite: Suite): void { console.log(`\n${this.styles.separator}`); console.log(`${this.styles.test} Starting test run with ${suite.allTests().length} tests`); console.log(`${this.styles.separator}\n`); From 5b22c448b7126d12f0c730e211a134cc73bb267a Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 2 Mar 2026 14:21:57 -0500 Subject: [PATCH 54/60] Refactor field validation to remove early continue Replace the early `continue` when `actualValue` is undefined with an `else` block that scopes the comparison logic. This refactor preserves existing validation behavior while keeping control flow explicit and improving readability by avoiding an early loop exit. --- tests/fixtures/network-helpers.ts | 45 +++++++++++++++---------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/tests/fixtures/network-helpers.ts b/tests/fixtures/network-helpers.ts index 443ac35..5d78d44 100644 --- a/tests/fixtures/network-helpers.ts +++ b/tests/fixtures/network-helpers.ts @@ -696,30 +696,29 @@ export class NetworkHelper { if (actualValue === undefined) { validationErrors.push(`Field '${fieldPath}' not found in response`); - continue; - } - - // Handle different comparison types - let isMatch = false; - - if (expectedValue === actualValue) { - isMatch = true; - } else if (Array.isArray(actualValue)) { - // For arrays, check if expected value is contained - isMatch = actualValue.some(item => - typeof item === 'object' && item !== null - ? Object.values(item).includes(expectedValue) - : item === expectedValue, - ); - } else if (typeof actualValue === 'string' && typeof expectedValue === 'string') { - // For strings, allow partial matching (useful for emails, names with formatting) - isMatch = actualValue.includes(expectedValue) || expectedValue.includes(actualValue); - } + } else { + // Handle different comparison types + let isMatch = false; + + if (expectedValue === actualValue) { + isMatch = true; + } else if (Array.isArray(actualValue)) { + // For arrays, check if expected value is contained + isMatch = actualValue.some(item => + typeof item === 'object' && item !== null + ? Object.values(item).includes(expectedValue) + : item === expectedValue, + ); + } else if (typeof actualValue === 'string' && typeof expectedValue === 'string') { + // For strings, allow partial matching (useful for emails, names with formatting) + isMatch = actualValue.includes(expectedValue) || expectedValue.includes(actualValue); + } - if (!isMatch) { - validationErrors.push( - `Field '${fieldPath}' mismatch: expected '${expectedValue}', got '${actualValue}'`, - ); + if (!isMatch) { + validationErrors.push( + `Field '${fieldPath}' mismatch: expected '${expectedValue}', got '${actualValue}'`, + ); + } } } From 7cb900f2055009f49bbf9e1062a667853e7053d2 Mon Sep 17 00:00:00 2001 From: Ginny Yadav Date: Mon, 2 Mar 2026 14:35:05 -0500 Subject: [PATCH 55/60] Prefer pipeline parameters in CircleCI config Make pipeline parameters always take precedence over project-level environment variables in .circleci/config.yml. Replace the shell fallback assignments (e.g. ${TARGET_ENV:-<< pipeline.parameters.testEnvironment >>}) with direct exports from pipeline.parameters and update the explanatory comment. Changes apply to both job blocks setting TARGET_ENV, TEST_EXECUTION_KEY, and TEST_TAGS to ensure pipeline-provided values are used unconditionally. --- .circleci/config.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 76f0cf4..1e00ffa 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -45,13 +45,13 @@ jobs: - checkout - node/install - run: node --version - # Pipeline parameters override project env vars only when explicitly provided + # Pipeline parameters always take precedence over project-level env vars - run: name: Set environment variables command: | - echo "export TARGET_ENV=\"${TARGET_ENV:-<< pipeline.parameters.testEnvironment >>}\"" >> $BASH_ENV - echo "export TEST_EXECUTION_KEY=\"${TEST_EXECUTION_KEY:-<< pipeline.parameters.testExecKey >>}\"" >> $BASH_ENV - echo "export TEST_TAGS=\"${TEST_TAGS:-<< pipeline.parameters.testTags >>}\"" >> $BASH_ENV + echo "export TARGET_ENV=\"<< pipeline.parameters.testEnvironment >>\"" >> $BASH_ENV + echo "export TEST_EXECUTION_KEY=\"<< pipeline.parameters.testExecKey >>\"" >> $BASH_ENV + echo "export TEST_TAGS=\"<< pipeline.parameters.testTags >>\"" >> $BASH_ENV - restore_cache: keys: - dependency-cache-{{ checksum "package.json" }} @@ -163,13 +163,13 @@ jobs: - checkout - node/install - run: node --version - # Pipeline parameters override project env vars only when explicitly provided + # Pipeline parameters always take precedence over project-level env vars - run: name: Set environment variables command: | - echo "export TARGET_ENV=\"${TARGET_ENV:-<< pipeline.parameters.testEnvironment >>}\"" >> $BASH_ENV - echo "export TEST_EXECUTION_KEY=\"${TEST_EXECUTION_KEY:-<< pipeline.parameters.testExecKey >>}\"" >> $BASH_ENV - echo "export TEST_TAGS=\"${TEST_TAGS:-<< pipeline.parameters.testTags >>}\"" >> $BASH_ENV + echo "export TARGET_ENV=\"<< pipeline.parameters.testEnvironment >>\"" >> $BASH_ENV + echo "export TEST_EXECUTION_KEY=\"<< pipeline.parameters.testExecKey >>\"" >> $BASH_ENV + echo "export TEST_TAGS=\"<< pipeline.parameters.testTags >>\"" >> $BASH_ENV - restore_cache: keys: - dependency-cache-{{ checksum "package.json" }} From 5187f7e743ddedf615101cdbf99f9d910fabe549 Mon Sep 17 00:00:00 2001 From: Foscottl-TP Date: Wed, 4 Mar 2026 16:02:15 -0800 Subject: [PATCH 56/60] Add retry to patient search and multi-workspace PatientFilter test Add resilient typing to ClinicianDashboardPage.searchForPatient: retry up to 3 times, verify inputValue, clear between attempts, and throw a descriptive error on failure. Refactor clinician patient filter test to run across ALL_WORKSPACE_KEYS, use clinic-specific unique MRN/email generation, add tagging/validation, create patients only if missing, and restructure steps for clearer setup and verification. --- .../clinician/ClinicianDashboardPage.ts | 26 ++- .../Clinician-FilterPatientsByName.spec.ts | 202 +++++++++++------- 2 files changed, 151 insertions(+), 77 deletions(-) diff --git a/page-objects/clinician/ClinicianDashboardPage.ts b/page-objects/clinician/ClinicianDashboardPage.ts index c06413e..4f42ec1 100644 --- a/page-objects/clinician/ClinicianDashboardPage.ts +++ b/page-objects/clinician/ClinicianDashboardPage.ts @@ -133,7 +133,31 @@ class ClinicianDashboardPage { * @param name - The name of the patient to search for. */ async searchForPatient(name: string): Promise { - await this.searchInput.fill(name); + // Retry up to 3 times to ensure text is properly entered + let success = false; + let attempt = 1; + + while (attempt <= 3 && !success) { + await this.searchInput.fill(name); + + // Verify the text was actually entered + const inputValue = await this.searchInput.inputValue(); + if (inputValue === name) { + success = true; + } else if (attempt < 3) { + // If not successful and not the last attempt, wait and try again + await this.page.waitForTimeout(500); + // Clear the field before retrying + await this.searchInput.clear(); + } + + attempt += 1; + } + + if (!success) { + throw new Error(`Failed to enter search text "${name}" after 3 attempts`); + } + // Press Enter to trigger search await this.searchInput.press('Enter'); // Wait longer for search to process and results to load diff --git a/tests/clinician/Clinician-FilterPatientsByName.spec.ts b/tests/clinician/Clinician-FilterPatientsByName.spec.ts index d238ed8..e86a454 100644 --- a/tests/clinician/Clinician-FilterPatientsByName.spec.ts +++ b/tests/clinician/Clinician-FilterPatientsByName.spec.ts @@ -1,90 +1,140 @@ -import { expect, test } from '@fixtures/base'; +import { expect } from '@fixtures/base'; +import { test, ALL_WORKSPACE_KEYS } from '@fixtures/clinic-helpers'; +import { WorkspaceKey } from '@pom/clinician/ClinicianNavigation'; +import { TEST_TAGS, createValidatedTags } from '@fixtures/test-tags'; import ClinicianDashboardPage from '@pom/clinician/ClinicianDashboardPage'; -import WorkspacesPage from '@pom/clinician/WorkspacesPage'; -test.describe('Filter patients in clinic', () => { - // Use unique patient names for each test run - const timestamp = Date.now(); - const patientName1 = `Filter Patient A ${timestamp}`; - const patientName2 = `Filter Patient B ${timestamp}`; - const patientBirthdate = '01/01/1995'; // Shared birthdate for simplicity - const patnetMRN1 = `MRN-${timestamp}-1`; - const patnetMRN2 = `MRN-${timestamp}-2`; - const patientEmail1 = `webuiautomation+createdpatient-${timestamp}-1@tidepool.org`; - const patientEmail2 = `webuiautomation+createdpatient-${timestamp}-2@tidepool.org`; +ALL_WORKSPACE_KEYS.forEach((workspace: WorkspaceKey) => { + test.describe(`Filter patients in clinic [${workspace}]`, () => { + // Define the patient data at top level (unique per workspace) + const currentDate = Date.now(); + const workspaceId = workspace.replace(/[^a-zA-Z0-9]/g, ''); // Clean workspace name for IDs + const shortTimestamp = currentDate.toString().slice(-8); // Last 8 digits for uniqueness + const shortWorkspaceId = workspaceId.substring(0, 4); // First 4 chars of workspace + const patientName1 = `Filter Patient A`; + const patientName2 = `Filter Patient B`; + const patientBirthdate = '01/01/1995'; // Shared birthdate for simplicity + const patientMRN1 = `${shortWorkspaceId}${shortTimestamp}1`; // Max 13 chars (4+8+1) + const patientMRN2 = `${shortWorkspaceId}${shortTimestamp}2`; // Max 13 chars (4+8+1) + const patientEmail1 = `webuiautomation+filter${shortTimestamp}1${shortWorkspaceId}@tidepool.org`; + const patientEmail2 = `webuiautomation+filter${shortTimestamp}2${shortWorkspaceId}@tidepool.org`; - let workspacesPage: WorkspacesPage; - let clinicWorkspacePage: ClinicianDashboardPage; + test( + `Clinician - Filter Patients by Name [${workspace}]`, + { + tag: createValidatedTags([ + TEST_TAGS.CLINICIAN, + TEST_TAGS.UI, + TEST_TAGS.HIGH, + TEST_TAGS.REGRESSION, + ]), + }, + async ({ page }) => { + // Step 1: Log in to clinician account + await test.step('Given clinician has been logged in', async () => { + await test.clinician.setup(page); + }); - test.beforeEach(async ({ page }) => { - workspacesPage = new WorkspacesPage(page); - clinicWorkspacePage = new ClinicianDashboardPage(page); + // Step 2: Navigate to specific workspace + await test.step(`When user navigates to workspace ${workspace}`, async () => { + await test.clinician.navigateToWorkspace(workspace, page); + }); - await test.step('Given user has been logged in and navigated to base URL', async () => { - await workspacesPage.goto(); - await page.waitForURL(workspacesPage.url); - await workspacesPage.header.waitFor({ state: 'visible' }); - }); + // Create pages + const clinicWorkspacePage = new ClinicianDashboardPage(page); - await test.step('Given the user is on the first clinic workspace', async () => { - await workspacesPage.visitFirstClinic(); - await clinicWorkspacePage.waitForLoadState(); // Wait for clinic page elements - }); + // Step 3: Create Patient A + await test.step('When Patient A has been created', async () => { + // Check if Patient A already exists + await clinicWorkspacePage.searchForPatient(patientName1); + let patientAExists = false; + try { + await expect(clinicWorkspacePage.getPatientCellByName(patientName1)).toBeVisible({ + timeout: 3000, + }); + patientAExists = true; + } catch { + patientAExists = false; + } - await test.step('Given two patients exist', async () => { - // Add first patient - await clinicWorkspacePage.openAndFillAddPatientDialog( - patientName1, - patientBirthdate, - patnetMRN1, - patientEmail1, - ); - await clinicWorkspacePage.submitAddPatientDialog(); - await clinicWorkspacePage.closeBringDataDialog(); - // Ensure the first patient is added before adding the second - await expect(clinicWorkspacePage.getPatientCellByName(patientName1)).toBeVisible({ - timeout: 10000, - }); + if (!patientAExists) { + await clinicWorkspacePage.openAndFillAddPatientDialog( + patientName1, + patientBirthdate, + patientMRN1, + patientEmail1, + ); + await clinicWorkspacePage.submitAddPatientDialog(); + await clinicWorkspacePage.closeBringDataDialog(); + } - // Add second patient - await clinicWorkspacePage.openAndFillAddPatientDialog( - patientName2, - patientBirthdate, - patnetMRN2, - patientEmail2, - ); - await clinicWorkspacePage.submitAddPatientDialog(); - await clinicWorkspacePage.closeBringDataDialog(); - // Ensure the second patient is also added - await expect(clinicWorkspacePage.getPatientCellByName(patientName2)).toBeVisible({ - timeout: 10000, - }); - }); - }); + // Search for the patient to ensure it's visible in the list + await clinicWorkspacePage.searchForPatient(patientName1); + await expect(clinicWorkspacePage.getPatientCellByName(patientName1)).toBeVisible({ + timeout: 10000, + }); + }); + + // Step 4: Create Patient B + await test.step('When Patient B has been created', async () => { + // Check if Patient B already exists + await clinicWorkspacePage.searchForPatient(patientName2); + let patientBExists = false; + try { + await expect(clinicWorkspacePage.getPatientCellByName(patientName2)).toBeVisible({ + timeout: 3000, + }); + patientBExists = true; + } catch { + patientBExists = false; + } + + if (!patientBExists) { + await clinicWorkspacePage.openAndFillAddPatientDialog( + patientName2, + patientBirthdate, + patientMRN2, + patientEmail2, + ); + await clinicWorkspacePage.submitAddPatientDialog(); + await clinicWorkspacePage.closeBringDataDialog(); + } + + // Search for the patient to ensure it's visible in the list + await clinicWorkspacePage.searchForPatient(patientName2); + await expect(clinicWorkspacePage.getPatientCellByName(patientName2)).toBeVisible({ + timeout: 10000, + }); + }); - test('Clinician - Filter Patients by Name', async () => { - await test.step("When user filters by the first patient's name", async () => { - await clinicWorkspacePage.searchForPatient(patientName1); - }); + // Step 5: Filter by Patient A + await test.step("When user filters by Patient A's name", async () => { + await clinicWorkspacePage.searchForPatient(patientName1); + }); - await test.step('Then only the first patient should be visible', async () => { - const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); - const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); - await expect(patientCell1).toBeVisible(); - await expect(patientCell2).not.toBeVisible(); - }); + // Step 6: Verify only Patient A is visible + await test.step('Then only Patient A should be visible', async () => { + await clinicWorkspacePage.searchForPatient(patientName1); // Search to ensure list is populated + const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); + const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); + await expect(patientCell1).toBeVisible(); + await expect(patientCell2).not.toBeVisible(); + }); - await test.step('When user clears the filter', async () => { - // Assuming a method like clearPatientSearch exists or searchForPatient('') clears - await clinicWorkspacePage.searchForPatient(''); // Clear search by searching for empty string - // Or potentially: await clinicWorkspacePage.clearPatientSearch(); - }); + // Step 7: Clear the filter + await test.step('When user clears the filter', async () => { + await clinicWorkspacePage.searchForPatient(''); // Clear search by searching for empty string + }); - await test.step('Then both patients should be visible again', async () => { - const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); - const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); - await expect(patientCell1).toBeVisible(); - await expect(patientCell2).toBeVisible(); - }); + // Step 8: Verify both patients are visible + await test.step('Then both patients should be visible again', async () => { + await clinicWorkspacePage.searchForPatient(''); // Clear search to show all patients + const patientCell1 = clinicWorkspacePage.getPatientCellByName(patientName1); + const patientCell2 = clinicWorkspacePage.getPatientCellByName(patientName2); + await expect(patientCell1).toBeVisible(); + await expect(patientCell2).toBeVisible(); + }); + }, + ); }); }); From 6f190233d52540a1be4e4d26a1931294d3efef2c Mon Sep 17 00:00:00 2001 From: Foscottl-TP Date: Mon, 9 Mar 2026 09:44:55 -0700 Subject: [PATCH 57/60] Create TEST_FORMAT_GUIDE.md Created an MD file to help aid test migration from other playwright tests. --- docs/TEST_FORMAT_GUIDE.md | 842 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 842 insertions(+) create mode 100644 docs/TEST_FORMAT_GUIDE.md diff --git a/docs/TEST_FORMAT_GUIDE.md b/docs/TEST_FORMAT_GUIDE.md new file mode 100644 index 0000000..13b38f9 --- /dev/null +++ b/docs/TEST_FORMAT_GUIDE.md @@ -0,0 +1,842 @@ +# Test Format and Architecture Guide + +## Overview + +This guide documents the standardized test format and architecture patterns used in our Playwright-based UI testing suite. This format ensures consistency, maintainability, and readability across all tests. + +## Table of Contents + +- [Test Step Formatting](#test-step-formatting) +- [Page Objects and Component Scripts](#page-objects-and-component-scripts) +- [Fixtures and Helper Scripts](#fixtures-and-helper-scripts) +- [Navigation Functions](#navigation-functions) +- [Complete Test Example](#complete-test-example) +- [Migrating Existing Playwright Tests](#migrating-existing-playwright-tests) +- [Best Practices](#best-practices) + +--- + +## Test Step Formatting + +### Structure Pattern + +Tests follow a clear structure using `test.step()` to organize actions into logical groups. Each step includes: + +1. **Commented description** - Brief explanation of what the step accomplishes +2. **Step description string** - Descriptive text that appears in test reports +3. **Variable creation and placement** - Local variables declared at step level when needed +4. **Helper function usage** - Leveraging fixtures and page objects + +### Basic Step Format + +```typescript +// Step 1: Short comment describing the step purpose +await test.step('Given/When/Then descriptive step name', async () => { + // Local variables for this step + const variableName = someValue; + + // Actions using helper functions and page objects + await helperFunction.action(page); + await pageObject.element.action(); +}); +``` + +### Variable Creation and Placement + +**Variables are created at different scopes based on their usage:** + +```typescript +test('Test Name', async ({ page }) => { + // Test-level variables (used across multiple steps) + let originalEmail = ''; + const accountSettingsPage = new AccountSettingsPage(page); + + await test.step('Step with local variables', async () => { + // Step-level variables (only used within this step) + const tempValue = 'temporary-data'; + const currentTimestamp = Date.now(); + + // Use variables + await someAction(tempValue, currentTimestamp); + }); + + await test.step('Step using test-level variable', async () => { + originalEmail = await accountSettingsPage.emailInput.inputValue(); + }); +}); +``` + +### Step Types and Naming + +**Follow Given-When-Then pattern:** + +- **Given steps** - Setup/precondition steps +- **When steps** - Action steps +- **Then steps** - Verification/assertion steps + +```typescript +// Setup steps +await test.step('Given personal account has been logged in', async () => { + // Setup code +}); + +// Action steps +await test.step('When user updates the email field', async () => { + // User actions +}); + +// Verification steps +await test.step('Then the save changes message displays', async () => { + // Assertions and validations +}); +``` + +### Special Step Types + +**No-screenshot steps for API validations:** +```typescript +await (test as any).stepNoScreenshot( + 'Then profile endpoint responds with GET request consistent with schema', + async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }, +); +``` + +--- + +## Page Objects and Component Scripts + +### Purpose + +Page objects encapsulate UI elements and functionality into reusable classes, promoting: +- **Code reusability** across multiple tests +- **Maintainability** when UI changes +- **Readability** through semantic method names + +### Page Object Structure + +```typescript +import { Page, Locator } from '@playwright/test'; + +export class ComponentNamePage { + readonly page: Page; + readonly elementName: Locator; + readonly anotherElement: Locator; + + constructor(page: Page) { + this.page = page; + this.elementName = page.getByRole('button', { name: 'Submit' }); + this.anotherElement = page.getByText('Expected Text'); + } + + // Optional: Complex actions as methods + async performComplexAction(): Promise { + // Multiple element interactions + } +} + +export default ComponentNamePage; +``` + +### File Organization + +``` +page-objects/ +ā”œā”€ā”€ LoginPage.ts # Core authentication +ā”œā”€ā”€ account/ +│ ā”œā”€ā”€ AccountSettingsPage.ts # Account management +│ └── AccountNavigation.ts # Account navigation +ā”œā”€ā”€ patient/ +│ ā”œā”€ā”€ PatientDashboard.ts # Patient-specific pages +│ └── PatientNavigation.ts # Patient navigation +└── clinician/ + ā”œā”€ā”€ ClinicianDashboard.ts # Clinician-specific pages + └── ClinicianNavigation.ts # Clinician navigation +``` + +### Usage in Tests + +```typescript +import { AccountSettingsPage } from '../../../page-objects/account/AccountSettingsPage'; + +// Initialize page object +const accountSettingsPage = new AccountSettingsPage(page); + +// Use semantic element references +await accountSettingsPage.emailInput.fill('new-email@example.com'); +await accountSettingsPage.saveButton.click(); +await accountSettingsPage.saveConfirm.waitFor({ state: 'visible' }); +``` + +--- + +## Fixtures and Helper Scripts + +### Fixture Architecture + +Fixtures provide reusable functionality and are organized by domain: + +``` +tests/fixtures/ +ā”œā”€ā”€ base.ts # Core test fixtures and custom configurations +ā”œā”€ā”€ test-tags.ts # Test tagging system for organization +ā”œā”€ā”€ patient-helpers.ts # Patient-specific helper functions +ā”œā”€ā”€ account-helpers.ts # Account management helpers +ā”œā”€ā”€ clinic-helpers.ts # Clinician workflow helpers +└── network-helpers.ts # API testing and network capture +``` + +### Base Fixture Usage + +```typescript +import { test } from '../../fixtures/base'; +import { test as patientTest } from '../../fixtures/patient-helpers'; +import { test as accountTest } from '../../fixtures/account-helpers'; +import { createNetworkHelper } from '../../fixtures/network-helpers'; + +// Access custom fixtures +test('Test with enhanced fixtures', async ({ page, timeLogger, stepTimer }) => { + // Enhanced logging and timing automatically available +}); +``` + +### Helper Function Patterns + +**Setup helpers:** +```typescript +// Patient session setup +await patientTest.patient.setup(page); + +// API capture setup +api = createNetworkHelper(page); +await api.startCapture(); +``` + +**Navigation helpers:** +```typescript +// Account navigation +await accountTest.account.navigateTo('AccountSettings', page); + +// Patient navigation +await patientTest.patient.navigateTo('Profile', page); +``` + +**Validation helpers:** +```typescript +// API endpoint validation +await api.validateEndpointResponse('profile-metadata-put'); + +// Custom validation with captured requests +const putCapture = api.getCaptures() + .find((req: any) => req.method === 'PUT' && req.url.includes('/profile')); +``` + +### Test Tagging System + +```typescript +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; + +test('Test Name', { + tag: createValidatedTags([ + TEST_TAGS.PATIENT, // User type + TEST_TAGS.PERSONAL, // User subtype + TEST_TAGS.API, // Test type + TEST_TAGS.UI, // Test type + TEST_TAGS.HIGH, // Priority + TEST_TAGS.API_PROFILE, // Specific endpoint + ]), +}, async ({ page }) => { + // Test implementation +}); +``` + +--- + +## Navigation Functions + +### Navigation Architecture + +Navigation is handled through specialized classes that provide consistent routing: + +### Patient Navigation + +```typescript +import PatientNav from '@pom/patient/PatientNavigation'; + +// Initialize navigation +const nav = new PatientNav(page); + +// Direct page access +await nav.pages.ViewData.link.click(); +await nav.pages.Profile.link.waitFor({ state: 'visible' }); + +// Verification +await nav.pages.Profile.verifyElement.waitFor({ state: 'visible' }); +``` + +### Account Navigation + +```typescript +import AccountNav from '@pom/account/AccountNavigation'; + +// Helper function usage (recommended) +await accountTest.account.navigateTo('AccountSettings', page); + +// Direct navigation (when needed) +const accountNav = new AccountNav(page); +await accountNav.pages.AccountSettings.link.click(); +``` + +### Clinician Multi-Workspace Navigation + +**For clinician tests that require workspace navigation, use multi-workspace looping to test across all Admin/Member and tier variations:** + +#### Available Workspace Combinations + +The system supports 8 workspace combinations covering role and tier variations: +- **Admin Clinic** (Base, Enterprise, Essential, Professional) +- **Member Clinic** (Base, Enterprise, Essential, Professional) + +#### Multi-Workspace Test Pattern + +```typescript +import { test as clinicTest } from '../../fixtures/clinic-helpers'; +import { ALL_WORKSPACE_KEYS, type WorkspaceKey } from '../../page-objects/clinician/ClinicianNavigation'; + +// Loop through all workspace variations +for (const workspace of ALL_WORKSPACE_KEYS) { + test(`Test Name - ${workspace}`, async ({ page }) => { + + // Step 1: Setup clinician session + await test.step('Given clinician account has been logged in', async () => { + await clinicTest.clinic.setup(page); + }); + + // Step 2: Navigate to specific workspace + await test.step(`When user navigates to ${workspace} workspace`, async () => { + await clinicTest.clinic.navigateToWorkspace(workspace as WorkspaceKey, page); + }); + + // Step 3: Navigate to target page within workspace + await test.step('When user navigates to target page', async () => { + await clinicTest.clinic.navigateTo('PatientList', page); + }); + + // Step 4: Conditional logic based on role + if (workspace.includes('Member')) { + await test.step('Then Member user sees limited options', async () => { + // Member-specific assertions + await expect(adminOnlyButton).not.toBeVisible(); + }); + return; // Skip admin-only steps + } + + // Step 5: Admin-only functionality + await test.step('Then Admin user can access full functionality', async () => { + await expect(adminOnlyButton).toBeVisible(); + await adminOnlyButton.click(); + }); + }); +} +``` + +#### When to Use Multi-Workspace Looping + +**Use multi-workspace looping when:** +- Test involves workspace-specific functionality +- Different roles (Admin/Member) have different permissions +- Testing needs to verify behavior across different workspace tiers +- Navigation requires being within a workspace context + +**Skip multi-workspace looping when:** +- Test is purely authentication-related +- Testing global clinician features (account settings, profile) +- Test doesn't involve workspace-specific navigation or functionality + +#### Single Workspace Testing + +For tests that don't need multi-workspace coverage: + +```typescript +import { test as clinicTest } from '../../fixtures/clinic-helpers'; + +test('Single Workspace Test', async ({ page }) => { + // Use default workspace (AdminClinicBase) + await clinicTest.clinic.setup(page); + await clinicTest.clinic.navigateTo('Profile', page); + + // Test implementation without workspace variations +}); +``` + +#### Workspace-Specific Navigation + +```typescript +// Navigate to specific workspace +await clinicTest.clinic.navigateToWorkspace('AdminClinicEnterprise', page); + +// Navigate to page with workspace context +await clinicTest.clinic.navigateTo('PatientList', page, 'MemberClinicBase'); + +// Direct workspace navigation using page objects +const clinicianNav = new ClinicianNav(page); +await clinicianNav.workspaces.AdminClinicProfessional.link.click(); +``` + +### Navigation Patterns + +**Using helper functions (preferred):** +```typescript +// Setup navigation after login +await patientTest.patient.setup(page); +await clinicTest.clinic.setup(page); + +// Navigate to specific pages +await accountTest.account.navigateTo('AccountSettings', page); +await clinicTest.clinic.navigateTo('PatientList', page); +``` + +**Direct navigation when more control is needed:** +```typescript +const nav = new PatientNav(page); + +// Close any blocking dialogs first +await patientTest.patient.closeDialogs(page); + +// Navigate with verification +await nav.pages.Profile.link.click(); +await nav.pages.Profile.verifyElement.waitFor({ state: 'visible' }); +``` + +--- + +## Complete Test Example + +Here's a complete test demonstrating all patterns: + +```typescript +import { test } from '../../fixtures/base'; +import { test as patientTest } from '../../fixtures/patient-helpers'; +import { test as accountTest } from '../../fixtures/account-helpers'; +import { createNetworkHelper } from '../../fixtures/network-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { AccountSettingsPage } from '../../../page-objects/account/AccountSettingsPage'; + +test.describe('Account Settings - Personal - Edit Email', () => { + // Test-level variables for network helpers + let api: ReturnType; + + test( + 'Account Settings - Personal - Edit Email', + { + tag: createValidatedTags([ + TEST_TAGS.PATIENT, + TEST_TAGS.PERSONAL, + TEST_TAGS.API, + TEST_TAGS.UI, + TEST_TAGS.HIGH, + TEST_TAGS.API_PROFILE, + ]), + }, + async ({ page }) => { + // Test-level variables for cross-step usage + const accountSettingsPage = new AccountSettingsPage(page); + let originalEmail = ''; + + // Step 1: Setup and authentication + await test.step('Given personal account has been logged in', async () => { + api = createNetworkHelper(page); + await api.startCapture(); + await page.goto('/data'); + await patientTest.patient.setup(page); + }); + + // Step 2: Navigation using helper function + await test.step('When user navigates to account settings', async () => { + await accountTest.account.navigateTo('AccountSettings', page); + }); + + // Step 3: API validation using helper function + await (test as any).stepNoScreenshot( + 'Then profile endpoint responds with GET request consistent with schema', + async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }, + ); + + // Step 4: UI interaction with variable capture + await test.step('When user updates the email field', async () => { + originalEmail = await accountSettingsPage.emailInput.inputValue(); + await accountSettingsPage.emailInput.fill('qa+TempPersonalEdit@tidepool.org'); + }); + + // Step 5: User action + await test.step('When user taps the save button', async () => { + await accountSettingsPage.saveButton.click(); + }); + + // Step 6: UI verification + await test.step('Then the save changes message displays', async () => { + await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); + }); + + // Step 7: API validation with custom logic + await (test as any).stepNoScreenshot( + 'Then PUT request is validated and email is set to new value', + async () => { + await api.validateEndpointResponse('profile-metadata-put'); + + // Custom validation logic + const putCapture = api + .getCaptures() + .find((req: any) => req.method === 'PUT' && req.url.includes('/profile')); + + if (!putCapture) throw new Error('No PUT /profile request captured'); + if (!putCapture.requestBody?.email) { + throw new Error('PUT request missing email field'); + } + }, + ); + + // Cleanup + await api.stopCapture(); + }, + ); +}); +``` + +--- + +## Migrating Existing Playwright Tests + +This section provides clear patterns for converting existing Playwright tests into our standardized format. Use these examples to transform tests from other frameworks or patterns. + +### Migration Checklist + +**Before migrating, identify:** +1. **Test type**: Patient, Clinician, or Account-focused +2. **Required imports**: Which fixtures and page objects are needed +3. **Workspace requirements**: Does the clinician test need multi-workspace looping? +4. **API testing**: Does the test need network capture and validation? + +### Converting Basic Test Structure + +#### Before (Standard Playwright) +```typescript +import { test, expect } from '@playwright/test'; + +test('Login Test', async ({ page }) => { + await page.goto('/login'); + await page.getByRole('textbox', { name: 'email' }).fill('user@example.com'); + await page.getByRole('textbox', { name: 'password' }).fill('password'); + await page.getByRole('button', { name: 'Login' }).click(); + await expect(page.locator('h1')).toContainText('Dashboard'); +}); +``` + +#### After (Our Format) +```typescript +import { test } from '../../fixtures/base'; +import { test as patientTest } from '../../fixtures/patient-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; + +test.describe('Authentication Tests', () => { + test( + 'Patient Login Flow', + { + tag: createValidatedTags([ + TEST_TAGS.PATIENT, + TEST_TAGS.PERSONAL, + TEST_TAGS.UI, + TEST_TAGS.HIGH, + ]), + }, + async ({ page }) => { + // Step 1: Navigate to login + await test.step('Given user navigates to login page', async () => { + await page.goto('/login'); + }); + + // Step 2: Enter credentials + await test.step('When user enters valid credentials', async () => { + await page.getByRole('textbox', { name: 'email' }).fill('user@example.com'); + await page.getByRole('textbox', { name: 'password' }).fill('password'); + }); + + // Step 3: Submit login + await test.step('When user submits login form', async () => { + await page.getByRole('button', { name: 'Login' }).click(); + }); + + // Step 4: Verify success + await test.step('Then user sees dashboard', async () => { + await expect(page.locator('h1')).toContainText('Dashboard'); + }); + }, + ); +}); +``` + +### Converting Page Object Usage + +#### Before (Basic Page Objects) +```typescript +class LoginPage { + constructor(private page: Page) {} + + async login(email: string, password: string) { + await this.page.getByRole('textbox', { name: 'email' }).fill(email); + await this.page.getByRole('textbox', { name: 'password' }).fill(password); + await this.page.getByRole('button', { name: 'Login' }).click(); + } +} + +// Usage in test +const loginPage = new LoginPage(page); +await loginPage.login('user@example.com', 'password'); +``` + +#### After (Our Page Object + Helper Pattern) +```typescript +// Use existing page objects and helper functions +import LoginPage from '@pom/LoginPage'; +import { test as patientTest } from '../../fixtures/patient-helpers'; + +// In test +await test.step('Given user has been authenticated', async () => { + await patientTest.patient.setup(page); // Uses helper function +}); + +// Or manual page object usage when needed +await test.step('When user enters credentials', async () => { + const loginPage = new LoginPage(page); + await loginPage.emailInput.fill('user@example.com'); + await loginPage.passwordInput.fill('password'); + await loginPage.loginButton.click(); +}); +``` + +### Converting Clinician Tests - Multi-Workspace Decision + +#### Identify if Multi-Workspace Looping is Needed + +**Ask these questions:** +1. Does the test navigate to a workspace-specific page? (`PatientList`, `WorkspaceSettings`, `AddPatient`) +2. Does the test involve role-based permissions? (Admin vs Member differences) +3. Does the test need to verify behavior across different workspace tiers? + +#### Before (Single Test) +```typescript +test('Clinician can add patient', async ({ page }) => { + await page.goto('/clinic-workspace'); + await page.getByRole('link', { name: 'Patient List' }).click(); + await page.getByRole('button', { name: 'Add Patient' }).click(); + // ... rest of test +}); +``` + +#### After (Multi-Workspace if Needed) +```typescript +import { test as clinicTest } from '../../fixtures/clinic-helpers'; +import { ALL_WORKSPACE_KEYS, type WorkspaceKey } from '../../page-objects/clinician/ClinicianNavigation'; + +// If test involves workspace navigation and role differences +for (const workspace of ALL_WORKSPACE_KEYS) { + test(`Clinician can add patient - ${workspace}`, async ({ page }) => { + + await test.step('Given clinician has been logged in', async () => { + await clinicTest.clinic.setup(page); + }); + + await test.step(`When user navigates to ${workspace} workspace`, async () => { + await clinicTest.clinic.navigateToWorkspace(workspace as WorkspaceKey, page); + }); + + await test.step('When user navigates to patient list', async () => { + await clinicTest.clinic.navigateTo('PatientList', page); + }); + + // Role-specific logic + if (workspace.includes('Member')) { + await test.step('Then Member user cannot add patients', async () => { + await expect(page.getByRole('button', { name: 'Add Patient' })).not.toBeVisible(); + }); + return; + } + + await test.step('When Admin user clicks Add Patient', async () => { + await page.getByRole('button', { name: 'Add Patient' }).click(); + }); + }); +} + +// If test doesn't need workspace variations +test('Clinician profile settings', async ({ page }) => { + await test.step('Given clinician has been logged in', async () => { + await clinicTest.clinic.setup(page); + }); + + await test.step('When user navigates to profile', async () => { + await clinicTest.clinic.navigateTo('Profile', page); + }); + // ... rest of test (no workspace looping needed) +}); +``` + +### Converting API Testing + +#### Before (Basic Network Capture) +```typescript +test('API response validation', async ({ page }) => { + await page.route('**/api/profile', route => route.continue()); + + await page.goto('/profile'); + await page.getByRole('textbox', { name: 'email' }).fill('new@email.com'); + await page.getByRole('button', { name: 'Save' }).click(); + + // Manual network validation... +}); +``` + +#### After (Our Network Helper Pattern) +```typescript +import { createNetworkHelper } from '../../fixtures/network-helpers'; + +test('Profile update with API validation', async ({ page }) => { + // Test-level variable for network helper + let api: ReturnType; + + await test.step('Given user is logged in with network capture', async () => { + api = createNetworkHelper(page); + await api.startCapture(); + await patientTest.patient.setup(page); + }); + + await test.step('When user navigates to profile', async () => { + await accountTest.account.navigateTo('Profile', page); + }); + + // API validation step (no screenshot needed) + await (test as any).stepNoScreenshot( + 'Then profile GET request is validated', + async () => { + await api.validateEndpointResponse('profile-metadata-get'); + }, + ); + + await test.step('When user updates email', async () => { + await profilePage.emailInput.fill('new@email.com'); + await profilePage.saveButton.click(); + }); + + await (test as any).stepNoScreenshot( + 'Then profile PUT request is validated', + async () => { + await api.validateEndpointResponse('profile-metadata-put'); + }, + ); + + // Cleanup + await api.stopCapture(); +}); +``` + +### Import Migration Guide + +#### Required Imports for Different Test Types + +**Patient Tests:** +```typescript +import { test } from '../../fixtures/base'; +import { test as patientTest } from '../../fixtures/patient-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +// Add page objects as needed: +// import PatientDashboard from '@pom/patient/PatientDashboard'; +``` + +**Clinician Tests:** +```typescript +import { test } from '../../fixtures/base'; +import { test as clinicTest } from '../../fixtures/clinic-helpers'; +import { ALL_WORKSPACE_KEYS, type WorkspaceKey } from '@pom/clinician/ClinicianNavigation'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +``` + +**Account/Settings Tests:** +```typescript +import { test } from '../../fixtures/base'; +import { test as accountTest } from '../../fixtures/account-helpers'; +import { TEST_TAGS, createValidatedTags } from '../../fixtures/test-tags'; +import { AccountSettingsPage } from '@pom/account/AccountSettingsPage'; +``` + +**API Testing (add to any test type):** +```typescript +import { createNetworkHelper } from '../../fixtures/network-helpers'; +// Declare at test level: let api: ReturnType; +``` + +### Migration Decision Tree + +``` +1. What type of user does this test focus on? + ā”œā”€ā”€ Patient → Use patient-helpers, patient page objects + ā”œā”€ā”€ Clinician → Use clinic-helpers, clinician page objects + └── Account/General → Use account-helpers, account page objects + +2. Does the test involve API validation? + ā”œā”€ā”€ Yes → Add createNetworkHelper import and api variable + └── No → Skip network helper + +3. Is this a clinician test that navigates to workspace pages? + ā”œā”€ā”€ Yes → Use multi-workspace looping pattern + └── No → Use single workspace or no workspace + +4. How complex are the interactions? + ā”œā”€ā”€ Simple → Use helper functions (setup, navigateTo) + └── Complex → Create page object instances + helper functions +``` + +This migration guide provides clear conversion patterns that any AI can follow to transform existing Playwright tests into your standardized format. + +--- + +## Best Practices + +### Test Organization + +1. **Use descriptive test and step names** that clearly indicate purpose +2. **Group related tests** in describe blocks +3. **Follow Given-When-Then pattern** for step organization +4. **Use semantic locators** (`getByRole`, `getByText`) over CSS selectors + +### Variable Management + +1. **Declare variables at appropriate scope** (test-level vs step-level) +2. **Use meaningful variable names** that indicate content/purpose +3. **Initialize complex objects early** (page objects, network helpers) +4. **Clean up resources** in finally blocks or at test end + +### Helper Function Usage + +1. **Prefer helper functions** over direct page object usage for common flows +2. **Use fixtures** for cross-cutting concerns (logging, timing, network) +3. **Leverage navigation helpers** for consistent routing +4. **Validate API responses** using network helpers when testing UI that triggers API calls + +### Error Handling + +1. **Use appropriate timeouts** for element waits +2. **Provide meaningful error messages** for custom validations +3. **Clean up resources** even when tests fail +4. **Use stepNoScreenshot** for API-only validations to reduce noise + +### Code Reusability + +1. **Create page objects** for any UI component used in multiple tests +2. **Extract repeated logic** into helper functions +3. **Use test tags** consistently for test organization and filtering +4. **Document complex page objects** with JSDoc comments + +This guide ensures all team members and AI assistants can create consistent, maintainable tests following established patterns. \ No newline at end of file From d1bcfbcb98705a7bf7c3d9877515816b0da4622d Mon Sep 17 00:00:00 2001 From: Foscottl-TP Date: Mon, 6 Apr 2026 11:22:03 -0700 Subject: [PATCH 58/60] Update int env URL to external.integration Change the 'int' target environment URL in utilities/env.ts from https://int.development.tidepool.org to https://external.integration.tidepool.org/ so the integration target points to the external integration host. Fixing this because the previous URL was not accessible, incorrect, or out of date. --- utilities/env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utilities/env.ts b/utilities/env.ts index 206d92c..f80d1fa 100644 --- a/utilities/env.ts +++ b/utilities/env.ts @@ -38,7 +38,7 @@ const URL_MAP: Record = { qa5: 'https://qa5.development.tidepool.org', production: 'https://app.tidepool.org', prd: 'https://app.tidepool.org', // Alias for production - int: 'https://int.development.tidepool.org', // Integration environment + int: 'https://external.integration.tidepool.org', // Integration environment }; export default { From 7502add07d5b0606ca8746713865b72b3f801e90 Mon Sep 17 00:00:00 2001 From: Foscottl-TP Date: Thu, 9 Apr 2026 08:29:54 -0700 Subject: [PATCH 59/60] Revert email on PUT validation failure in tests Wrap PUT /profile validation in a try/catch to capture any validation error, always execute the step that restores the original email, and re-throw the captured error after the revert. Applies the change to both claimed and personal AccountSettings EditEmail specs to ensure cleanup (email reversion) occurs even when the PUT validation fails. --- .../AccountSettings-Claimed-EditEmail.spec.ts | 65 ++++++++----------- ...AccountSettings-Personal-EditEmail.spec.ts | 65 ++++++++----------- 2 files changed, 56 insertions(+), 74 deletions(-) diff --git a/tests/claimed/API-Profile/AccountSettings-Claimed-EditEmail.spec.ts b/tests/claimed/API-Profile/AccountSettings-Claimed-EditEmail.spec.ts index 713a370..d5bb4ba 100644 --- a/tests/claimed/API-Profile/AccountSettings-Claimed-EditEmail.spec.ts +++ b/tests/claimed/API-Profile/AccountSettings-Claimed-EditEmail.spec.ts @@ -63,26 +63,31 @@ test.describe('Account Settings - Claimed - Edit Email', () => { await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); }); - // Step 7: Validate PUT request and email value - await (test as any).stepNoScreenshot( - 'Then PUT request is validated and email is set to new value', - async () => { - await api.validateEndpointResponse('profile-metadata-put'); - const putCapture = api - .getCaptures() - .find((req: any) => req.method === 'PUT' && req.url.includes('/profile')); - if (!putCapture) throw new Error('No PUT /profile request captured'); - if ( - !putCapture.requestBody || - !putCapture.requestBody.email || - putCapture.requestBody.email !== 'qa+TempEdit@tidepool.org' - ) { - throw new Error('PUT request did not set email to qa+TempEdit@tidepool.org'); - } - }, - ); + // Step 7: Validate PUT request and email value (with email reversion on failure) + let step7ValidationError = null; + try { + await (test as any).stepNoScreenshot( + 'Then PUT request is validated and email is set to new value', + async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putCapture = api + .getCaptures() + .find((req: any) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) throw new Error('No PUT /profile request captured'); + if ( + !putCapture.requestBody || + !putCapture.requestBody.email || + putCapture.requestBody.email !== 'qa+TempEdit@tidepool.org' + ) { + throw new Error('PUT request did not set email to qa+TempEdit@tidepool.org'); + } + }, + ); + } catch (error) { + step7ValidationError = error; + } - // Step 8: Change email field to temporary value + // Step 8: Change email field to temporary value (always execute to revert email) await test.step('When user sets the email field to the previous value', async () => { await accountSettingsPage.emailInput.fill(originalEmail); }); @@ -97,24 +102,10 @@ test.describe('Account Settings - Claimed - Edit Email', () => { await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); }); - // Step 7: Validate PUT request and email value - await (test as any).stepNoScreenshot( - 'Then PUT request is validated and email is set to new value', - async () => { - await api.validateEndpointResponse('profile-metadata-put'); - const putCapture = api - .getCaptures() - .find((req: any) => req.method === 'PUT' && req.url.includes('/profile')); - if (!putCapture) throw new Error('No PUT /profile request captured'); - if ( - !putCapture.requestBody || - !putCapture.requestBody.email || - putCapture.requestBody.email !== originalEmail - ) { - throw new Error('PUT request did not set email to originalEmail'); - } - }, - ); + // Re-throw step 7 validation error after email reversion (if any) + if (step7ValidationError) { + throw step7ValidationError; + } await api.stopCapture(); }, diff --git a/tests/personal/AP-Profile/AccountSettings-Personal-EditEmail.spec.ts b/tests/personal/AP-Profile/AccountSettings-Personal-EditEmail.spec.ts index ee0d478..5a48a62 100644 --- a/tests/personal/AP-Profile/AccountSettings-Personal-EditEmail.spec.ts +++ b/tests/personal/AP-Profile/AccountSettings-Personal-EditEmail.spec.ts @@ -63,26 +63,31 @@ test.describe('Account Settings - Personal - Edit Email', () => { await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); }); - // Step 7: Validate PUT request and email value - await (test as any).stepNoScreenshot( - 'Then PUT request is validated and email is set to new value', - async () => { - await api.validateEndpointResponse('profile-metadata-put'); - const putCapture = api - .getCaptures() - .find((req: any) => req.method === 'PUT' && req.url.includes('/profile')); - if (!putCapture) throw new Error('No PUT /profile request captured'); - if ( - !putCapture.requestBody || - !putCapture.requestBody.email || - putCapture.requestBody.email !== 'qa+TempPersonalEdit@tidepool.org' - ) { - throw new Error('PUT request did not set email to qa+TempEdit@tidepool.org'); - } - }, - ); + // Step 7: Validate PUT request and email value (with email reversion on failure) + let step7ValidationError = null; + try { + await (test as any).stepNoScreenshot( + 'Then PUT request is validated and email is set to new value', + async () => { + await api.validateEndpointResponse('profile-metadata-put'); + const putCapture = api + .getCaptures() + .find((req: any) => req.method === 'PUT' && req.url.includes('/profile')); + if (!putCapture) throw new Error('No PUT /profile request captured'); + if ( + !putCapture.requestBody || + !putCapture.requestBody.email || + putCapture.requestBody.email !== 'qa+TempPersonalEdit@tidepool.org' + ) { + throw new Error('PUT request did not set email to qa+TempPersonalEdit@tidepool.org'); + } + }, + ); + } catch (error) { + step7ValidationError = error; + } - // Step 8: Change email field to temporary value + // Step 8: Change email field to temporary value (always execute to revert email) await test.step('When user sets the email field to the previous value', async () => { await accountSettingsPage.emailInput.fill(originalEmail); }); @@ -97,24 +102,10 @@ test.describe('Account Settings - Personal - Edit Email', () => { await accountSettingsPage.saveConfirm.waitFor({ state: 'visible', timeout: 5000 }); }); - // Step 7: Validate PUT request and email value - await (test as any).stepNoScreenshot( - 'Then PUT request is validated and email is set to new value', - async () => { - await api.validateEndpointResponse('profile-metadata-put'); - const putCapture = api - .getCaptures() - .find((req: any) => req.method === 'PUT' && req.url.includes('/profile')); - if (!putCapture) throw new Error('No PUT /profile request captured'); - if ( - !putCapture.requestBody || - !putCapture.requestBody.email || - putCapture.requestBody.email !== originalEmail - ) { - throw new Error('PUT request did not set email to originalEmail'); - } - }, - ); + // Re-throw step 7 validation error after email reversion (if any) + if (step7ValidationError) { + throw step7ValidationError; + } await api.stopCapture(); }, From 5851de15ed608b362af62c9a91a76f9c186048a8 Mon Sep 17 00:00:00 2001 From: Foscottl-TP Date: Mon, 4 May 2026 09:55:53 -0700 Subject: [PATCH 60/60] Add dev1 to TARGET_ENV and URL_MAP Include the 'dev1' environment in the environment schema and mapping. Updates the zod TARGET_ENV enum to allow 'dev1' and adds a corresponding URL_MAP entry (https://dev1.dev.tidepool.org/) so the app can resolve the development environment. No other logic changed. --- utilities/env.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/utilities/env.ts b/utilities/env.ts index f80d1fa..1a1ef20 100644 --- a/utilities/env.ts +++ b/utilities/env.ts @@ -14,7 +14,7 @@ const envSchema = z.object({ SHARED_PASSWORD: z.string(), CLINICIAN_USERNAME: z.string(), CLINICIAN_PASSWORD: z.string(), - TARGET_ENV: z.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production', 'prd', 'int']), + TARGET_ENV: z.enum(['qa1', 'qa2', 'qa3', 'qa4', 'qa5', 'production', 'prd', 'int', 'dev1']), XRAY_CLIENT_ID: z.string().optional(), XRAY_CLIENT_SECRET: z.string().optional(), XRAY_PROJECT_KEY: z.string().default('SAND'), @@ -39,6 +39,7 @@ const URL_MAP: Record = { production: 'https://app.tidepool.org', prd: 'https://app.tidepool.org', // Alias for production int: 'https://external.integration.tidepool.org', // Integration environment + dev1: 'https://dev1.dev.tidepool.org/', // Development environment }; export default {