diff --git a/.circleci/config.yml b/.circleci/config.yml index 77bedc5..1e00ffa 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,10 +6,16 @@ orbs: parameters: testEnvironment: type: string - default: 'qa1' + default: 'qa2' testExecKey: type: string default: 'none' + testTags: + type: string + default: '' + xrayProjectKey: + type: string + default: 'SAND' jobs: code-quality-check: working_directory: ~/tidepool-org/webuitests @@ -33,15 +39,19 @@ 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 - environment: - TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> - TARGET_ENV: << pipeline.parameters.testEnvironment >> steps: - checkout - node/install - run: node --version + # Pipeline parameters always take precedence over project-level env vars + - run: + name: Set environment variables + command: | + 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" }} @@ -57,67 +67,109 @@ 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 run test --shard=$CIRCLE_NODE_INDEX/$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: path: playwright-report - store_artifacts: path: test-results - - store_test_results: - path: test-output/test-results.xml - # Commit workflow notifications - basic templates for all branches - - slack/notify: - event: fail - mentions: '<@UG56AQFK2>' - template: basic_fail_1 - - slack/notify: - event: pass - branch_pattern: main - template: basic_success_1 - - slack/notify: - event: pass - branch_pattern: /^(?!main$).*/ - template: basic_success_1 - - unless: + + # Only send notifications from the first node to avoid duplicates + - when: condition: - and: - - equal: ['testParallel', << pipeline.parameters.testEnvironment >>] + equal: ["0", "${CIRCLE_NODE_INDEX}"] steps: - - run: - name: Gather Test Evidence - 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 - 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 >>"' - when: always - - run: - name: Add Test Evidence to JIRA - command: node utilities/sendTestEvidenceToJira.js - when: always + - 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: 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>" + } + } + ] + } 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 - environment: - TEST_EXECUTION_KEY: << pipeline.parameters.testExecKey >> - TARGET_ENV: << pipeline.parameters.testEnvironment >> steps: - checkout - node/install - run: node --version + # Pipeline parameters always take precedence over project-level env vars + - run: + name: Set environment variables + command: | + 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" }} @@ -133,109 +185,72 @@ 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 run test --shard=$CIRCLE_NODE_INDEX/$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: 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 - # 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>*" - } - } - ] - } - - 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: + # Only send notifications from the first node to avoid duplicates + - when: condition: - and: - - equal: ['testParallel', << pipeline.parameters.testEnvironment >>] + equal: ["0", "${CIRCLE_NODE_INDEX}"] steps: - - run: - name: Gather Test Evidence - 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 - 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 >>"' - when: always - - run: - name: Add Test Evidence to JIRA - command: node utilities/sendTestEvidenceToJira.js - when: always + - 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>*" + } + } + ] + } workflows: commit-workflow: jobs: - code-quality-check - - test: - requires: - - eslint-check \ No newline at end of file + - test diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index eac6fc5..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "overrides": [ - { - "files": ["tests/**/*.spec.ts", "tests/**/*.test.ts"], - "rules": { - "no-restricted-syntax": [ - "error", - { - "selector": "CallExpression[callee.name=\"test\"]:not([callee.object]):not(:has(ObjectExpression Property[key.name=\"tag\"] CallExpression[callee.name=\"createValidatedTags\"]))", - "message": "All main test() calls must include a tag property with createValidatedTags([...]) containing User Type, Test Type, and Priority tags." - } - ] - } - }, - { - "files": ["tests/fixtures/network-helpers.ts"], - "rules": { - "no-underscore-dangle": "off", - "@typescript-eslint/no-shadow": "off" - } - }, - { - "files": ["tests/fixtures/patient-helpers.ts"], - "rules": { - "no-continue": "off" - } - } - ] -} \ No newline at end of file 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/CLAUDE.md b/CLAUDE.md index 076be15..a280f6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,24 +9,41 @@ 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 -- `TARGET_ENV=qa2 playwright test` - Run tests on qa2 environment -- `TARGET_ENV=production playwright test` - 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 --project=chromium-patient` - Run only patient tests -- `playwright test --project=chromium-clinician` - Run only clinician tests +- `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 + +- `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 ### Page Object Model Structure + The codebase follows the Page Object Model (POM) pattern with a clear separation: - **`page-objects/`** - Contains all page object classes @@ -36,65 +53,123 @@ 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, production -- Environment variables validated at startup +- **`.env` file** - Local environment configuration (set TARGET_ENV and credentials) +- Supports environments: qa1, qa2, qa3, qa4, qa5, prd, int +- Environment variables validated at startup via Zod schema +- CircleCI uses pipeline parameters to set environment variables ### 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 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 with configurable project key ## 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**: + - 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 ### 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 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, production) -- Optional: `BROWSERSTACK_USERNAME` / `BROWSERSTACK_ACCESS_KEY` \ No newline at end of file + +- `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` (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 + +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. 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/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/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 diff --git a/docs/XRAY_INTEGRATION.md b/docs/XRAY_INTEGRATION.md new file mode 100644 index 0000000..d72e063 --- /dev/null +++ b/docs/XRAY_INTEGRATION.md @@ -0,0 +1,251 @@ +# 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 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/eslint.config.mjs b/eslint.config.mjs index 06179f8..7be942f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,7 +12,6 @@ import js from '@eslint/js'; import { configs, plugins } from 'eslint-config-airbnb-extended'; import { rules as prettierConfigRules } from 'eslint-config-prettier'; import prettierPlugin from 'eslint-plugin-prettier'; -import playwrightPlugin from 'eslint-plugin-playwright'; const gitignorePath = path.resolve('.', '.gitignore'); @@ -98,23 +97,4 @@ export default [ 'no-useless-escape': 'off', }, }, - // Test-specific rules for .spec.ts files - { - files: ['tests/**/*.spec.ts', 'tests/**/*.test.ts'], - plugins: { - playwright: playwrightPlugin, - }, - rules: { - // Enforce that test() calls in spec files have createValidatedTags - 'no-restricted-syntax': [ - 'error', - { - selector: - 'CallExpression[callee.name="test"]:not(:has(ObjectExpression Property[key.name="tag"] CallExpression[callee.name="createValidatedTags"]))', - message: - 'All test() calls must include a tag property with createValidatedTags([...]) containing User Type, Test Type, and Priority tags.', - }, - ], - }, - }, ]; diff --git a/package-lock.json b/package-lock.json index 277afef..8c143cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import-x": "^4.16.1", "eslint-plugin-n": "^17.21.3", - "eslint-plugin-playwright": "^2.5.0", "eslint-plugin-prettier": "^5.5.3", "globals": "^16.3.0", "jiti": "^2.5.1", @@ -45,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, @@ -79,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, @@ -90,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": { @@ -122,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": { @@ -132,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" }, @@ -150,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" }, @@ -189,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": { @@ -212,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": { @@ -224,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" }, @@ -296,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": { @@ -323,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": { @@ -333,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": { @@ -383,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", @@ -426,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", @@ -437,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", @@ -492,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", @@ -550,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", @@ -587,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", @@ -631,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", @@ -689,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", @@ -728,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", @@ -747,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" }, @@ -758,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" @@ -920,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", @@ -940,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", @@ -981,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, @@ -1051,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" @@ -1098,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", @@ -1108,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": "*" @@ -1129,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" @@ -1153,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": { @@ -1169,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" @@ -1190,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" @@ -1212,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" @@ -1234,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": { @@ -1247,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" @@ -1272,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": { @@ -1290,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" @@ -1315,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" @@ -1355,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": { @@ -1662,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" @@ -1670,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", @@ -1742,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", @@ -1865,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": { @@ -1896,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", @@ -1908,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" @@ -1938,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": { @@ -1958,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": { @@ -1979,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", @@ -2000,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" @@ -2024,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": { @@ -2079,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" @@ -2091,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" @@ -2126,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" @@ -2161,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", @@ -2188,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", @@ -2220,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", @@ -2236,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", @@ -2279,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" } @@ -2505,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": { @@ -2533,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": { @@ -2584,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" } @@ -2656,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": { @@ -2672,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" } @@ -2681,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" } @@ -2690,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" } @@ -2705,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" @@ -2817,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" @@ -2953,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" } @@ -3096,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", @@ -3127,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" } @@ -3141,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": { @@ -3242,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", @@ -3278,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", @@ -3355,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": { @@ -3552,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,30 +3597,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-plugin-playwright": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.5.0.tgz", - "integrity": "sha512-1ckFw7Abdz+l23wtw5Tg4GTK3Y+MgEQQNjEr7FTJP3wwmIOj8DkbJ6G655aPc09c0Kfn/NoGA4xpMZzeSO4NWw==", - "dev": true, - "dependencies": { - "globals": "^16.4.0" - }, - "engines": { - "node": ">=16.9.0" - }, - "peerDependencies": { - "eslint": ">=8.40.0" - } - }, "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 +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": { @@ -3812,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" } @@ -3849,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" }, @@ -3862,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": { @@ -3906,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", @@ -3952,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" }, @@ -3962,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", @@ -4008,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", @@ -4053,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", @@ -4068,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", @@ -4087,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", @@ -4106,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", @@ -4133,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": { @@ -4181,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", @@ -4216,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", @@ -4240,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" } @@ -4254,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" } @@ -4358,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", @@ -4420,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": { @@ -4512,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", @@ -4558,6 +4540,7 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -4742,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", @@ -4840,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", @@ -4875,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": { @@ -4957,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": { @@ -5043,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" } @@ -5060,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" } @@ -5080,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", @@ -5140,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" }, @@ -5187,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" @@ -5198,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", @@ -5230,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", @@ -5349,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": { @@ -5368,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" @@ -5407,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", @@ -5437,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", @@ -5473,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", @@ -5516,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" } }, @@ -5715,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" } @@ -5724,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" }, @@ -5737,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" }, @@ -5777,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": { @@ -5816,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": { @@ -5849,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": { @@ -5876,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" } @@ -5967,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" @@ -5980,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" } @@ -6019,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" }, @@ -6060,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" @@ -6328,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" @@ -6418,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" } @@ -6451,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": { @@ -6478,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" @@ -6518,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" @@ -6549,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": { @@ -6565,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": { @@ -6613,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": { @@ -6654,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" @@ -6694,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" @@ -6709,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" @@ -6732,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", @@ -6770,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": { @@ -6804,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", @@ -6827,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", @@ -6939,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", @@ -7034,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", @@ -7045,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", @@ -7119,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", @@ -7128,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" @@ -7161,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": { @@ -7219,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", @@ -7231,6 +7156,10 @@ }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/set-function-length": { @@ -7254,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", @@ -7355,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", @@ -7392,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": { @@ -7480,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" } @@ -7518,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": { @@ -7581,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": { @@ -7608,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": { @@ -7640,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", @@ -7751,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" @@ -7767,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" } @@ -7842,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": { @@ -7877,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", @@ -7926,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", @@ -7935,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", @@ -7966,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, @@ -7981,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" @@ -8001,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": { @@ -8030,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" } @@ -8098,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" @@ -8178,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" } @@ -8250,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" @@ -8285,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" @@ -8315,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", @@ -8378,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" @@ -8390,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" @@ -8425,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" @@ -8458,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" @@ -8594,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 92f020a..7352bca 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,23 @@ { "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": "TARGET_ENV=qa1 npx playwright test --debug ", - "test": "TARGET_ENV=qa1 playwright test", + "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 ." }, "repository": { @@ -42,7 +49,6 @@ "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import-x": "^4.16.1", "eslint-plugin-n": "^17.21.3", - "eslint-plugin-playwright": "^2.5.0", "eslint-plugin-prettier": "^5.5.3", "globals": "^16.3.0", "jiti": "^2.5.1", diff --git a/page-objects/clinician/ClinicianDashboardPage.ts b/page-objects/clinician/ClinicianDashboardPage.ts index 23b107f..4f42ec1 100644 --- a/page-objects/clinician/ClinicianDashboardPage.ts +++ b/page-objects/clinician/ClinicianDashboardPage.ts @@ -14,10 +14,6 @@ class ClinicianDashboardPage { readonly patientListTable: Locator; - readonly patientListTable_rows: Locator; - - readonly showAllToggle: Locator; - // Locators for the Add Patient Dialog readonly addPatientDialog: Locator; @@ -27,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 @@ -39,6 +39,8 @@ class ClinicianDashboardPage { readonly removePatientButton: Locator; + readonly editPatientDetailsButton: Locator; + readonly removePatientConfirm: Locator; constructor(page: Page) { @@ -47,9 +49,7 @@ class ClinicianDashboardPage { // Main page locators this.addNewPatientButton = page.getByRole('button', { name: 'Add New Patient' }); this.searchInput = page.getByRole('textbox', { name: 'Search' }); - this.patientListTable = page.locator('table#peopleTable'); - this.patientListTable_rows = page.getByRole('row'); - this.showAllToggle = page.getByLabel('Toggle visibility'); + this.patientListTable = page.getByRole('table', { name: 'peopletablelabel' }); // Add Patient Dialog locators this.addPatientDialog = page.getByRole('dialog'); @@ -62,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', }); @@ -77,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 }); } @@ -84,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); } /** @@ -115,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 @@ -129,7 +171,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 }); } /** @@ -145,26 +187,21 @@ 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 confirmRemovePatient(): Promise { - await this.removePatientConfirm.click(); + async clickEditPatientDetailsMenuItem(): Promise { + await this.editPatientDetailsButton.click(); } - async getPatientNames(): Promise { - const rows = await this.patientListTable.locator('tbody tr').all(); - const names: string[] = []; - for (const row of rows) { - // Patient name is in the first cell with role='cell' and scope='row' - const nameCell = row.locator('th[role="cell"][scope="row"]'); - // Extract only the first span (patient name) - const nameSpan = nameCell.locator('span').first(); - const name = (await nameSpan.textContent())?.trim() || ''; - if (name) names.push(name); - } - return names; + 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 8ba4457..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 @@ -29,32 +34,6 @@ export class ProfilePage { } } - // //select a Target Range from the dropdown - // async selectTargetRange(index: number): Promise { - // const targetRangeCombo = this.page.getByRole('combobox', { name: 'Target Range' }); - // if (await targetRangeCombo.isVisible({ timeout: 3000 })) { - // await targetRangeCombo.selectOption({ index }); - // } - // } - - // // get the current Target Range index from the dropdown - // async getCurrentTargetRangeIndex(): Promise { - // const targetRangeCombo = this.page.getByRole('combobox', { name: 'Target Range' }); - // if (await targetRangeCombo.isVisible({ timeout: 3000 })) { - // const currentValue = await targetRangeCombo.inputValue(); - // const options = await targetRangeCombo.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 - // } - // Select a diagnosis type from the dropdown async selectDiagnosisType(index: number): Promise { const diagnosisCombo = this.page.getByRole('combobox', { name: 'Diagnosed as' }); @@ -90,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); } @@ -106,40 +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() === 'GET', - ); - - 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'); - - await this.page.reload(); - - // Wait for the GET request to complete (with timeout) - try { - await saveProfilePromise; - } catch (error) { - console.log('โš ๏ธ GET request timeout - continuing anyway'); - } + async saveProfile() { + await this.saveButton.click(); } /** diff --git a/playwright.config.ts b/playwright.config.ts index ce3dd73..288e0b7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,13 +2,6 @@ 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, @@ -34,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, @@ -44,7 +37,8 @@ export default defineConfig({ reporter: [ ['html', { open: 'never', outputFolder: 'playwright-report' }], - ['junit', xrayOptions], + ['json', { outputFile: 'test-results/last-run.json' }], + ['./utilities/xray-json-reporter.ts'], ], use: { @@ -63,7 +57,7 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/personal.json', - headless: false, + headless: !!process.env.CI, }, }, @@ -73,7 +67,7 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/claimed.json', - headless: false, + headless: !!process.env.CI, }, }, @@ -83,7 +77,7 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/clinician.json', - headless: false, + headless: !!process.env.CI, }, }, diff --git a/tests/claimed/API-User/claimed-email-edit.spec.ts b/tests/claimed/API-Profile/AccountSettings-Claimed-EditEmail.spec.ts similarity index 62% rename from tests/claimed/API-User/claimed-email-edit.spec.ts rename to tests/claimed/API-Profile/AccountSettings-Claimed-EditEmail.spec.ts index c2ac03c..d5bb4ba 100644 --- a/tests/claimed/API-User/claimed-email-edit.spec.ts +++ b/tests/claimed/API-Profile/AccountSettings-Claimed-EditEmail.spec.ts @@ -5,20 +5,20 @@ 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, TEST_TAGS.CLAIMED, TEST_TAGS.API, TEST_TAGS.UI, - TEST_TAGS.PRIORITY_HIGH, - TEST_TAGS.API_USER, + TEST_TAGS.HIGH, + TEST_TAGS.API_PROFILE, ]), }, async ({ page }) => { @@ -63,26 +63,31 @@ 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 !== '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('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'); - } - }, - ); + // Re-throw step 7 validation error after email reversion (if any) + if (step7ValidationError) { + throw step7ValidationError; + } await api.stopCapture(); }, 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 90% rename from tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts rename to tests/claimed/API-Profile/AccountSettings-Claimed-EditFullName.spec.ts index 34b7a58..4e65b72 100644 --- a/tests/claimed/API-Profile/claimed-profile-edit-fullname.spec.ts +++ b/tests/claimed/API-Profile/AccountSettings-Claimed-EditFullName.spec.ts @@ -18,23 +18,23 @@ 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, - TEST_TAGS.CLINICIAN, + TEST_TAGS.CLINICIAN, // Added clinician tag TEST_TAGS.CLAIMED, - TEST_TAGS.SHARED_MEMBER, + TEST_TAGS.SHARED_MEMBER, // Added shared member tag TEST_TAGS.API, TEST_TAGS.UI, - TEST_TAGS.PRIORITY_HIGH, + TEST_TAGS.HIGH, TEST_TAGS.API_PROFILE, ]), }, 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(); @@ -148,8 +148,8 @@ test.describe('Claimed Account Settings edit (Full Name only) updates Profile en await patientTest.patient.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 () => { + // 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); }); @@ -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 bc8fc59..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.PRIORITY_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); }, ); @@ -154,7 +162,8 @@ test.describe('Comprehensive Profile Access Test: Edit as Claimed, View as Share 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 1c722f8..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.PRIORITY_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('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 () => { - // 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 birthDate = `05/20/${birthYear}`; - - // Generate random 15-digit MRN - const randomMRN = Array.from({ length: 15 }, () => - Math.floor(Math.random() * 10).toString(), - ).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; - } - - // //Get current Target Range index and calculate next one (1-5, wrapping) - // const currentTargetRangeIndex = await profilePage.getCurrentTargetRangeIndex(); - // let nextTargetRangeIndex = currentTargetRangeIndex + 1; - // if (nextTargetRangeIndex > 4 || nextTargetRangeIndex === 0) { - // nextTargetRangeIndex = 1; - // } - - // Update fields using ProfilePage methods - await profilePage.fillFullName(updatedName); - await profilePage.fillBirthDate(birthDate); - await profilePage.fillMRN(randomMRN); - await profilePage.selectDiagnosisType(nextDiagnosisIndex); - await profilePage.fillEmail(email); - // await profilePage.selectTargetRange(nextTargetRangeIndex); - }); - // 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 GET request consistent with schema [no-screenshot]', async () => { - await api.validateEndpointResponse('profile-metadata-get'); - }); - - // Step 10 : Confirm changes update in UI - await test.step('Then profile changes are reflected in the UI', async () => { - const updatedProfilePage = new ProfilePage(page); - // Add verification logic here as needed - }); - - // await api.stopCapture(); - }, - ); -}); diff --git a/tests/clinician/create-clinic-workspace.spec.ts b/tests/clinician/Clinician-CreateClinicWorkspace.spec.ts similarity index 93% rename from tests/clinician/create-clinic-workspace.spec.ts rename to tests/clinician/Clinician-CreateClinicWorkspace.spec.ts index ac7926f..85eb77e 100644 --- a/tests/clinician/create-clinic-workspace.spec.ts +++ b/tests/clinician/Clinician-CreateClinicWorkspace.spec.ts @@ -5,14 +5,14 @@ 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.PRIORITY_HIGH]), + tag: createValidatedTags([TEST_TAGS.CLINICIAN, TEST_TAGS.UI, TEST_TAGS.HIGH]), }, async ({ page }) => { // Step 1: Login as clinician @@ -31,7 +31,7 @@ test.describe('Custodial patients are allowed access and modification of profile // Create clinic creation page instance const clinicCreationPage = new ClinicCreationPage(page); - // Step 3: Confirm create page exists and is reached. + // Step 3: Confirm create page exists and is rached. await test.step('Then the user navigates to the create patient page', async () => { await expect(page).toHaveURL(/clinic-details\/new/); await expect(clinicCreationPage.pageHeader).toBeVisible(); diff --git a/tests/clinician/edit-clinic-address.spec.ts b/tests/clinician/Clinician-EditClinicDetails.spec.ts similarity index 97% rename from tests/clinician/edit-clinic-address.spec.ts rename to tests/clinician/Clinician-EditClinicDetails.spec.ts index b33c02a..48a7f05 100644 --- a/tests/clinician/edit-clinic-address.spec.ts +++ b/tests/clinician/Clinician-EditClinicDetails.spec.ts @@ -9,9 +9,9 @@ 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.PRIORITY_MEDIUM]), + tag: createValidatedTags([TEST_TAGS.CLINICIAN, TEST_TAGS.UI, TEST_TAGS.MEDIUM]), }, async ({ page }) => { // Step 1: Log in to clinician account and setup network capture diff --git a/tests/clinician/Clinician-FilterPatientsByName.spec.ts b/tests/clinician/Clinician-FilterPatientsByName.spec.ts new file mode 100644 index 0000000..e86a454 --- /dev/null +++ b/tests/clinician/Clinician-FilterPatientsByName.spec.ts @@ -0,0 +1,140 @@ +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'; + +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`; + + 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); + }); + + // Step 2: Navigate to specific workspace + await test.step(`When user navigates to workspace ${workspace}`, async () => { + await test.clinician.navigateToWorkspace(workspace, page); + }); + + // Create pages + const clinicWorkspacePage = new ClinicianDashboardPage(page); + + // 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; + } + + if (!patientAExists) { + await clinicWorkspacePage.openAndFillAddPatientDialog( + patientName1, + patientBirthdate, + patientMRN1, + patientEmail1, + ); + await clinicWorkspacePage.submitAddPatientDialog(); + await clinicWorkspacePage.closeBringDataDialog(); + } + + // 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, + }); + }); + + // Step 5: Filter by Patient A + await test.step("When user filters by Patient A's name", async () => { + await clinicWorkspacePage.searchForPatient(patientName1); + }); + + // 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(); + }); + + // Step 7: Clear the filter + await test.step('When user clears the filter', async () => { + await clinicWorkspacePage.searchForPatient(''); // Clear search by searching for empty string + }); + + // 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(); + }); + }, + ); + }); +}); diff --git a/tests/clinician/add-delete-patient.spec.ts b/tests/clinician/add-delete-patient.spec.ts deleted file mode 100644 index 0a0f47d..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.PRIORITY_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/clinician/filter-patient.spec.ts b/tests/clinician/filter-patient.spec.ts deleted file mode 100644 index 4f572d1..0000000 --- a/tests/clinician/filter-patient.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { expect } from '@fixtures/base'; -import { test, ALL_WORKSPACE_KEYS } from '@fixtures/clinic-helpers'; -import { TEST_TAGS, createValidatedTags } from '@fixtures/test-tags'; -import ClinicianDashboardPage from '@pom/clinician/ClinicianDashboardPage'; - -import type { WorkspaceKey } from '@pom/clinician/ClinicianNavigation'; - -ALL_WORKSPACE_KEYS.forEach((workspace: WorkspaceKey) => { - test.describe('Patient filter functionality in workspace', () => { - test( - `should filter patients correctly in workspace: "[${workspace}]"`, - { - tag: createValidatedTags([TEST_TAGS.CLINICIAN, TEST_TAGS.UI, TEST_TAGS.PRIORITY_MEDIUM]), - }, - async ({ page }) => { - // Step 1: Log in to clinician account - 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 workspace: ${workspace}`, async () => { - await test.clinician.navigateToWorkspace(workspace, page); - }); - - // Define the dashboard - const dashboard = new ClinicianDashboardPage(page); - - // Step 3: Click the Show All toggle button - await test.step('When user clicks the Show All toggle button', async () => { - await dashboard.showAllToggle.click(); - await page.waitForTimeout(1000); - }); - - // Step 4: Define patient list for filtering - let patientNames: string[] = []; - await (test as any).stepNoScreenshot( - 'When user views the patient list contents', - async () => { - patientNames = await dashboard.getPatientNames(); - }, - ); - - // Step 5: Get first two patient names - await test.step('Then at least 2 patient names display in patient list', async () => { - expect(patientNames.length).toBeGreaterThanOrEqual(2); - }); - - // Define patients for later comparison - const patientA = patientNames[0]; - const patientB = patientNames[1]; - - // Step 6: Click the Show All toggle button - await test.step('When user clicks the Show All toggle button', async () => { - await dashboard.showAllToggle.click(); - await page.waitForTimeout(1000); - }); - - // Step 7: Search for patient A - await test.step(`When user searches for patient A: ${patientA}`, async () => { - await dashboard.searchInput.fill(patientA); - await page.waitForTimeout(2000); - }); - - // Step 8:Refresh Patient list for filtering - await (test as any).stepNoScreenshot( - 'When user views the patient list contents', - async () => { - patientNames = await dashboard.getPatientNames(); - }, - ); - - // Step 9: Verify patient A displays in the list - await test.step(`Then patient A: ${patientA} displays in the list`, async () => { - expect(patientNames).toContain(patientA); - }); - - // Step 10: Verify patient B does not display in the list - await test.step(`Then patient B: ${patientB} does not display in the list`, async () => { - expect(patientNames).not.toContain(patientB); - }); - - // Step 11: Clear the search box - await test.step('When user clears the search box', async () => { - await dashboard.searchInput.fill(''); - await page.waitForTimeout(4000); - }); - - // Step 12: Refresh Patient list for filtering - await (test as any).stepNoScreenshot( - 'When user views the patient list contents', - async () => { - patientNames = await dashboard.getPatientNames(); - }, - ); - - // Step 13: Verify both patients display in the list - await test.step('Then both patients display in the list', async () => { - expect(patientNames).toContain(patientA); - expect(patientNames).toContain(patientB); - }); - }, - ); - }); -}); 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..5d78d44 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 nestedPath - 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, nestedPath: string): any { - return nestedPath.split('.').reduce((current, key) => current?.[key], obj); + private getNestedValue(obj: any, propertyPath: string): any { + 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); } /** @@ -544,8 +609,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(); @@ -598,6 +662,72 @@ 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`); + } 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 (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/fixtures/test-tags.ts b/tests/fixtures/test-tags.ts index 7ab682f..8d1ef25 100644 --- a/tests/fixtures/test-tags.ts +++ b/tests/fixtures/test-tags.ts @@ -48,10 +48,10 @@ export const TEST_TAGS = { REGRESSION: '@regression', // Priority - PRIORITY_CRITICAL: '@critical', - PRIORITY_HIGH: '@high', - PRIORITY_MEDIUM: '@medium', - PRIORITY_LOW: '@low', + CRITICAL: '@critical', + HIGH: '@high', + MEDIUM: '@medium', + LOW: '@low', // Endpoint API Testing API_PROFILE: '@api_profile', @@ -62,12 +62,7 @@ export const TEST_TAGS = { 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.PRIORITY_CRITICAL, - TEST_TAGS.PRIORITY_HIGH, - TEST_TAGS.PRIORITY_MEDIUM, - TEST_TAGS.PRIORITY_LOW, - ], + PRIORITIES: [TEST_TAGS.CRITICAL, TEST_TAGS.HIGH, TEST_TAGS.MEDIUM, TEST_TAGS.LOW], }; /** @@ -101,8 +96,7 @@ export function validateRequiredTags(tags: string[]) { export function createValidatedTags(tags: string[]) { const validation = validateRequiredTags(tags); if (!validation.isValid) { - const errorMessage = `Test tags validation failed: ${validation.message}`; - throw new Error(errorMessage); + throw new Error(`Test tags validation failed: ${validation.message}`); } return tags; } 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/AP-Profile/AccountSettings-Personal-EditEmail.spec.ts b/tests/personal/AP-Profile/AccountSettings-Personal-EditEmail.spec.ts new file mode 100644 index 0000000..5a48a62 --- /dev/null +++ b/tests/personal/AP-Profile/AccountSettings-Personal-EditEmail.spec.ts @@ -0,0 +1,113 @@ +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 (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 (always execute to revert email) + 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 }); + }); + + // 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-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 22d01a7..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.PRIORITY_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 75% rename from tests/personal/login.spec.ts rename to tests/personal/Login-Validations.spec.ts index ccd52ba..aed57ae 100644 --- a/tests/personal/login.spec.ts +++ b/tests/personal/Login-Validations.spec.ts @@ -2,8 +2,8 @@ import { expect, test } from '@fixtures/base'; 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: [] } }); @@ -11,9 +11,14 @@ 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, TEST_TAGS.UI, TEST_TAGS.PRIORITY_HIGH]), + tag: createValidatedTags([ + TEST_TAGS.CLINICIAN, + TEST_TAGS.UI, + TEST_TAGS.SMOKE, + TEST_TAGS.CRITICAL, + ]), }, async ({ page }) => { const loginPage = new LoginPage(page); @@ -33,14 +38,19 @@ test.describe('Login into application', () => { ); test( - 'should show error message with invalid credentials', + 'Login - Invalid credentials', { - tag: createValidatedTags([TEST_TAGS.PATIENT, TEST_TAGS.UI, TEST_TAGS.PRIORITY_MEDIUM]), + 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 credentials', async () => { + await test.step('When user attempts to login with invalid username', async () => { await loginPage.goto(); // Enter email @@ -59,17 +69,22 @@ test.describe('Login into application', () => { ); test( - 'should validate email format', + 'Login - Validate email format', { - tag: createValidatedTags([TEST_TAGS.PATIENT, TEST_TAGS.UI, TEST_TAGS.PRIORITY_MEDIUM]), + 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 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'); }); @@ -85,9 +100,14 @@ test.describe('Login into application', () => { ); test( - 'should show error message with invalid credentials 1', + 'Login - Invalid password message displays', { - tag: createValidatedTags([TEST_TAGS.PATIENT, TEST_TAGS.UI, TEST_TAGS.PRIORITY_MEDIUM]), + tag: createValidatedTags([ + TEST_TAGS.CLINICIAN, + TEST_TAGS.UI, + TEST_TAGS.SMOKE, + TEST_TAGS.HIGH, + ]), }, async ({ page }) => { const loginPage = new LoginPage(page); diff --git a/tests/personal/Patient-DisplayFunctionality.spec.ts b/tests/personal/Patient-DisplayFunctionality.spec.ts new file mode 100644 index 0000000..164ef44 --- /dev/null +++ b/tests/personal/Patient-DisplayFunctionality.spec.ts @@ -0,0 +1,286 @@ +// @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: string | null; + + 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('Patient - Bolus Dashboard - Navigation and Visualization', async ({ page }) => { + const basicsPage = new PatientDataBasicsPage(page); + const dailyPage = new PatientDataDailyPage(page); + let selectedDateText: string | null; + + 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: string | null; + + 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/tests/personal/basic-functionality.spec.ts b/tests/personal/basic-functionality.spec.ts deleted file mode 100644 index 0ce9646..0000000 --- a/tests/personal/basic-functionality.spec.ts +++ /dev/null @@ -1,315 +0,0 @@ -// @ts-check -import { expect, test } from '@fixtures/base'; -import { TEST_TAGS, createValidatedTags } from '@fixtures/test-tags'; - -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', - { - tag: createValidatedTags([TEST_TAGS.PATIENT, TEST_TAGS.UI, TEST_TAGS.PRIORITY_HIGH]), - }, - async ({ page }) => { - const basicsPage = new PatientDataBasicsPage(page); - const dailyPage = new PatientDataDailyPage(page); - - let selectedDateText: string | null; - - 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', - { - tag: createValidatedTags([TEST_TAGS.PATIENT, TEST_TAGS.UI, TEST_TAGS.PRIORITY_MEDIUM]), - }, - async ({ page }) => { - const basicsPage = new PatientDataBasicsPage(page); - const dailyPage = new PatientDataDailyPage(page); - let selectedDateText: string | null; - - 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', - { - tag: createValidatedTags([TEST_TAGS.PATIENT, TEST_TAGS.UI, TEST_TAGS.PRIORITY_MEDIUM]), - }, - async ({ page }) => { - const basicsPage = new PatientDataBasicsPage(page); - const dailyPage = new PatientDataDailyPage(page); - let selectedDateText: string | null; - - 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', - { - tag: createValidatedTags([TEST_TAGS.PATIENT, TEST_TAGS.UI, TEST_TAGS.PRIORITY_LOW]), - }, - 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', - { - tag: createValidatedTags([TEST_TAGS.PATIENT, TEST_TAGS.UI, TEST_TAGS.PRIORITY_LOW]), - }, - 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/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/env.ts b/utilities/env.ts index 5c11e15..1a1ef20 100644 --- a/utilities/env.ts +++ b/utilities/env.ts @@ -14,9 +14,14 @@ 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', 'dev1']), 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(), }); const env = envSchema.safeParse(process.env); @@ -32,6 +37,9 @@ 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://external.integration.tidepool.org', // Integration environment + dev1: 'https://dev1.dev.tidepool.org/', // Development environment }; export default { diff --git a/utilities/test-runner.js b/utilities/test-runner.js new file mode 100644 index 0000000..9b8673e --- /dev/null +++ b/utilities/test-runner.js @@ -0,0 +1,166 @@ +/** + * 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 + * + * 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" 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 + */ + +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 with @ prefix + 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) + // Uses positive lookahead regex for AND logic + 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.', + ); + // eslint-disable-next-line n/no-process-exit + 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}`); + 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}`); + if (code !== 0) { + throw new Error(`Playwright tests failed with exit code: ${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(); +} diff --git a/utilities/xray-json-reporter.ts b/utilities/xray-json-reporter.ts new file mode 100644 index 0000000..7ee6ca9 --- /dev/null +++ b/utilities/xray-json-reporter.ts @@ -0,0 +1,729 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { FullConfig, FullResult, Suite, TestCase, TestResult } from '@playwright/test/reporter'; +import env from './env'; +import { + XrayTestStepDefinition, + XrayTestStepResult, + XrayTest, + XrayExecutionResult, + XrayEvidence, + XrayImportResponse, +} from './xray-types'; + +/** + * Xray JSON Reporter for Playwright + * Maps Playwright test data to Xray Cloud JSON format and uploads results + */ +class XrayJsonReporter { + private styles = { + 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', + }; + + /** + * Authenticates with Xray API using client credentials + */ + async authenticateWithXray(): Promise { + const startAuth = Date.now(); + try { + 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: { + '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( + `Authentication failed (HTTP ${response.status}): ${errorText || 'No error details'}`, + ); + } + + const token = await response.text(); + 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; + } + } + + /** + * Maps Playwright test status to Xray Cloud status + * Note: Xray Cloud uses PASSED/FAILED, Xray Server uses PASS/FAIL + */ + private getTestStatus(status: string): 'PASSED' | 'FAILED' | 'TODO' | 'EXECUTING' { + if (status === 'passed') return 'PASSED'; + if (status === 'skipped') return 'TODO'; + return 'FAILED'; + } + + /** + * 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 ''; + } + } + + /** + * Determines if an attachment should be included as evidence + * Videos are only included for failed tests; other files check size threshold + */ + 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)); + + if (!hasData) { + return false; + } + + // Videos: Only for failed tests + if (contentType.includes('video')) { + return testStatus !== 'passed'; + } + + 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(stepPattern), + ); + + for (const attachment of stepAttachments) { + 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'); + } + // 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); + } + + if (base64Data) { + evidence.push({ + data: base64Data, + filename, + contentType, + }); + } + } + } + } + + return evidence; + } + + /** + * 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 }; + } + + 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 }; + } + + /** + * Maps Playwright test result to Xray test format + */ + private async mapPlaywrightTestToXray( + testCase: TestCase, + testResult: TestResult, + ): Promise { + const annotations = testResult.annotations || []; + const attachments = testResult.attachments || []; + const testStatus = testResult.status; + + const { stepDefinitions, stepResults } = await this.extractSteps( + annotations, + attachments, + testStatus, + ); + + // 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'; + } + + // Remove test-level evidence to avoid duplication (using step-level evidence instead) + + return { + testInfo: { + summary: testCase.title, + type: 'Manual', + projectKey: env.XRAY_PROJECT_KEY || 'QAE', + steps: stepDefinitions.length > 0 ? stepDefinitions : undefined, + }, + status: this.getTestStatus(testStatus), + comment: testResult.error?.message, + steps: stepResults.length > 0 ? stepResults : undefined, + }; + } + + /** + * 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[] = []; + + for (const suite of playwrightResult.suites || []) { + await this.processSuite(suite, tests); + } + + 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; + const todoCount = tests.filter(t => t.status === 'TODO').length; + + 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: 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, + }; + } + + /** + * Recursively processes test suites + */ + private async processSuite(suite: any, tests: XrayTest[]): Promise { + 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); + } + } + } + + for (const nestedSuite of suite.suites || []) { + await this.processSuite(nestedSuite, tests); + } + } + + /** + * 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 += 1) { + 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(); + + // 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( + `${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, (key, value) => { + // Skip circular references in upload payload + if (key === 'parent' || key === 'suite' || key === '_parentSuite' || key === '_project') { + return undefined; + } + return value; + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Upload failed (HTTP ${response.status}): ${errorText}`); + } + + 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} 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; + } + } + + /** + * 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 { + 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: ${env.TARGET_ENV}`); + + const testExecKey = env.TEST_EXECUTION_KEY; + 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 + 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`); + return; + } + + await this.uploadToXray(xrayResult); + + 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 Playwright integration + */ + 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`); + } + + 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 { + 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`); + + const testExecKey = env.TEST_EXECUTION_KEY; + if (env.XRAY_CLIENT_ID && env.XRAY_CLIENT_SECRET && testExecKey && testExecKey !== 'none') { + 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); + } + } + } +} + +export default XrayJsonReporter; 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; + }[]; +}